From cd11faec7b031b928bc5ec37f350d623ffb28713 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Mon, 9 Feb 2026 08:38:37 +0100 Subject: [PATCH] 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 --- src/vs/base/common/strings.ts | 176 ++++++++++++++++++ src/vs/base/test/common/strings.test.ts | 100 ++++++++++ .../contrib/mcp/common/mcpRegistry.ts | 11 ++ 3 files changed, 287 insertions(+) diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index 146bd1d690f..3802d3ff356 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -1409,3 +1409,179 @@ function toBinary(str: string): string { export function multibyteAwareBtoa(str: string): string { return btoa(toBinary(str)); } + +//#region Punycode + +// RFC 3492 constants +const enum PunycodeConstants { + BASE = 36, + TMIN = 1, + TMAX = 26, + SKEW = 38, + DAMP = 700, + INITIAL_BIAS = 72, + INITIAL_N = 128, + DELIMITER = CharCode.Dash +} + +/** + * Encodes a digit value (0-35) to its punycode character. + * 0-25 -> 'a'-'z', 26-35 -> '0'-'9' + */ +function encodePunycodeDigit(d: number): number { + return d < 26 ? CharCode.a + d : CharCode.Digit0 + d - 26; +} + +/** + * Adapts the bias according to RFC 3492 section 3.4. + */ +function adaptPunycodeBias(delta: number, numPoints: number, firstTime: boolean): number { + delta = firstTime ? Math.floor(delta / PunycodeConstants.DAMP) : delta >> 1; + delta += Math.floor(delta / numPoints); + + let k = 0; + const baseMinusTmin = PunycodeConstants.BASE - PunycodeConstants.TMIN; + while (delta > (baseMinusTmin * PunycodeConstants.TMAX) >> 1) { + delta = Math.floor(delta / baseMinusTmin); + k += PunycodeConstants.BASE; + } + return k + Math.floor((baseMinusTmin + 1) * delta / (delta + PunycodeConstants.SKEW)); +} + +/** + * Encodes a Unicode string to Punycode according to RFC 3492. + * This is the raw encoding without the "xn--" ACE prefix. + * + * @param input The Unicode string to encode. + * @returns The Punycode-encoded ASCII string. + * @throws Error if the input contains invalid surrogate pairs. + * + * @example + * // allow-any-unicode-next-line + * punycodeEncode('münchen') // returns 'mnchen-3ya' + * // allow-any-unicode-next-line + * punycodeEncode('bücher') // returns 'bcher-kva' + * // allow-any-unicode-next-line + * punycodeEncode('日本語') // returns 'wgv71a119e' + */ +export function punycodeEncode(input: string): string { + const output: number[] = []; + + // Collect all code points using the existing CodePointIterator + const codePoints: number[] = []; + const iterator = new CodePointIterator(input); + while (!iterator.eol()) { + const cp = iterator.nextCodePoint(); + // Check for lone surrogates (invalid Unicode) + if (cp >= 0xD800 && cp <= 0xDFFF) { + throw new Error('Invalid surrogate pair in input'); + } + codePoints.push(cp); + } + + // Copy basic code points to output + let basicCount = 0; + for (const cp of codePoints) { + if (cp < PunycodeConstants.INITIAL_N) { + output.push(cp); + basicCount++; + } + } + + // Add delimiter if there were basic code points and there are non-basic ones + const handledCount = basicCount; + if (basicCount > 0 && basicCount < codePoints.length) { + output.push(PunycodeConstants.DELIMITER); + } + + // Main encoding loop + let n = PunycodeConstants.INITIAL_N; + let delta = 0; + let bias = PunycodeConstants.INITIAL_BIAS; + let h = handledCount; + + while (h < codePoints.length) { + // Find the minimum code point >= n + let m = 0x10FFFF; // Maximum valid Unicode code point + for (const cp of codePoints) { + if (cp >= n && cp < m) { + m = cp; + } + } + + // Increase delta to account for skipped code points + const deltaIncrement = (m - n) * (h + 1); + if (delta > Number.MAX_SAFE_INTEGER - deltaIncrement) { + throw new Error('Punycode overflow'); + } + delta += deltaIncrement; + n = m; + + // Process each code point + for (const cp of codePoints) { + if (cp < n) { + delta++; + if (delta === 0) { + throw new Error('Punycode overflow'); + } + } else if (cp === n) { + // Encode delta as a variable-length integer + let q = delta; + for (let k = PunycodeConstants.BASE; ; k += PunycodeConstants.BASE) { + const t = k <= bias ? PunycodeConstants.TMIN : + k >= bias + PunycodeConstants.TMAX ? PunycodeConstants.TMAX : + k - bias; + if (q < t) { + break; + } + const digit = t + ((q - t) % (PunycodeConstants.BASE - t)); + output.push(encodePunycodeDigit(digit)); + q = Math.floor((q - t) / (PunycodeConstants.BASE - t)); + } + output.push(encodePunycodeDigit(q)); + bias = adaptPunycodeBias(delta, h + 1, h === handledCount); + delta = 0; + h++; + } + } + delta++; + n++; + } + + let ret = ''; + for (const ch of output) { + ret += String.fromCharCode(ch); + } + return ret; +} + +/** + * Encodes a domain label using Punycode with the ACE prefix "xn--". + * If the label contains only ASCII characters, it is returned unchanged. + * + * @param label The domain label to encode. + * @returns The ACE-encoded label with "xn--" prefix, or the original label if all ASCII. + * + * @example + * // allow-any-unicode-next-line + * toPunycodeACE('münchen') // returns 'xn--mnchen-3ya' + * toPunycodeACE('example') // returns 'example' (no encoding needed) + */ +export function toPunycodeACE(label: string): string { + // Check if the label contains only ASCII characters + let hasNonASCII = false; + for (let i = 0; i < label.length; i++) { + if (label.charCodeAt(i) >= PunycodeConstants.INITIAL_N) { + hasNonASCII = true; + break; + } + } + + if (!hasNonASCII) { + return label; + } + + return 'xn--' + punycodeEncode(label); +} + +//#endregion diff --git a/src/vs/base/test/common/strings.test.ts b/src/vs/base/test/common/strings.test.ts index aeac4aa7b16..cfbcf1948e4 100644 --- a/src/vs/base/test/common/strings.test.ts +++ b/src/vs/base/test/common/strings.test.ts @@ -755,6 +755,106 @@ suite('Strings', () => { assert.ok(strings.multibyteAwareBtoa(new Array(100000).fill('vs').join('')).length > 0); // https://github.com/microsoft/vscode/issues/112013 }); + suite('punycode', () => { + test('punycodeEncode - basic ASCII only', () => { + // Pure ASCII strings should be returned as-is + assert.strictEqual(strings.punycodeEncode('abc'), 'abc'); + assert.strictEqual(strings.punycodeEncode('hello'), 'hello'); + assert.strictEqual(strings.punycodeEncode('example'), 'example'); + }); + + test('punycodeEncode - empty string', () => { + assert.strictEqual(strings.punycodeEncode(''), ''); + }); + + test('punycodeEncode - German words', () => { + // "münchen" -> "mnchen-3ya" + assert.strictEqual(strings.punycodeEncode('münchen'), 'mnchen-3ya'); + // "bücher" -> "bcher-kva" + assert.strictEqual(strings.punycodeEncode('bücher'), 'bcher-kva'); + }); + + test('punycodeEncode - Chinese', () => { + // "中文" -> "fiq228c" + assert.strictEqual(strings.punycodeEncode('中文'), 'fiq228c'); + }); + + test('punycodeEncode - Japanese', () => { + // "日本語" -> "wgv71a119e" + assert.strictEqual(strings.punycodeEncode('日本語'), 'wgv71a119e'); + }); + + test('punycodeEncode - Arabic', () => { + // RFC 3492 example (A) - Arabic (Egyptian) + assert.strictEqual( + strings.punycodeEncode('ليهمابتكلموشعربي؟'), + 'egbpdaj6bu4bxfgehfvwxn' + ); + }); + + test('punycodeEncode - mixed ASCII and non-ASCII', () => { + // "café" -> "caf-dma" + assert.strictEqual(strings.punycodeEncode('café'), 'caf-dma'); + }); + + test('punycodeEncode - supplementary plane characters', () => { + // Emoji test - "💻" (U+1F4BB = 128187 decimal) + assert.strictEqual(strings.punycodeEncode('💻'), '3s8h'); + // Mixed with ASCII - "a💻b" + assert.strictEqual(strings.punycodeEncode('a💻b'), 'ab-sv72a'); + }); + + test('punycodeEncode - RFC 3492 test vectors', () => { + // (B) Chinese (simplified) - "他们为什么不说中文" + assert.strictEqual( + strings.punycodeEncode('\u4ed6\u4eec\u4e3a\u4ec0\u4e48\u4e0d\u8bf4\u4e2d\u6587'), + 'ihqwcrb4cv8a8dqg056pqjye' + ); + + // (C) Chinese (traditional) - "他們爲什麽不說中文" + assert.strictEqual( + strings.punycodeEncode('\u4ed6\u5011\u7232\u4ec0\u9ebd\u4e0d\u8aaa\u4e2d\u6587'), + 'ihqwctvzc91f659drss3x8bo0yb' + ); + + // (D) Czech - "Pročprostěnemluvíčesky" - Note: uppercase P is preserved + assert.strictEqual( + strings.punycodeEncode('Pro\u010dprost\u011bnemluv\u00ed\u010desky'), + 'Proprostnemluvesky-uyb24dma41a' + ); + + // (L) Japanese - "3年B組金八先生" (3nen B gumi Kinpachi sensei) + // Note: uppercase B is preserved in output + assert.strictEqual( + strings.punycodeEncode('3\u5e74B\u7d44\u91d1\u516b\u5148\u751f'), + '3B-ww4c5e180e575a65lsy2b' + ); + + // (M) Japanese - "安室奈美恵-with-SUPER-MONKEYS" + // Note: ASCII characters preserve their original case + assert.strictEqual( + strings.punycodeEncode('\u5b89\u5ba4\u5948\u7f8e\u6075-with-SUPER-MONKEYS'), + '-with-SUPER-MONKEYS-pc58ag80a8qai00g7n9n' + ); + }); + + test('toACE - returns ASCII unchanged', () => { + assert.strictEqual(strings.toPunycodeACE('example'), 'example'); + assert.strictEqual(strings.toPunycodeACE('hello-world'), 'hello-world'); + assert.strictEqual(strings.toPunycodeACE('test123'), 'test123'); + }); + + test('toACE - adds xn-- prefix for non-ASCII', () => { + assert.strictEqual(strings.toPunycodeACE('münchen'), 'xn--mnchen-3ya'); + assert.strictEqual(strings.toPunycodeACE('bücher'), 'xn--bcher-kva'); + assert.strictEqual(strings.toPunycodeACE('日本語'), 'xn--wgv71a119e'); + }); + + test('toACE - empty string', () => { + assert.strictEqual(strings.toPunycodeACE(''), ''); + }); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index 084d2f5126c..d51ad00f53c 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -26,6 +26,7 @@ import { observableConfigValue } from '../../../../platform/observable/common/pl import { IQuickInputButton, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; @@ -85,6 +86,8 @@ export class McpRegistry extends Disposable implements IMcpRegistry { @IQuickInputService private readonly _quickInputService: IQuickInputService, @ILabelService private readonly _labelService: ILabelService, @ILogService private readonly _logService: ILogService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); this._mcpAccessValue = observableConfigValue(mcpAccessConfig, McpAccessValue.All, configurationService); @@ -213,6 +216,14 @@ export class McpRegistry extends Disposable implements IMcpRegistry { autoTrustChanges = false, errorOnUserInteraction = false, }: IMcpResolveConnectionOptions) { + if (collection.scope === StorageScope.WORKSPACE && !this._workspaceTrustManagementService.isWorkspaceTrusted()) { + if (errorOnUserInteraction) { + throw new UserInteractionRequiredError('workspaceTrust'); + } else if (!await this._workspaceTrustRequestService.requestWorkspaceTrust({ message: localize('runTrust', "This MCP server definition is defined in your workspace files.") })) { + return false; + } + } + if (collection.trustBehavior === McpServerTrust.Kind.Trusted) { this._logService.trace(`MCP server ${definition.id} is trusted, no trust prompt needed`); return true;