Skip to content

Commit cd11fae

Browse files
jriekenconnor4312
andauthored
CVE-2026-21518 (MSRC#106249) (#32)
* strings: add a punycode encoding function Reuses existing unicode constructs we have. Verified against the RFC. Implemented with Opus, reviewed with Sonnet4.5, Gemini 3 Pro, and GPT 5.2. * mcp: explicitly request workspace trust to start MCP servers --------- Co-authored-by: Connor Peet <connor@peet.io>
1 parent 9d87ffd commit cd11fae

3 files changed

Lines changed: 287 additions & 0 deletions

File tree

src/vs/base/common/strings.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,3 +1409,179 @@ function toBinary(str: string): string {
14091409
export function multibyteAwareBtoa(str: string): string {
14101410
return btoa(toBinary(str));
14111411
}
1412+
1413+
//#region Punycode
1414+
1415+
// RFC 3492 constants
1416+
const enum PunycodeConstants {
1417+
BASE = 36,
1418+
TMIN = 1,
1419+
TMAX = 26,
1420+
SKEW = 38,
1421+
DAMP = 700,
1422+
INITIAL_BIAS = 72,
1423+
INITIAL_N = 128,
1424+
DELIMITER = CharCode.Dash
1425+
}
1426+
1427+
/**
1428+
* Encodes a digit value (0-35) to its punycode character.
1429+
* 0-25 -> 'a'-'z', 26-35 -> '0'-'9'
1430+
*/
1431+
function encodePunycodeDigit(d: number): number {
1432+
return d < 26 ? CharCode.a + d : CharCode.Digit0 + d - 26;
1433+
}
1434+
1435+
/**
1436+
* Adapts the bias according to RFC 3492 section 3.4.
1437+
*/
1438+
function adaptPunycodeBias(delta: number, numPoints: number, firstTime: boolean): number {
1439+
delta = firstTime ? Math.floor(delta / PunycodeConstants.DAMP) : delta >> 1;
1440+
delta += Math.floor(delta / numPoints);
1441+
1442+
let k = 0;
1443+
const baseMinusTmin = PunycodeConstants.BASE - PunycodeConstants.TMIN;
1444+
while (delta > (baseMinusTmin * PunycodeConstants.TMAX) >> 1) {
1445+
delta = Math.floor(delta / baseMinusTmin);
1446+
k += PunycodeConstants.BASE;
1447+
}
1448+
return k + Math.floor((baseMinusTmin + 1) * delta / (delta + PunycodeConstants.SKEW));
1449+
}
1450+
1451+
/**
1452+
* Encodes a Unicode string to Punycode according to RFC 3492.
1453+
* This is the raw encoding without the "xn--" ACE prefix.
1454+
*
1455+
* @param input The Unicode string to encode.
1456+
* @returns The Punycode-encoded ASCII string.
1457+
* @throws Error if the input contains invalid surrogate pairs.
1458+
*
1459+
* @example
1460+
* // allow-any-unicode-next-line
1461+
* punycodeEncode('münchen') // returns 'mnchen-3ya'
1462+
* // allow-any-unicode-next-line
1463+
* punycodeEncode('bücher') // returns 'bcher-kva'
1464+
* // allow-any-unicode-next-line
1465+
* punycodeEncode('日本語') // returns 'wgv71a119e'
1466+
*/
1467+
export function punycodeEncode(input: string): string {
1468+
const output: number[] = [];
1469+
1470+
// Collect all code points using the existing CodePointIterator
1471+
const codePoints: number[] = [];
1472+
const iterator = new CodePointIterator(input);
1473+
while (!iterator.eol()) {
1474+
const cp = iterator.nextCodePoint();
1475+
// Check for lone surrogates (invalid Unicode)
1476+
if (cp >= 0xD800 && cp <= 0xDFFF) {
1477+
throw new Error('Invalid surrogate pair in input');
1478+
}
1479+
codePoints.push(cp);
1480+
}
1481+
1482+
// Copy basic code points to output
1483+
let basicCount = 0;
1484+
for (const cp of codePoints) {
1485+
if (cp < PunycodeConstants.INITIAL_N) {
1486+
output.push(cp);
1487+
basicCount++;
1488+
}
1489+
}
1490+
1491+
// Add delimiter if there were basic code points and there are non-basic ones
1492+
const handledCount = basicCount;
1493+
if (basicCount > 0 && basicCount < codePoints.length) {
1494+
output.push(PunycodeConstants.DELIMITER);
1495+
}
1496+
1497+
// Main encoding loop
1498+
let n = PunycodeConstants.INITIAL_N;
1499+
let delta = 0;
1500+
let bias = PunycodeConstants.INITIAL_BIAS;
1501+
let h = handledCount;
1502+
1503+
while (h < codePoints.length) {
1504+
// Find the minimum code point >= n
1505+
let m = 0x10FFFF; // Maximum valid Unicode code point
1506+
for (const cp of codePoints) {
1507+
if (cp >= n && cp < m) {
1508+
m = cp;
1509+
}
1510+
}
1511+
1512+
// Increase delta to account for skipped code points
1513+
const deltaIncrement = (m - n) * (h + 1);
1514+
if (delta > Number.MAX_SAFE_INTEGER - deltaIncrement) {
1515+
throw new Error('Punycode overflow');
1516+
}
1517+
delta += deltaIncrement;
1518+
n = m;
1519+
1520+
// Process each code point
1521+
for (const cp of codePoints) {
1522+
if (cp < n) {
1523+
delta++;
1524+
if (delta === 0) {
1525+
throw new Error('Punycode overflow');
1526+
}
1527+
} else if (cp === n) {
1528+
// Encode delta as a variable-length integer
1529+
let q = delta;
1530+
for (let k = PunycodeConstants.BASE; ; k += PunycodeConstants.BASE) {
1531+
const t = k <= bias ? PunycodeConstants.TMIN :
1532+
k >= bias + PunycodeConstants.TMAX ? PunycodeConstants.TMAX :
1533+
k - bias;
1534+
if (q < t) {
1535+
break;
1536+
}
1537+
const digit = t + ((q - t) % (PunycodeConstants.BASE - t));
1538+
output.push(encodePunycodeDigit(digit));
1539+
q = Math.floor((q - t) / (PunycodeConstants.BASE - t));
1540+
}
1541+
output.push(encodePunycodeDigit(q));
1542+
bias = adaptPunycodeBias(delta, h + 1, h === handledCount);
1543+
delta = 0;
1544+
h++;
1545+
}
1546+
}
1547+
delta++;
1548+
n++;
1549+
}
1550+
1551+
let ret = '';
1552+
for (const ch of output) {
1553+
ret += String.fromCharCode(ch);
1554+
}
1555+
return ret;
1556+
}
1557+
1558+
/**
1559+
* Encodes a domain label using Punycode with the ACE prefix "xn--".
1560+
* If the label contains only ASCII characters, it is returned unchanged.
1561+
*
1562+
* @param label The domain label to encode.
1563+
* @returns The ACE-encoded label with "xn--" prefix, or the original label if all ASCII.
1564+
*
1565+
* @example
1566+
* // allow-any-unicode-next-line
1567+
* toPunycodeACE('münchen') // returns 'xn--mnchen-3ya'
1568+
* toPunycodeACE('example') // returns 'example' (no encoding needed)
1569+
*/
1570+
export function toPunycodeACE(label: string): string {
1571+
// Check if the label contains only ASCII characters
1572+
let hasNonASCII = false;
1573+
for (let i = 0; i < label.length; i++) {
1574+
if (label.charCodeAt(i) >= PunycodeConstants.INITIAL_N) {
1575+
hasNonASCII = true;
1576+
break;
1577+
}
1578+
}
1579+
1580+
if (!hasNonASCII) {
1581+
return label;
1582+
}
1583+
1584+
return 'xn--' + punycodeEncode(label);
1585+
}
1586+
1587+
//#endregion

src/vs/base/test/common/strings.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,106 @@ suite('Strings', () => {
755755
assert.ok(strings.multibyteAwareBtoa(new Array(100000).fill('vs').join('')).length > 0); // https://github.com/microsoft/vscode/issues/112013
756756
});
757757

758+
suite('punycode', () => {
759+
test('punycodeEncode - basic ASCII only', () => {
760+
// Pure ASCII strings should be returned as-is
761+
assert.strictEqual(strings.punycodeEncode('abc'), 'abc');
762+
assert.strictEqual(strings.punycodeEncode('hello'), 'hello');
763+
assert.strictEqual(strings.punycodeEncode('example'), 'example');
764+
});
765+
766+
test('punycodeEncode - empty string', () => {
767+
assert.strictEqual(strings.punycodeEncode(''), '');
768+
});
769+
770+
test('punycodeEncode - German words', () => {
771+
// "münchen" -> "mnchen-3ya"
772+
assert.strictEqual(strings.punycodeEncode('münchen'), 'mnchen-3ya');
773+
// "bücher" -> "bcher-kva"
774+
assert.strictEqual(strings.punycodeEncode('bücher'), 'bcher-kva');
775+
});
776+
777+
test('punycodeEncode - Chinese', () => {
778+
// "中文" -> "fiq228c"
779+
assert.strictEqual(strings.punycodeEncode('中文'), 'fiq228c');
780+
});
781+
782+
test('punycodeEncode - Japanese', () => {
783+
// "日本語" -> "wgv71a119e"
784+
assert.strictEqual(strings.punycodeEncode('日本語'), 'wgv71a119e');
785+
});
786+
787+
test('punycodeEncode - Arabic', () => {
788+
// RFC 3492 example (A) - Arabic (Egyptian)
789+
assert.strictEqual(
790+
strings.punycodeEncode('ليهمابتكلموشعربي؟'),
791+
'egbpdaj6bu4bxfgehfvwxn'
792+
);
793+
});
794+
795+
test('punycodeEncode - mixed ASCII and non-ASCII', () => {
796+
// "café" -> "caf-dma"
797+
assert.strictEqual(strings.punycodeEncode('café'), 'caf-dma');
798+
});
799+
800+
test('punycodeEncode - supplementary plane characters', () => {
801+
// Emoji test - "💻" (U+1F4BB = 128187 decimal)
802+
assert.strictEqual(strings.punycodeEncode('💻'), '3s8h');
803+
// Mixed with ASCII - "a💻b"
804+
assert.strictEqual(strings.punycodeEncode('a💻b'), 'ab-sv72a');
805+
});
806+
807+
test('punycodeEncode - RFC 3492 test vectors', () => {
808+
// (B) Chinese (simplified) - "他们为什么不说中文"
809+
assert.strictEqual(
810+
strings.punycodeEncode('\u4ed6\u4eec\u4e3a\u4ec0\u4e48\u4e0d\u8bf4\u4e2d\u6587'),
811+
'ihqwcrb4cv8a8dqg056pqjye'
812+
);
813+
814+
// (C) Chinese (traditional) - "他們爲什麽不說中文"
815+
assert.strictEqual(
816+
strings.punycodeEncode('\u4ed6\u5011\u7232\u4ec0\u9ebd\u4e0d\u8aaa\u4e2d\u6587'),
817+
'ihqwctvzc91f659drss3x8bo0yb'
818+
);
819+
820+
// (D) Czech - "Pročprostěnemluvíčesky" - Note: uppercase P is preserved
821+
assert.strictEqual(
822+
strings.punycodeEncode('Pro\u010dprost\u011bnemluv\u00ed\u010desky'),
823+
'Proprostnemluvesky-uyb24dma41a'
824+
);
825+
826+
// (L) Japanese - "3年B組金八先生" (3nen B gumi Kinpachi sensei)
827+
// Note: uppercase B is preserved in output
828+
assert.strictEqual(
829+
strings.punycodeEncode('3\u5e74B\u7d44\u91d1\u516b\u5148\u751f'),
830+
'3B-ww4c5e180e575a65lsy2b'
831+
);
832+
833+
// (M) Japanese - "安室奈美恵-with-SUPER-MONKEYS"
834+
// Note: ASCII characters preserve their original case
835+
assert.strictEqual(
836+
strings.punycodeEncode('\u5b89\u5ba4\u5948\u7f8e\u6075-with-SUPER-MONKEYS'),
837+
'-with-SUPER-MONKEYS-pc58ag80a8qai00g7n9n'
838+
);
839+
});
840+
841+
test('toACE - returns ASCII unchanged', () => {
842+
assert.strictEqual(strings.toPunycodeACE('example'), 'example');
843+
assert.strictEqual(strings.toPunycodeACE('hello-world'), 'hello-world');
844+
assert.strictEqual(strings.toPunycodeACE('test123'), 'test123');
845+
});
846+
847+
test('toACE - adds xn-- prefix for non-ASCII', () => {
848+
assert.strictEqual(strings.toPunycodeACE('münchen'), 'xn--mnchen-3ya');
849+
assert.strictEqual(strings.toPunycodeACE('bücher'), 'xn--bcher-kva');
850+
assert.strictEqual(strings.toPunycodeACE('日本語'), 'xn--wgv71a119e');
851+
});
852+
853+
test('toACE - empty string', () => {
854+
assert.strictEqual(strings.toPunycodeACE(''), '');
855+
});
856+
});
857+
758858
ensureNoDisposablesAreLeakedInTestSuite();
759859
});
760860

