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>
This commit is contained in:
Johannes Rieken
2026-02-09 08:38:37 +01:00
committed by GitHub
parent 9d87ffd102
commit cd11faec7b
3 changed files with 287 additions and 0 deletions

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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;