src/vs/workbench/contrib/mcp/common/mcpRegistry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { observableConfigValue } from '../../../../platform/observable/common/pl
2626
import { IQuickInputButton, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
2727
import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
2828
import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js';
29+
import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js';
2930
import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js';
3031
import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js';
3132
import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js';
@@ -85,6 +86,8 @@ export class McpRegistry extends Disposable implements IMcpRegistry {
8586
@IQuickInputService private readonly _quickInputService: IQuickInputService,
8687
@ILabelService private readonly _labelService: ILabelService,
8788
@ILogService private readonly _logService: ILogService,
89+
@IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService,
90+
@IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService,
8891
) {
8992
super();
9093
this._mcpAccessValue = observableConfigValue(mcpAccessConfig, McpAccessValue.All, configurationService);
@@ -213,6 +216,14 @@ export class McpRegistry extends Disposable implements IMcpRegistry {
213216
autoTrustChanges = false,
214217
errorOnUserInteraction = false,
215218
}: IMcpResolveConnectionOptions) {
219+
if (collection.scope === StorageScope.WORKSPACE && !this._workspaceTrustManagementService.isWorkspaceTrusted()) {
220+
if (errorOnUserInteraction) {
221+
throw new UserInteractionRequiredError('workspaceTrust');
222+
} else if (!await this._workspaceTrustRequestService.requestWorkspaceTrust({ message: localize('runTrust', "This MCP server definition is defined in your workspace files.") })) {
223+
return false;
224+
}
225+
}
226+
216227
if (collection.trustBehavior === McpServerTrust.Kind.Trusted) {
217228
this._logService.trace(`MCP server ${definition.id} is trusted, no trust prompt needed`);
218229
return true;

0 commit comments

Comments
 (0)