From 581c55ad990a7a0279234118cc5048596c338390 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 18 Sep 2026 03:54:43 -0700 Subject: [PATCH 1/5] json: align schema URL trust with request destinations Canonicalize schema URLs before trusted-domain matching and request dispatch. Reject desktop requests when the legacy HTTP transport would select a different host, port, or request target. Cover browser and desktop URL handling with offline regression tests that preserve trust gates and exercise native request validation without opening sockets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../client/src/jsonClient.ts | 40 ++- .../client/src/node/jsonClientMain.ts | 16 + .../client/src/test/schemaRequestTestUtils.ts | 233 ++++++++++++++ .../src/test/schemaRequestTransport.test.ts | 283 ++++++++++++++++++ .../test/schemaRequestTransportTestUtils.ts | 112 +++++++ .../client/src/test/schemaRequests.test.ts | 191 ++++++++++++ .../client/src/utils/urlMatch.ts | 27 +- 7 files changed, 884 insertions(+), 18 deletions(-) create mode 100644 extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts create mode 100644 extensions/json-language-features/client/src/test/schemaRequestTransport.test.ts create mode 100644 extensions/json-language-features/client/src/test/schemaRequestTransportTestUtils.ts create mode 100644 extensions/json-language-features/client/src/test/schemaRequests.test.ts diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index fa1c6d10209921..b941db70fd29d3 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -22,7 +22,7 @@ import { import { hash } from './utils/hash'; import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem, createSchemaLoadIssueItem, createSchemaLoadStatusItem } from './languageStatus'; import { getLanguageParticipants, LanguageParticipants } from './languageParticipants'; -import { matchesUrlPattern } from './utils/urlMatch'; +import { getSchemaRequestUrl, matchesUrlPattern } from './utils/urlMatch'; namespace VSCodeContentRequest { export const type: RequestType = new RequestType('vscode/content'); @@ -419,10 +419,16 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP if (!workspace.isTrusted) { throw new ResponseError(SchemaRequestServiceErrors.UntrustedWorkspaceError, l10n.t('Downloading schemas is disabled in untrusted workspaces')); } - if (!await isTrusted(uri)) { - throw new ResponseError(SchemaRequestServiceErrors.UntrustedSchemaError, l10n.t('Location {0} is untrusted', uriString)); + let requestUrl: URL; + try { + requestUrl = getSchemaRequestUrl(uri); + } catch (e) { + throw new ResponseError(SchemaRequestServiceErrors.HTTPError, e.toString(), e); + } + if (!await isTrusted(requestUrl)) { + throw new ResponseError(SchemaRequestServiceErrors.UntrustedSchemaError, l10n.t('Location {0} is untrusted', requestUrl.href)); } - if (runtime.telemetry && uri.authority === 'schema.management.azure.com') { + if (runtime.telemetry && requestUrl.host === 'schema.management.azure.com') { /* __GDPR__ "json.schema" : { "owner": "aeschli", @@ -430,10 +436,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP "schemaURL" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The azure schema URL that was requested." } } */ - runtime.telemetry.sendTelemetryEvent('json.schema', { schemaURL: uriString }); + runtime.telemetry.sendTelemetryEvent('json.schema', { schemaURL: requestUrl.href }); } try { - return await runtime.schemaRequests.getContent(uriString); + return await runtime.schemaRequests.getContent(requestUrl.href); } catch (e) { throw new ResponseError(SchemaRequestServiceErrors.HTTPError, e.toString(), e); } @@ -689,20 +695,23 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP return schemaAssociationsCache; } - async function isTrusted(uri: Uri): Promise { - if (uri.scheme !== 'http' && uri.scheme !== 'https') { - return true; - } - const uriString = uri.toString(true); - + async function isTrusted(url: URL): Promise { // Check against trustedDomains setting - if (matchesUrlPattern(uri, trustedDomains)) { + if (matchesUrlPattern(url, trustedDomains)) { return true; } + const matchesSchemaUri = (uri: string): boolean => { + try { + return getSchemaRequestUrl(Uri.parse(uri)).href === url.href; + } catch { + return false; + } + }; + const knownAssociations = await getSchemaAssociations(false); for (const association of knownAssociations) { - if (association.uri === uriString) { + if (matchesSchemaUri(association.uri)) { return true; } } @@ -710,7 +719,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP if (settingsCache.json && settingsCache.json.schemas) { for (const schemaSetting of settingsCache.json.schemas) { const schemaUri = schemaSetting.url; - if (schemaUri === uriString) { + if (schemaUri && matchesSchemaUri(schemaUri)) { return true; } } @@ -996,4 +1005,3 @@ export namespace ErrorCodes { export function isSchemaResolveError(d: Diagnostic) { return typeof d.code === 'number' && d.code >= ErrorCodes.SchemaResolveError; } - diff --git a/extensions/json-language-features/client/src/node/jsonClientMain.ts b/extensions/json-language-features/client/src/node/jsonClientMain.ts index 8e387c122f0c28..e5ad7543c7eab2 100644 --- a/extensions/json-language-features/client/src/node/jsonClientMain.ts +++ b/extensions/json-language-features/client/src/node/jsonClientMain.ts @@ -9,6 +9,7 @@ import { ServerOptions, TransportKind, LanguageClientOptions, LanguageClient } f import { promises as fs } from 'fs'; import * as path from 'path'; +import { parse as parseUrl } from 'url'; import { xhr, XHRResponse, getErrorStatusDescription, Headers } from 'request-light'; import TelemetryReporter from '@vscode/extension-telemetry'; @@ -158,6 +159,21 @@ async function getSchemaRequestService(context: ExtensionContext, log: LogOutput return { getContent: async (uri: string) => { + // request-light's legacy parser must preserve the destination approved by the common client. + const approvedUrl = new URL(uri); + const transportUrl = parseUrl(uri); + const hostname = approvedUrl.hostname.startsWith('[') ? approvedUrl.hostname.slice(1, -1) : approvedUrl.hostname; + const defaultPort = approvedUrl.protocol === 'https:' ? 443 : 80; + // Native requests also replace port zero with the protocol's default port. + const transportPort = Number(transportUrl.port) || defaultPort; + if ((approvedUrl.protocol !== 'http:' && approvedUrl.protocol !== 'https:') + || transportUrl.protocol !== approvedUrl.protocol + || transportUrl.hostname !== hostname + || transportPort !== Number(approvedUrl.port || defaultPort) + || transportUrl.path !== approvedUrl.pathname + approvedUrl.search) { + throw new Error(l10n.t('Unable to download schema from {0} because the HTTP transport interprets the URL differently.', uri)); + } + if (cache && /^https?:\/\/(json|www)\.schemastore\.org\//.test(uri)) { const content = await cache.getSchemaIfUpdatedSince(uri, retryTimeoutInHours); if (content) { diff --git a/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts new file mode 100644 index 00000000000000..54089d843e20ab --- /dev/null +++ b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts @@ -0,0 +1,233 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { readFileSync } from 'fs'; +import { createRequire } from 'module'; +import * as path from 'path'; +import { parse } from 'url'; +import { compileFunction, createContext } from 'vm'; +import type { ExtensionContext, Uri } from 'vscode'; + +type UrlMatcher = typeof import('../utils/urlMatch'); +type ClientMain = typeof import('../node/jsonClientMain'); + +export interface RequestOptions { + trustedDomains: Record; + downloadEnabled?: boolean; + workspaceTrusted?: boolean; + schemas?: { url: string }[]; + extensionSchemas?: string[]; +} + +export interface RequestRecord { + url: string; + host: string | null; + path: string | null; +} + +class Disposable { + constructor(private readonly callback: () => void = () => { }) { } + dispose() { this.callback(); } + static from(...disposables: Disposable[]) { + return new Disposable(() => disposables.forEach(disposable => disposable.dispose())); + } +} + +class MessageType { + constructor(readonly method: string) { } +} + +class ResponseError extends Error { + constructor(readonly code: number, message: string, readonly data?: object) { + super(message); + } +} + +async function getUri(): Promise { + const uriModule = path.join(__dirname, '..', '..', '..', '..', '..', 'out', 'vs', 'base', 'common', 'uri.js'); + return createRequire(__filename)(uriModule).URI; +} + +function createLoader(mocks: Record, globals: Record = {}) { + const context = createContext({ URL, TextDecoder, console, ...globals }); + const cache = new Map(); + return function load(filename: string): T { + filename = path.resolve(filename); + const cached = cache.get(filename); + if (cached) { + return cached.exports as T; + } + const module = { exports: {} }; + cache.set(filename, module); + const requireMock = (id: string): object => { + if (Object.hasOwn(mocks, id)) { + return mocks[id]; + } + assert.ok(id.startsWith('.'), `Unexpected dependency: ${id}`); + return load(path.resolve(path.dirname(filename), id + '.js')); + }; + const run = compileFunction(readFileSync(filename, 'utf8'), ['require', 'module', 'exports'], { filename, parsingContext: context }); + run(requireMock, module, module.exports); + return module.exports as T; + }; +} + +export async function createMatcher() { + const Uri = await getUri(); + const load = createLoader({ vscode: { Uri } }); + const { matchesUrlPattern } = load(path.join(__dirname, '..', 'utils', 'urlMatch.js')); + return (value: string, domains: Record) => matchesUrlPattern(Uri.parse(value), domains); +} + +export async function createSchemaClient(transport: 'browser' | 'node', options: RequestOptions, dependencies: Record = {}) { + const Uri = await getUri(); + const requests: RequestRecord[] = []; + const checked: { url: string; allowed: boolean }[] = []; + const documents: string[] = []; + const files: string[] = []; + const handlers = new Map Promise>(); + const subscriptions: Disposable[] = []; + const disposable = () => new Disposable(); + const statusItem = () => ({ dispose() { }, update() { } }); + const log = { info() { }, trace() { }, error() { }, dispose() { } }; + const schema = '{"type":"object"}'; + + class LanguageClient { + registerProposedFeatures() { } + onRequest(type: MessageType, handler: (url: string) => Promise) { + handlers.set(type.method, handler); + return disposable(); + } + async start() { } + async stop() { } + async sendNotification() { } + } + + const protocol = { + LanguageClient, + RequestType: MessageType, + NotificationType: MessageType, + ResponseError, + TransportKind: { ipc: 1 }, + }; + const extensionSchemas = (options.extensionSchemas ?? []).map(url => ({ fileMatch: '*.json', url })); + const extension = { packageJSON: { contributes: { jsonValidation: extensionSchemas } } }; + const mocks = { + vscode: { + Uri, + Disposable, + RelativePattern: class { }, + StatusBarAlignment: { Right: 2 }, + CodeActionKind: { QuickFix: 'quickfix' }, + LogLevel: { Trace: 1 }, + env: { appName: 'Schema Tests', appHost: transport }, + l10n: { t: (message: string) => message }, + commands: { registerCommand: disposable }, + languages: { registerCodeActionsProvider: disposable }, + extensions: { allAcrossExtensionHosts: [extension], onDidChange: disposable }, + window: { createStatusBarItem: statusItem, createOutputChannel: () => log }, + workspace: { + isTrusted: options.workspaceTrusted ?? true, + getConfiguration: (section?: string) => ({ + get: (key: string) => { + switch (key) { + case 'json.schemaDownload.enable': return options.downloadEnabled ?? true; + case 'json.schemaDownload.trustedDomains': return options.trustedDomains; + default: return undefined; + } + }, + inspect: (key: string) => section === 'json' && key === 'schemas' ? { globalValue: options.schemas } : undefined + }), + createFileSystemWatcher: () => ({ + dispose() { }, onDidCreate: disposable, onDidChange: disposable, onDidDelete: disposable + }), + onDidChangeTextDocument: disposable, + onDidCloseTextDocument: disposable, + onDidChangeConfiguration: disposable, + onDidGrantWorkspaceTrust: disposable, + openTextDocument: async (uri: Uri) => { + documents.push(uri.toString()); + return { getText: () => schema }; + }, + fs: { + readFile: async (uri: Uri) => { + files.push(uri.toString()); + return new TextEncoder().encode(schema); + } + } + } + }, + 'vscode-languageclient': protocol, + 'vscode-languageclient/node': protocol, + 'vscode-languageclient/browser': protocol, + './languageParticipants': { + getLanguageParticipants: () => ({ documentSelector: ['json'], onDidChange: disposable, dispose() { } }) + }, + './languageStatus': { + createLimitStatusItem: statusItem, + createSchemaLoadStatusItem: statusItem, + createLanguageStatusItem: statusItem + }, + './schemaCache': {}, + '@vscode/extension-telemetry': class { dispose() { } }, + fs: { + promises: { + readFile: async () => Buffer.from(JSON.stringify({ main: './client/out/node/jsonClientMain', aiKey: '' })) + } + }, + path, + url: { parse }, + 'request-light': { + xhr: async ({ url }: { url: string }) => { + const destination = parse(url); + requests.push({ url, host: destination.host, path: destination.pathname }); + return { status: 200, headers: {}, responseText: schema }; + } + } + }; + Object.assign(mocks, dependencies); + const globals = { + process: { env: {} }, + setTimeout: () => { throw new Error('Unexpected timer'); }, + clearTimeout: () => { }, + Worker: class { postMessage() { } }, + fetch: async (url: string) => { + const destination = new URL(url); + requests.push({ url, host: destination.host, path: destination.pathname }); + return { text: async () => schema }; + } + }; + const load = createLoader(mocks, globals); + const matcher = load(path.join(__dirname, '..', 'utils', 'urlMatch.js')); + const matchesUrlPattern: UrlMatcher['matchesUrlPattern'] = (url, domains) => { + const allowed = matcher.matchesUrlPattern(url, domains); + checked.push({ url: url instanceof URL ? url.href : url.toString(), allowed }); + return allowed; + }; + Object.assign(mocks, { './utils/urlMatch': { ...matcher, matchesUrlPattern } }); + + const main = load(path.join(__dirname, '..', transport, 'jsonClientMain.js')); + const context: Partial = { + subscriptions, + extensionUri: Uri.parse('vscode-test://extension/json'), + globalStorageUri: Uri.parse('vscode-test://storage/json'), + asAbsolutePath: (value: string) => path.resolve(__dirname, value) + }; + await main.activate(context as ExtensionContext); + const request = handlers.get('vscode/content'); + assert.ok(request, 'The production client must register its schema request handler'); + return { + request, + requests, + checked, + documents, + files, + async dispose() { + await main.deactivate(); + subscriptions.forEach(subscription => subscription.dispose()); + } + }; +} diff --git a/extensions/json-language-features/client/src/test/schemaRequestTransport.test.ts b/extensions/json-language-features/client/src/test/schemaRequestTransport.test.ts new file mode 100644 index 00000000000000..bbabe0536fadc0 --- /dev/null +++ b/extensions/json-language-features/client/src/test/schemaRequestTransport.test.ts @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { createSchemaClient, RequestOptions } from './schemaRequestTestUtils'; +import { connectionBlocked, createNativeSchemaTransport } from './schemaRequestTransportTestUtils'; + +type SchemaClient = Awaited>; +type NativeTransport = ReturnType; + +const punctuationHosts = [ + { name: 'literal semicolon', punctuation: ';', canonical: ';' }, + { name: 'encoded semicolon', punctuation: '%3B', canonical: ';' }, + { name: 'literal backtick', punctuation: '`', canonical: '`' }, + { name: 'encoded backtick', punctuation: '%60', canonical: '`' }, + { name: 'literal opening brace', punctuation: '{', canonical: '{' }, + { name: 'encoded opening brace', punctuation: '%7B', canonical: '{' }, + { name: 'literal closing brace', punctuation: '}', canonical: '}' }, + { name: 'encoded closing brace', punctuation: '%7D', canonical: '}' } +]; + +async function withNativeClient(options: RequestOptions, run: (client: SchemaClient, transport: NativeTransport) => Promise) { + const transport = createNativeSchemaTransport(); + try { + const client = await createSchemaClient('node', options, { 'request-light': transport.requestLight }); + try { + await run(client, transport); + } finally { + await client.dispose(); + } + } finally { + transport.dispose(); + } +} + +async function assertBlocked(client: SchemaClient, transport: NativeTransport, input: string, checked: { url: string; allowed: boolean }[], code = 5) { + const error = await client.request(input).then(() => undefined, error => ({ code: error.code, message: error.message })); + assert.deepStrictEqual({ + code: error?.code, + hasMessage: typeof error?.message === 'string' && error.message.length > 0, + checked: client.checked, + calls: transport.calls, + requests: transport.requests, + destinations: transport.destinations + }, { + code, + hasMessage: true, + checked, + calls: [], + requests: [], + destinations: [] + }); +} + +suite('JSON schema desktop native transport agreement', () => { + for (const { name, punctuation, canonical } of punctuationHosts) { + test(`regression: rejects ${name} host before native dispatch`, async () => { + await withNativeClient({ trustedDomains: { 'https://*.trusted.example': true } }, async (client, transport) => { + await assertBlocked(client, transport, `https://attacker.example${punctuation}.trusted.example/schema.json`, [ + { url: `https://attacker.example${canonical}.trusted.example/schema.json`, allowed: true } + ]); + }); + }); + } + + test('regression: HTTP also rejects a host changed by request-light', async () => { + const url = 'http://attacker.example;.trusted.example/schema.json'; + await withNativeClient({ trustedDomains: { 'http://*.trusted.example': true } }, async (client, transport) => { + await assertBlocked(client, transport, url, [{ url, allowed: true }]); + }); + }); + + test('regression: a native destination denial cannot be bypassed by a broader allowance', async () => { + const url = 'https://attacker.example;.trusted.example/schema.json'; + await withNativeClient({ trustedDomains: { 'https://attacker.example': false, '*': true } }, async (client, transport) => { + await assertBlocked(client, transport, url, [{ url, allowed: true }]); + }); + }); + + test('regression: an implicit localhost allowance cannot select a different native host', async () => { + const url = 'https://attacker.example;.localhost/schema.json'; + await withNativeClient({ trustedDomains: {} }, async (client, transport) => { + await assertBlocked(client, transport, url, [{ url, allowed: true }]); + }); + }); + + for (const association of ['configured', 'extension-contributed'] as const) { + test(`regression: ${association} schema allowances cannot select a different native host`, async () => { + const input = 'https://attacker.example%3B.trusted.example/schema.json'; + const options: RequestOptions = association === 'configured' + ? { trustedDomains: {}, schemas: [{ url: input }] } + : { trustedDomains: {}, extensionSchemas: [input] }; + await withNativeClient(options, async (client, transport) => { + await assertBlocked(client, transport, input, [ + { url: 'https://attacker.example;.trusted.example/schema.json', allowed: false } + ]); + }); + }); + } + + for (const protocol of ['http:', 'https:'] as const) { + test(`regression: ${protocol} port zero cannot select the native default port`, async () => { + const url = `${protocol}//api.trusted.example:0/schema.json`; + await withNativeClient({ trustedDomains: { [`${protocol}//api.trusted.example:0`]: true } }, async (client, transport) => { + await assertBlocked(client, transport, url, [{ url, allowed: true }]); + }); + }); + } + + for (const { name, suffix } of [ + { name: 'path apostrophe', suffix: '/schema\'name.json' }, + { name: 'query backtick', suffix: '/schema.json?value=`' }, + { name: 'query braces', suffix: '/schema.json?value={}' } + ]) { + test(`regression: rejects a ${name} changed by request-light`, async () => { + const url = `https://api.trusted.example${suffix}`; + await withNativeClient({ trustedDomains: { 'https://api.trusted.example': true } }, async (client, transport) => { + await assertBlocked(client, transport, url, [{ url, allowed: true }]); + }); + }); + } + + for (const { name, input, url, hostname, port, path } of [ + { name: 'ordinary HTTPS', input: 'https://api.trusted.example/schema.json', url: 'https://api.trusted.example/schema.json', hostname: 'api.trusted.example', port: 443, path: '/schema.json' }, + { name: 'HTTPS default port', input: 'https://API.trusted.example:443/schema.json', url: 'https://api.trusted.example/schema.json', hostname: 'api.trusted.example', port: 443, path: '/schema.json' }, + { name: 'HTTP default port', input: 'http://api.trusted.example:80/schema.json', url: 'http://api.trusted.example/schema.json', hostname: 'api.trusted.example', port: 80, path: '/schema.json' }, + { name: 'nondefault port and query', input: 'https://api.trusted.example:8443/schema.json?q=1', url: 'https://api.trusted.example:8443/schema.json?q=1', hostname: 'api.trusted.example', port: 8443, path: '/schema.json?q=1' }, + { name: 'IPv6 default port', input: 'https://[2001:db8::1]:443/schema.json', url: 'https://[2001:db8::1]/schema.json', hostname: '2001:db8::1', port: 443, path: '/schema.json' }, + { name: 'IPv6 nondefault port', input: 'https://[2001:db8::1]:8443/schema.json?q=1', url: 'https://[2001:db8::1]:8443/schema.json?q=1', hostname: '2001:db8::1', port: 8443, path: '/schema.json?q=1' }, + { name: 'IPv6 localhost', input: 'http://[::1]:80/schema.json', url: 'http://[::1]/schema.json', hostname: '::1', port: 80, path: '/schema.json' }, + { name: 'encoded space, dot segments and fragment', input: 'https://api.trusted.example/schemas/../schemas/schema%20name.json?q=1#definition', url: 'https://api.trusted.example/schemas/schema%20name.json?q=1#definition', hostname: 'api.trusted.example', port: 443, path: '/schemas/schema%20name.json?q=1' }, + { name: 'punctuation in a path rather than a host', input: 'https://api.trusted.example/schema;.json', url: 'https://api.trusted.example/schema;.json', hostname: 'api.trusted.example', port: 443, path: '/schema;.json' }, + { name: 'empty query', input: 'https://api.trusted.example/schema.json?', url: 'https://api.trusted.example/schema.json', hostname: 'api.trusted.example', port: 443, path: '/schema.json' } + ]) { + test(`controls: ${name} reaches the approved native destination`, async () => { + await withNativeClient({ trustedDomains: { [new URL(url).origin]: true } }, async (client, transport) => { + const error = await client.request(input).then(() => undefined, error => ({ code: error.code, message: error.message })); + assert.deepStrictEqual({ + code: error?.code, + stoppedBeforeSocket: error?.message.includes(connectionBlocked), + checked: client.checked, + requests: transport.requests, + destinations: transport.destinations + }, { + code: 5, + stoppedBeforeSocket: true, + checked: [{ url, allowed: true }], + requests: [{ protocol: new URL(url).protocol, hostname, port, path, method: 'GET', rejectUnauthorized: true }], + destinations: [{ protocol: new URL(url).protocol, hostname, host: hostname, port, servername: hostname.includes(':') ? '' : hostname }] + }); + }); + }); + } + + test('controls: a scoped denial precedes a broader allowance and transport validation', async () => { + const url = 'https://attacker.example;.trusted.example/private/schema.json'; + await withNativeClient({ trustedDomains: { 'https://*.trusted.example/private/': false, '*': true } }, async (client, transport) => { + await assertBlocked(client, transport, url, [{ url, allowed: false }], 2); + }); + }); + + test('controls: ordinary domain and path denials make no native requests', async () => { + for (const url of ['https://denied.example/schema.json', 'https://api.trusted.example/private/schema.json']) { + await withNativeClient({ trustedDomains: { 'https://denied.example': false, 'https://*.trusted.example/private/': false, '*': true } }, async (client, transport) => { + await assertBlocked(client, transport, url, [{ url, allowed: false }], 2); + }); + } + }); + + test('controls: an earlier allowance retains its order before a later denial', async () => { + const url = 'https://api.trusted.example/schema.json'; + await withNativeClient({ trustedDomains: { 'https://api.trusted.example': true, '*': false } }, async (client, transport) => { + await assert.rejects(client.request(url), { code: 5 }); + assert.deepStrictEqual({ + checked: client.checked, + hostnames: transport.destinations.map(destination => destination.hostname) + }, { checked: [{ url, allowed: true }], hostnames: ['api.trusted.example'] }); + }); + }); + + test('controls: configured and contributed ordinary schemas retain native dispatch', async () => { + const configured = 'https://configured.example:443/schema.json'; + const contributed = 'https://contributed.example/schema.json'; + await withNativeClient({ trustedDomains: {}, schemas: [{ url: configured }], extensionSchemas: [contributed] }, async (client, transport) => { + await assert.rejects(client.request(configured), { code: 5 }); + await assert.rejects(client.request(contributed), { code: 5 }); + assert.deepStrictEqual({ + checked: client.checked, + hostnames: transport.destinations.map(destination => destination.hostname) + }, { + checked: [{ url: 'https://configured.example/schema.json', allowed: false }, { url: contributed, allowed: false }], + hostnames: ['configured.example', 'contributed.example'] + }); + }); + }); + + test('controls: download and workspace gates run before trust and transport validation', async () => { + const url = 'https://attacker.example;.trusted.example/schema.json'; + for (const { options, code } of [ + { options: { trustedDomains: { '*': true }, downloadEnabled: false }, code: 4 }, + { options: { trustedDomains: { '*': true }, workspaceTrusted: false }, code: 1 } + ]) { + await withNativeClient(options, async (client, transport) => { + await assertBlocked(client, transport, url, [], code); + }); + } + }); + + test('controls: encoded backslash denials remain before the native boundary', async () => { + const domains: Record[] = [ + { 'https://*.trusted.example': true }, + { 'https://attacker.example': false, '*': true } + ]; + for (const trustedDomains of domains) { + await withNativeClient({ trustedDomains }, async (client, transport) => { + await assertBlocked(client, transport, 'https://attacker.example%5Cfake.trusted.example/schema.json', [ + { url: 'https://attacker.example/fake.trusted.example/schema.json', allowed: false } + ], 2); + }); + } + }); + + test('controls: non-network resources never reach request-light', async () => { + await withNativeClient({ trustedDomains: {}, downloadEnabled: false, workspaceTrusted: false }, async (client, transport) => { + const contents = [ + await client.request('file:///schemas/schema.json'), + await client.request('vscode://schemas/schema.json') + ]; + assert.deepStrictEqual({ contents, calls: transport.calls, destinations: transport.destinations }, { + contents: ['{"type":"object"}', '{"type":"object"}'], + calls: [], + destinations: [] + }); + }); + }); + + test('controls: request-light retains schema headers, TLS configuration and redirect limit', async () => { + const url = 'https://api.trusted.example/schema.json'; + await withNativeClient({ trustedDomains: { '*': true } }, async (client, transport) => { + transport.requestLight.configure(undefined, false); + await assert.rejects(client.request(url), { code: 5 }); + assert.deepStrictEqual({ + calls: transport.calls.map(call => ({ url: call.url, followRedirects: call.followRedirects, headers: { ...call.headers } })), + tls: transport.requests.map(request => request.rejectUnauthorized) + }, { + calls: [{ url, followRedirects: 5, headers: { 'Accept-Encoding': 'gzip, deflate', 'User-Agent': 'Schema Tests (node)' } }], + tls: [false] + }); + }); + }); + + test('controls: native validation rejects an unescaped space before the nonconnecting Agent', () => { + const transport = createNativeSchemaTransport(); + try { + assert.throws(() => transport.request('https:', { + hostname: 'api.trusted.example', port: 443, path: '/invalid space.json', method: 'GET' + }), { code: 'ERR_UNESCAPED_CHARACTERS' }); + assert.deepStrictEqual(transport.destinations, []); + } finally { + transport.dispose(); + } + }); + + test('controls: browser fetch retains WHATWG punctuation hosts without desktop validation', async () => { + for (const { punctuation, canonical } of punctuationHosts) { + const client = await createSchemaClient('browser', { trustedDomains: { 'https://*.trusted.example': true } }); + try { + const content = await client.request(`https://attacker.example${punctuation}.trusted.example/schema.json`); + const url = `https://attacker.example${canonical}.trusted.example/schema.json`; + assert.deepStrictEqual({ content, checked: client.checked, requests: client.requests }, { + content: '{"type":"object"}', + checked: [{ url, allowed: true }], + requests: [{ url, host: `attacker.example${canonical}.trusted.example`, path: '/schema.json' }] + }); + } finally { + await client.dispose(); + } + } + }); +}); diff --git a/extensions/json-language-features/client/src/test/schemaRequestTransportTestUtils.ts b/extensions/json-language-features/client/src/test/schemaRequestTransportTestUtils.ts new file mode 100644 index 00000000000000..681b7d552df8c9 --- /dev/null +++ b/extensions/json-language-features/client/src/test/schemaRequestTransportTestUtils.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { readFileSync } from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import { createRequire } from 'module'; +import { compileFunction, createContext } from 'vm'; +import type { XHROptions } from 'request-light'; + +type RequestLight = typeof import('request-light'); +type Protocol = 'http:' | 'https:'; + +export const connectionBlocked = 'Schema test stopped before creating a socket'; + +export function createNativeSchemaTransport() { + const calls: XHROptions[] = []; + const requests: { + protocol: Protocol; + hostname: https.RequestOptions['hostname']; + port: https.RequestOptions['port']; + path: https.RequestOptions['path']; + method: string | undefined; + rejectUnauthorized: boolean | undefined; + }[] = []; + const destinations: { + protocol: Protocol; + hostname: https.RequestOptions['hostname']; + host: https.RequestOptions['host']; + port: https.RequestOptions['port']; + servername: string | undefined; + }[] = []; + + const stopConnection = (protocol: Protocol, options: https.RequestOptions): never => { + destinations.push({ + protocol, + hostname: options.hostname, + host: options.host, + port: options.port, + servername: options.servername + }); + throw new Error(connectionBlocked); + }; + + class NonConnectingHttpAgent extends http.Agent { + override createConnection(options: http.ClientRequestArgs): never { + return stopConnection('http:', options); + } + } + + class NonConnectingHttpsAgent extends https.Agent { + override createConnection(options: https.RequestOptions): never { + return stopConnection('https:', options); + } + } + + const httpAgent = new NonConnectingHttpAgent(); + const httpsAgent = new NonConnectingHttpsAgent(); + const request = (protocol: Protocol, options: https.RequestOptions, callback?: (response: http.IncomingMessage) => void) => { + requests.push({ + protocol, + hostname: options.hostname, + port: options.port, + path: options.path, + method: options.method, + rejectUnauthorized: options.rejectUnauthorized + }); + // Keep native option validation and destination selection, but never create a socket. + return protocol === 'https:' + ? https.request({ ...options, agent: httpsAgent }, callback) + : http.request({ ...options, agent: httpAgent }, callback); + }; + + const requireDependency = createRequire(__filename); + const filename = requireDependency.resolve('request-light'); + const module: { exports: Partial } = { exports: {} }; + const context = createContext({ Buffer, URL, process: { env: {} }, console }); + const run = compileFunction(readFileSync(filename, 'utf8'), ['require', 'module', 'exports'], { filename, parsingContext: context }); + // Run the installed library unchanged, with isolated HTTP boundaries and no ambient proxy settings. + run((id: string) => { + switch (id) { + case 'http': return { ...http, request: (options: https.RequestOptions, callback?: (response: http.IncomingMessage) => void) => request('http:', options, callback) }; + case 'https': return { ...https, request: (options: https.RequestOptions, callback?: (response: http.IncomingMessage) => void) => request('https:', options, callback) }; + default: return requireDependency(id); + } + }, module, module.exports); + const { xhr, configure, getErrorStatusDescription } = module.exports; + assert.ok(xhr && configure && getErrorStatusDescription, 'Load the installed request-light exports'); + const requestLight: RequestLight = { + xhr: options => { + calls.push(options); + return xhr(options); + }, + configure, + getErrorStatusDescription + }; + + return { + requestLight, + calls, + requests, + destinations, + request, + dispose() { + httpAgent.destroy(); + httpsAgent.destroy(); + } + }; +} diff --git a/extensions/json-language-features/client/src/test/schemaRequests.test.ts b/extensions/json-language-features/client/src/test/schemaRequests.test.ts new file mode 100644 index 00000000000000..2606c357438bc5 --- /dev/null +++ b/extensions/json-language-features/client/src/test/schemaRequests.test.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { suite, suiteSetup, test } from 'mocha'; +import { createMatcher, createSchemaClient, RequestOptions } from './schemaRequestTestUtils'; + +const disguisedHost = 'https://attacker.example%5Cfake.trusted.example/schema.json'; + +suite('JSON schema URL matching', () => { + let matches: Awaited>; + suiteSetup(async () => { matches = await createMatcher(); }); + + test('regression: a decoded backslash cannot grant a scoped wildcard domain allowance', () => { + assert.strictEqual(matches(disguisedHost, { 'https://*.trusted.example': true }), false); + }); + + test('regression: a decoded backslash cannot evade a scoped destination denial', () => { + assert.strictEqual(matches(disguisedHost, { 'https://attacker.example': false, '*': true }), false); + }); + + test('regression: a decoded backslash cannot grant implicit localhost trust', () => { + assert.strictEqual(matches('https://attacker.example%5Cfake.localhost/schema.json', {}), false); + }); + + test('regression: canonical paths cannot escape an allowed schema directory', () => { + assert.strictEqual(matches('https://api.trusted.example/schemas/../private/schema.json', { 'https://*.trusted.example/schemas/': true }), false); + }); + + test('regression: canonical paths retain a scoped denial before a broader allowance', () => { + assert.strictEqual(matches('https://api.trusted.example/schemas/../private/schema.json', { + 'https://*.trusted.example/private/': false, + 'https://*.trusted.example': true + }), false); + }); + + test('controls: ordinary domains, ports, paths, schemes and ordered denials', () => { + const domains = { + 'https://*.trusted.example/private/': false, + 'https://*.trusted.example/schemas/': true, + 'https://api.trusted.example:8443/schemas/': true + }; + assert.deepStrictEqual([ + matches('https://trusted.example/schemas/schema.json', domains), + matches('https://api.trusted.example/schemas/schema.json', domains), + matches('https://api.trusted.example:8443/schemas/schema.json', domains), + matches('https://api.trusted.example:8444/schemas/schema.json', domains), + matches('http://api.trusted.example/schemas/schema.json', domains), + matches('https://api.trusted.example/schemas-other/schema.json', domains), + matches('https://api.trusted.example/private/schema.json', domains), + matches('https://api.trusted.example/schema.json', { 'https://*.trusted.example': false, '*': true }), + matches('https://api.trusted.example/schema.json', { '*': false }), + matches('https://api.trusted.example:443/schemas/schema.json', { 'https://api.trusted.example:443/schemas/': true }), + matches('file:///schemas/schema.json', { 'file:///schemas/': true }) + ], [true, true, true, false, false, false, false, false, false, true, true]); + }); +}); + +for (const transport of ['browser', 'node'] as const) { + suite(`JSON schema ${transport} request dispatch`, () => { + async function withClient(options: RequestOptions, run: (client: Awaited>) => Promise) { + const client = await createSchemaClient(transport, options); + try { + await run(client); + } finally { + await client.dispose(); + } + } + + test('regression: a suffix-only allowance dispatches zero requests', async () => { + await withClient({ trustedDomains: { 'https://*.trusted.example': true } }, async client => { + const result = await client.request(disguisedHost).then(() => undefined, error => error.code); + assert.deepStrictEqual({ error: result, requests: client.requests }, { error: 2, requests: [] }); + }); + }); + + test('regression: a scoped destination denial dispatches zero requests', async () => { + await withClient({ trustedDomains: { 'https://attacker.example': false, '*': true } }, async client => { + const result = await client.request(disguisedHost).then(() => undefined, error => error.code); + assert.deepStrictEqual({ error: result, requests: client.requests }, { error: 2, requests: [] }); + }); + }); + + test('regression: the checked and fetched canonical destinations are identical', async () => { + await withClient({ trustedDomains: { 'https://*.trusted.example/schemas/': true } }, async client => { + await client.request('https://api.trusted.example/schemas/../schemas/schema%20name.json'); + const url = 'https://api.trusted.example/schemas/schema%20name.json'; + assert.deepStrictEqual({ + checked: client.checked, + requests: client.requests + }, { + checked: [{ url, allowed: true }], + requests: [{ url, host: 'api.trusted.example', path: '/schemas/schema%20name.json' }] + }); + }); + }); + + for (const association of ['configured', 'extension-contributed'] as const) { + test(`regression: ${association} schema allowances use the same canonical destination`, async () => { + const input = 'https://configured.example:443/schemas/../schemas/schema name.json'; + const options: RequestOptions = association === 'configured' + ? { trustedDomains: {}, schemas: [{ url: input }] } + : { trustedDomains: {}, extensionSchemas: [input] }; + await withClient(options, async client => { + await client.request(input); + const url = 'https://configured.example/schemas/schema%20name.json'; + assert.deepStrictEqual({ + checked: client.checked, + requests: client.requests + }, { + checked: [{ url, allowed: false }], + requests: [{ url, host: 'configured.example', path: '/schemas/schema%20name.json' }] + }); + }); + }); + } + + test('controls: ordinary allowed schemas keep explicit ports and paths', async () => { + await withClient({ trustedDomains: { 'https://api.trusted.example:8443/schemas/': true } }, async client => { + const url = 'https://api.trusted.example:8443/schemas/schema.json'; + const content = await client.request(url); + assert.deepStrictEqual({ + content, + checked: client.checked, + requests: client.requests + }, { + content: '{"type":"object"}', + checked: [{ url, allowed: true }], + requests: [{ url, host: 'api.trusted.example:8443', path: '/schemas/schema.json' }] + }); + }); + }); + + test('controls: configured and extension-contributed schemas remain allowed', async () => { + const configured = 'https://configured.example/schema.json'; + const contributed = 'https://contributed.example/schema.json'; + await withClient({ trustedDomains: {}, schemas: [{ url: configured }], extensionSchemas: [contributed] }, async client => { + await client.request(configured); + await client.request(contributed); + assert.deepStrictEqual(client.requests.map(request => request.url), [configured, contributed]); + }); + }); + + test('controls: ordinary scoped denials dispatch zero requests', async () => { + await withClient({ trustedDomains: { 'https://*.trusted.example/private/': false, '*': true } }, async client => { + const result = await client.request('https://api.trusted.example/private/schema.json').then(() => undefined, error => error.code); + assert.deepStrictEqual({ error: result, requests: client.requests }, { error: 2, requests: [] }); + }); + }); + + test('controls: disabled downloads dispatch zero requests before trust checks', async () => { + await withClient({ trustedDomains: { '*': true }, downloadEnabled: false }, async client => { + const result = await client.request(disguisedHost).then(() => undefined, error => error.code); + assert.deepStrictEqual({ error: result, checked: client.checked, requests: client.requests }, { error: 4, checked: [], requests: [] }); + }); + }); + + test('controls: untrusted workspaces dispatch zero requests before domain checks', async () => { + await withClient({ trustedDomains: { '*': true }, workspaceTrusted: false }, async client => { + const result = await client.request(disguisedHost).then(() => undefined, error => error.code); + assert.deepStrictEqual({ error: result, checked: client.checked, requests: client.requests }, { error: 1, checked: [], requests: [] }); + }); + }); + + test('controls: non-network resources retain their existing handlers', async () => { + await withClient({ trustedDomains: {}, downloadEnabled: false, workspaceTrusted: false }, async client => { + const document = 'file:///schemas/schema.json'; + const resource = 'vscode://schemas/schema.json'; + const contents = [await client.request(document), await client.request(resource)]; + const untitledError = await client.request('untitled:Untitled-1').then(() => undefined, error => error.code); + assert.deepStrictEqual({ + contents, + untitledError, + documents: client.documents, + files: client.files, + requests: client.requests, + checked: client.checked + }, { + contents: ['{"type":"object"}', '{"type":"object"}'], + untitledError: 7, + documents: [document], + files: [resource], + requests: [], + checked: [] + }); + }); + }); + }); +} diff --git a/extensions/json-language-features/client/src/utils/urlMatch.ts b/extensions/json-language-features/client/src/utils/urlMatch.ts index a870c2d072626c..450861e03f2f9d 100644 --- a/extensions/json-language-features/client/src/utils/urlMatch.ts +++ b/extensions/json-language-features/client/src/utils/urlMatch.ts @@ -5,6 +5,23 @@ import { Uri } from 'vscode'; +/** + * Resolve the URL exactly as the schema transports will interpret it. + */ +export function getSchemaRequestUrl(uri: Uri): URL { + return new URL(uri.toString(true)); +} + +function asMatchableUri(url: Uri | URL): Uri { + if (!(url instanceof URL)) { + if (url.scheme !== 'http' && url.scheme !== 'https') { + return url; + } + url = getSchemaRequestUrl(url); + } + return Uri.parse(url.href).with({ authority: url.host }); +} + /** * Check whether a URL matches the list of trusted domains or URIs. * @@ -16,7 +33,13 @@ import { Uri } from 'vscode'; * @param url The URL to check * @param trustedDomains Object mapping domain patterns to boolean trust values */ -export function matchesUrlPattern(url: Uri, trustedDomains: Record): boolean { +export function matchesUrlPattern(url: Uri | URL, trustedDomains: Record): boolean { + try { + url = asMatchableUri(url); + } catch { + return false; + } + // Check localhost if (isLocalhostAuthority(url.authority)) { return true; @@ -33,7 +56,7 @@ export function matchesUrlPattern(url: Uri, trustedDomains: Record Date: Fri, 18 Sep 2026 14:08:06 -0700 Subject: [PATCH 2/5] json: defer desktop transport URL validation Keep this change focused on canonical schema URLs in common trust checks and request dispatch. Remove the desktop transport parser comparison and its dedicated tests while retaining the 28 common-client browser and desktop regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../client/src/node/jsonClientMain.ts | 16 - .../client/src/test/schemaRequestTestUtils.ts | 4 +- .../src/test/schemaRequestTransport.test.ts | 283 ------------------ .../test/schemaRequestTransportTestUtils.ts | 112 ------- 4 files changed, 1 insertion(+), 414 deletions(-) delete mode 100644 extensions/json-language-features/client/src/test/schemaRequestTransport.test.ts delete mode 100644 extensions/json-language-features/client/src/test/schemaRequestTransportTestUtils.ts diff --git a/extensions/json-language-features/client/src/node/jsonClientMain.ts b/extensions/json-language-features/client/src/node/jsonClientMain.ts index e5ad7543c7eab2..8e387c122f0c28 100644 --- a/extensions/json-language-features/client/src/node/jsonClientMain.ts +++ b/extensions/json-language-features/client/src/node/jsonClientMain.ts @@ -9,7 +9,6 @@ import { ServerOptions, TransportKind, LanguageClientOptions, LanguageClient } f import { promises as fs } from 'fs'; import * as path from 'path'; -import { parse as parseUrl } from 'url'; import { xhr, XHRResponse, getErrorStatusDescription, Headers } from 'request-light'; import TelemetryReporter from '@vscode/extension-telemetry'; @@ -159,21 +158,6 @@ async function getSchemaRequestService(context: ExtensionContext, log: LogOutput return { getContent: async (uri: string) => { - // request-light's legacy parser must preserve the destination approved by the common client. - const approvedUrl = new URL(uri); - const transportUrl = parseUrl(uri); - const hostname = approvedUrl.hostname.startsWith('[') ? approvedUrl.hostname.slice(1, -1) : approvedUrl.hostname; - const defaultPort = approvedUrl.protocol === 'https:' ? 443 : 80; - // Native requests also replace port zero with the protocol's default port. - const transportPort = Number(transportUrl.port) || defaultPort; - if ((approvedUrl.protocol !== 'http:' && approvedUrl.protocol !== 'https:') - || transportUrl.protocol !== approvedUrl.protocol - || transportUrl.hostname !== hostname - || transportPort !== Number(approvedUrl.port || defaultPort) - || transportUrl.path !== approvedUrl.pathname + approvedUrl.search) { - throw new Error(l10n.t('Unable to download schema from {0} because the HTTP transport interprets the URL differently.', uri)); - } - if (cache && /^https?:\/\/(json|www)\.schemastore\.org\//.test(uri)) { const content = await cache.getSchemaIfUpdatedSince(uri, retryTimeoutInHours); if (content) { diff --git a/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts index 54089d843e20ab..c81aa601f4e655 100644 --- a/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts +++ b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts @@ -82,7 +82,7 @@ export async function createMatcher() { return (value: string, domains: Record) => matchesUrlPattern(Uri.parse(value), domains); } -export async function createSchemaClient(transport: 'browser' | 'node', options: RequestOptions, dependencies: Record = {}) { +export async function createSchemaClient(transport: 'browser' | 'node', options: RequestOptions) { const Uri = await getUri(); const requests: RequestRecord[] = []; const checked: { url: string; allowed: boolean }[] = []; @@ -179,7 +179,6 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: } }, path, - url: { parse }, 'request-light': { xhr: async ({ url }: { url: string }) => { const destination = parse(url); @@ -188,7 +187,6 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: } } }; - Object.assign(mocks, dependencies); const globals = { process: { env: {} }, setTimeout: () => { throw new Error('Unexpected timer'); }, diff --git a/extensions/json-language-features/client/src/test/schemaRequestTransport.test.ts b/extensions/json-language-features/client/src/test/schemaRequestTransport.test.ts deleted file mode 100644 index bbabe0536fadc0..00000000000000 --- a/extensions/json-language-features/client/src/test/schemaRequestTransport.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as assert from 'assert'; -import { suite, test } from 'mocha'; -import { createSchemaClient, RequestOptions } from './schemaRequestTestUtils'; -import { connectionBlocked, createNativeSchemaTransport } from './schemaRequestTransportTestUtils'; - -type SchemaClient = Awaited>; -type NativeTransport = ReturnType; - -const punctuationHosts = [ - { name: 'literal semicolon', punctuation: ';', canonical: ';' }, - { name: 'encoded semicolon', punctuation: '%3B', canonical: ';' }, - { name: 'literal backtick', punctuation: '`', canonical: '`' }, - { name: 'encoded backtick', punctuation: '%60', canonical: '`' }, - { name: 'literal opening brace', punctuation: '{', canonical: '{' }, - { name: 'encoded opening brace', punctuation: '%7B', canonical: '{' }, - { name: 'literal closing brace', punctuation: '}', canonical: '}' }, - { name: 'encoded closing brace', punctuation: '%7D', canonical: '}' } -]; - -async function withNativeClient(options: RequestOptions, run: (client: SchemaClient, transport: NativeTransport) => Promise) { - const transport = createNativeSchemaTransport(); - try { - const client = await createSchemaClient('node', options, { 'request-light': transport.requestLight }); - try { - await run(client, transport); - } finally { - await client.dispose(); - } - } finally { - transport.dispose(); - } -} - -async function assertBlocked(client: SchemaClient, transport: NativeTransport, input: string, checked: { url: string; allowed: boolean }[], code = 5) { - const error = await client.request(input).then(() => undefined, error => ({ code: error.code, message: error.message })); - assert.deepStrictEqual({ - code: error?.code, - hasMessage: typeof error?.message === 'string' && error.message.length > 0, - checked: client.checked, - calls: transport.calls, - requests: transport.requests, - destinations: transport.destinations - }, { - code, - hasMessage: true, - checked, - calls: [], - requests: [], - destinations: [] - }); -} - -suite('JSON schema desktop native transport agreement', () => { - for (const { name, punctuation, canonical } of punctuationHosts) { - test(`regression: rejects ${name} host before native dispatch`, async () => { - await withNativeClient({ trustedDomains: { 'https://*.trusted.example': true } }, async (client, transport) => { - await assertBlocked(client, transport, `https://attacker.example${punctuation}.trusted.example/schema.json`, [ - { url: `https://attacker.example${canonical}.trusted.example/schema.json`, allowed: true } - ]); - }); - }); - } - - test('regression: HTTP also rejects a host changed by request-light', async () => { - const url = 'http://attacker.example;.trusted.example/schema.json'; - await withNativeClient({ trustedDomains: { 'http://*.trusted.example': true } }, async (client, transport) => { - await assertBlocked(client, transport, url, [{ url, allowed: true }]); - }); - }); - - test('regression: a native destination denial cannot be bypassed by a broader allowance', async () => { - const url = 'https://attacker.example;.trusted.example/schema.json'; - await withNativeClient({ trustedDomains: { 'https://attacker.example': false, '*': true } }, async (client, transport) => { - await assertBlocked(client, transport, url, [{ url, allowed: true }]); - }); - }); - - test('regression: an implicit localhost allowance cannot select a different native host', async () => { - const url = 'https://attacker.example;.localhost/schema.json'; - await withNativeClient({ trustedDomains: {} }, async (client, transport) => { - await assertBlocked(client, transport, url, [{ url, allowed: true }]); - }); - }); - - for (const association of ['configured', 'extension-contributed'] as const) { - test(`regression: ${association} schema allowances cannot select a different native host`, async () => { - const input = 'https://attacker.example%3B.trusted.example/schema.json'; - const options: RequestOptions = association === 'configured' - ? { trustedDomains: {}, schemas: [{ url: input }] } - : { trustedDomains: {}, extensionSchemas: [input] }; - await withNativeClient(options, async (client, transport) => { - await assertBlocked(client, transport, input, [ - { url: 'https://attacker.example;.trusted.example/schema.json', allowed: false } - ]); - }); - }); - } - - for (const protocol of ['http:', 'https:'] as const) { - test(`regression: ${protocol} port zero cannot select the native default port`, async () => { - const url = `${protocol}//api.trusted.example:0/schema.json`; - await withNativeClient({ trustedDomains: { [`${protocol}//api.trusted.example:0`]: true } }, async (client, transport) => { - await assertBlocked(client, transport, url, [{ url, allowed: true }]); - }); - }); - } - - for (const { name, suffix } of [ - { name: 'path apostrophe', suffix: '/schema\'name.json' }, - { name: 'query backtick', suffix: '/schema.json?value=`' }, - { name: 'query braces', suffix: '/schema.json?value={}' } - ]) { - test(`regression: rejects a ${name} changed by request-light`, async () => { - const url = `https://api.trusted.example${suffix}`; - await withNativeClient({ trustedDomains: { 'https://api.trusted.example': true } }, async (client, transport) => { - await assertBlocked(client, transport, url, [{ url, allowed: true }]); - }); - }); - } - - for (const { name, input, url, hostname, port, path } of [ - { name: 'ordinary HTTPS', input: 'https://api.trusted.example/schema.json', url: 'https://api.trusted.example/schema.json', hostname: 'api.trusted.example', port: 443, path: '/schema.json' }, - { name: 'HTTPS default port', input: 'https://API.trusted.example:443/schema.json', url: 'https://api.trusted.example/schema.json', hostname: 'api.trusted.example', port: 443, path: '/schema.json' }, - { name: 'HTTP default port', input: 'http://api.trusted.example:80/schema.json', url: 'http://api.trusted.example/schema.json', hostname: 'api.trusted.example', port: 80, path: '/schema.json' }, - { name: 'nondefault port and query', input: 'https://api.trusted.example:8443/schema.json?q=1', url: 'https://api.trusted.example:8443/schema.json?q=1', hostname: 'api.trusted.example', port: 8443, path: '/schema.json?q=1' }, - { name: 'IPv6 default port', input: 'https://[2001:db8::1]:443/schema.json', url: 'https://[2001:db8::1]/schema.json', hostname: '2001:db8::1', port: 443, path: '/schema.json' }, - { name: 'IPv6 nondefault port', input: 'https://[2001:db8::1]:8443/schema.json?q=1', url: 'https://[2001:db8::1]:8443/schema.json?q=1', hostname: '2001:db8::1', port: 8443, path: '/schema.json?q=1' }, - { name: 'IPv6 localhost', input: 'http://[::1]:80/schema.json', url: 'http://[::1]/schema.json', hostname: '::1', port: 80, path: '/schema.json' }, - { name: 'encoded space, dot segments and fragment', input: 'https://api.trusted.example/schemas/../schemas/schema%20name.json?q=1#definition', url: 'https://api.trusted.example/schemas/schema%20name.json?q=1#definition', hostname: 'api.trusted.example', port: 443, path: '/schemas/schema%20name.json?q=1' }, - { name: 'punctuation in a path rather than a host', input: 'https://api.trusted.example/schema;.json', url: 'https://api.trusted.example/schema;.json', hostname: 'api.trusted.example', port: 443, path: '/schema;.json' }, - { name: 'empty query', input: 'https://api.trusted.example/schema.json?', url: 'https://api.trusted.example/schema.json', hostname: 'api.trusted.example', port: 443, path: '/schema.json' } - ]) { - test(`controls: ${name} reaches the approved native destination`, async () => { - await withNativeClient({ trustedDomains: { [new URL(url).origin]: true } }, async (client, transport) => { - const error = await client.request(input).then(() => undefined, error => ({ code: error.code, message: error.message })); - assert.deepStrictEqual({ - code: error?.code, - stoppedBeforeSocket: error?.message.includes(connectionBlocked), - checked: client.checked, - requests: transport.requests, - destinations: transport.destinations - }, { - code: 5, - stoppedBeforeSocket: true, - checked: [{ url, allowed: true }], - requests: [{ protocol: new URL(url).protocol, hostname, port, path, method: 'GET', rejectUnauthorized: true }], - destinations: [{ protocol: new URL(url).protocol, hostname, host: hostname, port, servername: hostname.includes(':') ? '' : hostname }] - }); - }); - }); - } - - test('controls: a scoped denial precedes a broader allowance and transport validation', async () => { - const url = 'https://attacker.example;.trusted.example/private/schema.json'; - await withNativeClient({ trustedDomains: { 'https://*.trusted.example/private/': false, '*': true } }, async (client, transport) => { - await assertBlocked(client, transport, url, [{ url, allowed: false }], 2); - }); - }); - - test('controls: ordinary domain and path denials make no native requests', async () => { - for (const url of ['https://denied.example/schema.json', 'https://api.trusted.example/private/schema.json']) { - await withNativeClient({ trustedDomains: { 'https://denied.example': false, 'https://*.trusted.example/private/': false, '*': true } }, async (client, transport) => { - await assertBlocked(client, transport, url, [{ url, allowed: false }], 2); - }); - } - }); - - test('controls: an earlier allowance retains its order before a later denial', async () => { - const url = 'https://api.trusted.example/schema.json'; - await withNativeClient({ trustedDomains: { 'https://api.trusted.example': true, '*': false } }, async (client, transport) => { - await assert.rejects(client.request(url), { code: 5 }); - assert.deepStrictEqual({ - checked: client.checked, - hostnames: transport.destinations.map(destination => destination.hostname) - }, { checked: [{ url, allowed: true }], hostnames: ['api.trusted.example'] }); - }); - }); - - test('controls: configured and contributed ordinary schemas retain native dispatch', async () => { - const configured = 'https://configured.example:443/schema.json'; - const contributed = 'https://contributed.example/schema.json'; - await withNativeClient({ trustedDomains: {}, schemas: [{ url: configured }], extensionSchemas: [contributed] }, async (client, transport) => { - await assert.rejects(client.request(configured), { code: 5 }); - await assert.rejects(client.request(contributed), { code: 5 }); - assert.deepStrictEqual({ - checked: client.checked, - hostnames: transport.destinations.map(destination => destination.hostname) - }, { - checked: [{ url: 'https://configured.example/schema.json', allowed: false }, { url: contributed, allowed: false }], - hostnames: ['configured.example', 'contributed.example'] - }); - }); - }); - - test('controls: download and workspace gates run before trust and transport validation', async () => { - const url = 'https://attacker.example;.trusted.example/schema.json'; - for (const { options, code } of [ - { options: { trustedDomains: { '*': true }, downloadEnabled: false }, code: 4 }, - { options: { trustedDomains: { '*': true }, workspaceTrusted: false }, code: 1 } - ]) { - await withNativeClient(options, async (client, transport) => { - await assertBlocked(client, transport, url, [], code); - }); - } - }); - - test('controls: encoded backslash denials remain before the native boundary', async () => { - const domains: Record[] = [ - { 'https://*.trusted.example': true }, - { 'https://attacker.example': false, '*': true } - ]; - for (const trustedDomains of domains) { - await withNativeClient({ trustedDomains }, async (client, transport) => { - await assertBlocked(client, transport, 'https://attacker.example%5Cfake.trusted.example/schema.json', [ - { url: 'https://attacker.example/fake.trusted.example/schema.json', allowed: false } - ], 2); - }); - } - }); - - test('controls: non-network resources never reach request-light', async () => { - await withNativeClient({ trustedDomains: {}, downloadEnabled: false, workspaceTrusted: false }, async (client, transport) => { - const contents = [ - await client.request('file:///schemas/schema.json'), - await client.request('vscode://schemas/schema.json') - ]; - assert.deepStrictEqual({ contents, calls: transport.calls, destinations: transport.destinations }, { - contents: ['{"type":"object"}', '{"type":"object"}'], - calls: [], - destinations: [] - }); - }); - }); - - test('controls: request-light retains schema headers, TLS configuration and redirect limit', async () => { - const url = 'https://api.trusted.example/schema.json'; - await withNativeClient({ trustedDomains: { '*': true } }, async (client, transport) => { - transport.requestLight.configure(undefined, false); - await assert.rejects(client.request(url), { code: 5 }); - assert.deepStrictEqual({ - calls: transport.calls.map(call => ({ url: call.url, followRedirects: call.followRedirects, headers: { ...call.headers } })), - tls: transport.requests.map(request => request.rejectUnauthorized) - }, { - calls: [{ url, followRedirects: 5, headers: { 'Accept-Encoding': 'gzip, deflate', 'User-Agent': 'Schema Tests (node)' } }], - tls: [false] - }); - }); - }); - - test('controls: native validation rejects an unescaped space before the nonconnecting Agent', () => { - const transport = createNativeSchemaTransport(); - try { - assert.throws(() => transport.request('https:', { - hostname: 'api.trusted.example', port: 443, path: '/invalid space.json', method: 'GET' - }), { code: 'ERR_UNESCAPED_CHARACTERS' }); - assert.deepStrictEqual(transport.destinations, []); - } finally { - transport.dispose(); - } - }); - - test('controls: browser fetch retains WHATWG punctuation hosts without desktop validation', async () => { - for (const { punctuation, canonical } of punctuationHosts) { - const client = await createSchemaClient('browser', { trustedDomains: { 'https://*.trusted.example': true } }); - try { - const content = await client.request(`https://attacker.example${punctuation}.trusted.example/schema.json`); - const url = `https://attacker.example${canonical}.trusted.example/schema.json`; - assert.deepStrictEqual({ content, checked: client.checked, requests: client.requests }, { - content: '{"type":"object"}', - checked: [{ url, allowed: true }], - requests: [{ url, host: `attacker.example${canonical}.trusted.example`, path: '/schema.json' }] - }); - } finally { - await client.dispose(); - } - } - }); -}); diff --git a/extensions/json-language-features/client/src/test/schemaRequestTransportTestUtils.ts b/extensions/json-language-features/client/src/test/schemaRequestTransportTestUtils.ts deleted file mode 100644 index 681b7d552df8c9..00000000000000 --- a/extensions/json-language-features/client/src/test/schemaRequestTransportTestUtils.ts +++ /dev/null @@ -1,112 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as assert from 'assert'; -import { readFileSync } from 'fs'; -import * as http from 'http'; -import * as https from 'https'; -import { createRequire } from 'module'; -import { compileFunction, createContext } from 'vm'; -import type { XHROptions } from 'request-light'; - -type RequestLight = typeof import('request-light'); -type Protocol = 'http:' | 'https:'; - -export const connectionBlocked = 'Schema test stopped before creating a socket'; - -export function createNativeSchemaTransport() { - const calls: XHROptions[] = []; - const requests: { - protocol: Protocol; - hostname: https.RequestOptions['hostname']; - port: https.RequestOptions['port']; - path: https.RequestOptions['path']; - method: string | undefined; - rejectUnauthorized: boolean | undefined; - }[] = []; - const destinations: { - protocol: Protocol; - hostname: https.RequestOptions['hostname']; - host: https.RequestOptions['host']; - port: https.RequestOptions['port']; - servername: string | undefined; - }[] = []; - - const stopConnection = (protocol: Protocol, options: https.RequestOptions): never => { - destinations.push({ - protocol, - hostname: options.hostname, - host: options.host, - port: options.port, - servername: options.servername - }); - throw new Error(connectionBlocked); - }; - - class NonConnectingHttpAgent extends http.Agent { - override createConnection(options: http.ClientRequestArgs): never { - return stopConnection('http:', options); - } - } - - class NonConnectingHttpsAgent extends https.Agent { - override createConnection(options: https.RequestOptions): never { - return stopConnection('https:', options); - } - } - - const httpAgent = new NonConnectingHttpAgent(); - const httpsAgent = new NonConnectingHttpsAgent(); - const request = (protocol: Protocol, options: https.RequestOptions, callback?: (response: http.IncomingMessage) => void) => { - requests.push({ - protocol, - hostname: options.hostname, - port: options.port, - path: options.path, - method: options.method, - rejectUnauthorized: options.rejectUnauthorized - }); - // Keep native option validation and destination selection, but never create a socket. - return protocol === 'https:' - ? https.request({ ...options, agent: httpsAgent }, callback) - : http.request({ ...options, agent: httpAgent }, callback); - }; - - const requireDependency = createRequire(__filename); - const filename = requireDependency.resolve('request-light'); - const module: { exports: Partial } = { exports: {} }; - const context = createContext({ Buffer, URL, process: { env: {} }, console }); - const run = compileFunction(readFileSync(filename, 'utf8'), ['require', 'module', 'exports'], { filename, parsingContext: context }); - // Run the installed library unchanged, with isolated HTTP boundaries and no ambient proxy settings. - run((id: string) => { - switch (id) { - case 'http': return { ...http, request: (options: https.RequestOptions, callback?: (response: http.IncomingMessage) => void) => request('http:', options, callback) }; - case 'https': return { ...https, request: (options: https.RequestOptions, callback?: (response: http.IncomingMessage) => void) => request('https:', options, callback) }; - default: return requireDependency(id); - } - }, module, module.exports); - const { xhr, configure, getErrorStatusDescription } = module.exports; - assert.ok(xhr && configure && getErrorStatusDescription, 'Load the installed request-light exports'); - const requestLight: RequestLight = { - xhr: options => { - calls.push(options); - return xhr(options); - }, - configure, - getErrorStatusDescription - }; - - return { - requestLight, - calls, - requests, - destinations, - request, - dispose() { - httpAgent.destroy(); - httpsAgent.destroy(); - } - }; -} From b29f7d1d95783eba10047be84e921498eb5d9b72 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 18 Sep 2026 15:53:14 -0700 Subject: [PATCH 3/5] json: preserve schema URL paths and cache identities Compare encoded canonical paths without another decoding pass and retain original language-server schema IDs when invalidating canonical disk-cache entries. Add path-scope and enabled-cache regressions, and run the JSON client suite in the existing Linux Electron-Unit PR job. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-linux-test.yml | 5 ++ .../client/src/jsonClient.ts | 16 +++- .../client/src/test/schemaRequestTestUtils.ts | 59 ++++++++++--- .../client/src/test/schemaRequests.test.ts | 83 +++++++++++++++++++ .../client/src/utils/urlMatch.ts | 4 +- 5 files changed, 153 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index dcdf39926c7a75..f567f07e70ba82 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -306,6 +306,11 @@ jobs: timeout-minutes: 15 run: npm run test-node + - name: Run JSON schema client tests + if: ${{ inputs.electron_tests && inputs.unit_tests }} + timeout-minutes: 5 + run: node node_modules/mocha/bin/mocha.js --reporter dot extensions/json-language-features/client/out/test/schemaRequests.test.js + - name: 🧪 Run unit tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.unit_tests }} timeout-minutes: 30 diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index b941db70fd29d3..e15b8204cf19ac 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -226,6 +226,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(schemaResolutionErrorStatusBarItem); const fileSchemaErrors = new Map(); + const schemaRequestAliases = new Map(); let schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); let trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); @@ -240,7 +241,14 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(commands.registerCommand(CommandIds.clearCacheCommandId, async () => { if (isClientReady && runtime.schemaRequests.clearCache) { const cachedSchemas = await runtime.schemaRequests.clearCache(); - await client.sendNotification(SchemaContentChangeNotification.type, cachedSchemas); + const schemaIds = new Set(cachedSchemas); + for (const [schemaId, requestUrl] of schemaRequestAliases) { + if (schemaIds.has(requestUrl)) { + schemaIds.add(schemaId); + schemaRequestAliases.delete(schemaId); + } + } + await client.sendNotification(SchemaContentChangeNotification.type, [...schemaIds]); } window.showInformationMessage(l10n.t('JSON schema cache cleared.')); })); @@ -439,7 +447,11 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP runtime.telemetry.sendTelemetryEvent('json.schema', { schemaURL: requestUrl.href }); } try { - return await runtime.schemaRequests.getContent(requestUrl.href); + const content = await runtime.schemaRequests.getContent(requestUrl.href); + if (runtime.schemaRequests.clearCache && requestUrl.href !== uriPath) { + schemaRequestAliases.set(uriPath, requestUrl.href); + } + return content; } catch (e) { throw new ResponseError(SchemaRequestServiceErrors.HTTPError, e.toString(), e); } diff --git a/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts index c81aa601f4e655..ed5cc9c4424c14 100644 --- a/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts +++ b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { readFileSync } from 'fs'; +import { promises as fs, readFileSync } from 'fs'; +import { createHash } from 'crypto'; import { createRequire } from 'module'; +import { tmpdir } from 'os'; import * as path from 'path'; import { parse } from 'url'; import { compileFunction, createContext } from 'vm'; @@ -20,6 +22,8 @@ export interface RequestOptions { workspaceTrusted?: boolean; schemas?: { url: string }[]; extensionSchemas?: string[]; + cache?: boolean; + schemaResponse?: () => { content: string; etag?: string }; } export interface RequestRecord { @@ -89,6 +93,10 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: const documents: string[] = []; const files: string[] = []; const handlers = new Map Promise>(); + const commands = new Map Promise>(); + const schemaNotifications: string[][] = []; + const cacheDirectory = options.cache ? await fs.mkdtemp(path.join(tmpdir(), 'json-schema-cache-test-')) : undefined; + const state = new Map(); const subscriptions: Disposable[] = []; const disposable = () => new Disposable(); const statusItem = () => ({ dispose() { }, update() { } }); @@ -103,7 +111,11 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: } async start() { } async stop() { } - async sendNotification() { } + async sendNotification(type: MessageType, value: string | string[]) { + if (type.method === 'json/schemaContent') { + schemaNotifications.push(typeof value === 'string' ? [value] : [...value]); + } + } } const protocol = { @@ -125,10 +137,15 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: LogLevel: { Trace: 1 }, env: { appName: 'Schema Tests', appHost: transport }, l10n: { t: (message: string) => message }, - commands: { registerCommand: disposable }, + commands: { + registerCommand: (id: string, handler: () => Promise) => { + commands.set(id, handler); + return new Disposable(() => commands.delete(id)); + } + }, languages: { registerCodeActionsProvider: disposable }, extensions: { allAcrossExtensionHosts: [extension], onDidChange: disposable }, - window: { createStatusBarItem: statusItem, createOutputChannel: () => log }, + window: { createStatusBarItem: statusItem, createOutputChannel: () => log, showInformationMessage: async () => undefined }, workspace: { isTrusted: options.workspaceTrusted ?? true, getConfiguration: (section?: string) => ({ @@ -171,19 +188,23 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: createSchemaLoadStatusItem: statusItem, createLanguageStatusItem: statusItem }, - './schemaCache': {}, '@vscode/extension-telemetry': class { dispose() { } }, fs: { promises: { - readFile: async () => Buffer.from(JSON.stringify({ main: './client/out/node/jsonClientMain', aiKey: '' })) + ...fs, + readFile: async (filename: string) => path.basename(filename) === 'package.json' + ? Buffer.from(JSON.stringify({ main: './client/out/node/jsonClientMain', aiKey: '' })) + : fs.readFile(filename), } }, path, + crypto: { createHash }, 'request-light': { xhr: async ({ url }: { url: string }) => { const destination = parse(url); requests.push({ url, host: destination.host, path: destination.pathname }); - return { status: 200, headers: {}, responseText: schema }; + const response = options.schemaResponse?.() ?? { content: schema }; + return { status: 200, headers: { etag: response.etag }, responseText: response.content }; } } }; @@ -211,7 +232,13 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: const context: Partial = { subscriptions, extensionUri: Uri.parse('vscode-test://extension/json'), - globalStorageUri: Uri.parse('vscode-test://storage/json'), + globalStorageUri: cacheDirectory ? Uri.file(cacheDirectory) : Uri.parse('vscode-test://storage/json'), + globalState: { + keys: () => [...state.keys()], + get: (key: string, fallback?: T): T | undefined => state.has(key) ? state.get(key) as T : fallback, + update: async (key: string, value: object) => { state.set(key, value); }, + setKeysForSync: () => { } + }, asAbsolutePath: (value: string) => path.resolve(__dirname, value) }; await main.activate(context as ExtensionContext); @@ -223,9 +250,21 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: checked, documents, files, + schemaNotifications, + async clearCache() { + const command = commands.get('json.clearCache'); + assert.ok(command, 'The production client must register the cache-clear command'); + await command(); + }, async dispose() { - await main.deactivate(); - subscriptions.forEach(subscription => subscription.dispose()); + try { + await main.deactivate(); + subscriptions.forEach(subscription => subscription.dispose()); + } finally { + if (cacheDirectory) { + await fs.rm(cacheDirectory, { recursive: true, force: true }); + } + } } }; } diff --git a/extensions/json-language-features/client/src/test/schemaRequests.test.ts b/extensions/json-language-features/client/src/test/schemaRequests.test.ts index 2606c357438bc5..8405551af45fef 100644 --- a/extensions/json-language-features/client/src/test/schemaRequests.test.ts +++ b/extensions/json-language-features/client/src/test/schemaRequests.test.ts @@ -8,6 +8,7 @@ import { suite, suiteSetup, test } from 'mocha'; import { createMatcher, createSchemaClient, RequestOptions } from './schemaRequestTestUtils'; const disguisedHost = 'https://attacker.example%5Cfake.trusted.example/schema.json'; +const encodedSeparators = ['%252F', '%252f']; suite('JSON schema URL matching', () => { let matches: Awaited>; @@ -36,6 +37,26 @@ suite('JSON schema URL matching', () => { }), false); }); + for (const separator of encodedSeparators) { + test(`regression: ${separator} remains encoded during path-scope matching`, () => { + const url = `https://api.trusted.example/schemas${separator}..${separator}private/schema.json`; + assert.strictEqual(matches(url, { 'https://api.trusted.example/schemas/': true }), false); + }); + } + + test('controls: request and pattern paths preserve the same encoded characters', () => { + assert.deepStrictEqual([ + matches('https://api.trusted.example/schema%20files/main.json', { 'https://api.trusted.example/schema%20files/': true }), + matches('https://api.trusted.example/schemas%252Fdata/main.json', { 'https://api.trusted.example/schemas%252Fdata/': true }), + matches('https://api.trusted.example/schemas%252F..%252Fprivate/schema.json', { + 'https://api.trusted.example/schemas%252F..%252Fprivate/': false, + 'https://api.trusted.example': true + }), + matches('https://api.trusted.example/schemas/../private/schema.json', { 'https://api.trusted.example/schemas/': true }), + matches('https://api.trusted.example/schemas%255C..%255Cprivate/schema.json', { 'https://api.trusted.example/schemas/': true }), + ], [true, true, false, false, false]); + }); + test('controls: ordinary domains, ports, paths, schemes and ordered denials', () => { const domains = { 'https://*.trusted.example/private/': false, @@ -83,6 +104,16 @@ for (const transport of ['browser', 'node'] as const) { }); }); + for (const separator of encodedSeparators) { + test(`regression: ${separator} cannot enter an allowed path by decoding again`, async () => { + await withClient({ trustedDomains: { 'https://api.trusted.example/schemas/': true } }, async client => { + const url = `https://api.trusted.example/schemas${separator}..${separator}private/schema.json`; + const error = await client.request(url).then(() => undefined, error => error.code); + assert.deepStrictEqual({ error, requests: client.requests }, { error: 2, requests: [] }); + }); + }); + } + test('regression: the checked and fetched canonical destinations are identical', async () => { await withClient({ trustedDomains: { 'https://*.trusted.example/schemas/': true } }, async client => { await client.request('https://api.trusted.example/schemas/../schemas/schema%20name.json'); @@ -189,3 +220,55 @@ for (const transport of ['browser', 'node'] as const) { }); }); } + +suite('JSON schema cache invalidation', () => { + test('regression: cache clearing invalidates every original schema ID sharing a canonical URL', async () => { + let version = 1; + const canonical = 'https://json.schemastore.org/package.json'; + const aliases = ['https://json.schemastore.org:443/package.json', 'https://json.schemastore.org/folder/../package.json']; + const client = await createSchemaClient('node', { + trustedDomains: { 'https://json.schemastore.org': true }, + cache: true, + schemaResponse: () => ({ content: JSON.stringify({ title: `schema-${version}` }), etag: `etag-${version}` }) + }); + try { + const initial = await client.request(aliases[0]); + version = 2; + const cachedAlias = await client.request(aliases[1]); + const cachedCanonical = await client.request(canonical); + const requestsBeforeClear = client.requests.length; + await client.clearCache(); + const refreshed = await client.request(aliases[0]); + + assert.deepStrictEqual({ + contents: [initial, cachedAlias, cachedCanonical, refreshed].map(content => JSON.parse(content).title), + requestsBeforeClear, + requestUrls: client.requests.map(request => request.url), + invalidated: client.schemaNotifications, + }, { + contents: ['schema-1', 'schema-1', 'schema-1', 'schema-2'], + requestsBeforeClear: 1, + requestUrls: [canonical, canonical], + invalidated: [[canonical, ...aliases]], + }); + } finally { + await client.dispose(); + } + }); + + test('controls: clearing a canonical cache entry preserves its schema ID', async () => { + const canonical = 'https://json.schemastore.org/package.json'; + const client = await createSchemaClient('node', { + trustedDomains: { 'https://json.schemastore.org': true }, + cache: true, + schemaResponse: () => ({ content: '{"type":"object"}', etag: 'canonical-etag' }) + }); + try { + await client.request(canonical); + await client.clearCache(); + assert.deepStrictEqual(client.schemaNotifications, [[canonical]]); + } finally { + await client.dispose(); + } + }); +}); diff --git a/extensions/json-language-features/client/src/utils/urlMatch.ts b/extensions/json-language-features/client/src/utils/urlMatch.ts index 450861e03f2f9d..df782c80aa5956 100644 --- a/extensions/json-language-features/client/src/utils/urlMatch.ts +++ b/extensions/json-language-features/client/src/utils/urlMatch.ts @@ -6,7 +6,7 @@ import { Uri } from 'vscode'; /** - * Resolve the URL exactly as the schema transports will interpret it. + * Resolve the effective URL used for schema trust checks and request dispatch. */ export function getSchemaRequestUrl(uri: Uri): URL { return new URL(uri.toString(true)); @@ -19,7 +19,7 @@ function asMatchableUri(url: Uri | URL): Uri { } url = getSchemaRequestUrl(url); } - return Uri.parse(url.href).with({ authority: url.host }); + return Uri.parse(url.href).with({ authority: url.host, path: url.pathname }); } /** From 464aba80521b7eb1c4aee5259b2eaf4f6b693955 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 18 Sep 2026 16:04:37 -0700 Subject: [PATCH 4/5] json: run schema tests through shared extension scripts Register the standalone JSON client suite beside CSS and HTML in both integration launchers. Preserve grep filtering, failure exit codes, and the existing CI JUnit reporting conventions. Remove the dedicated Linux workflow step so the existing desktop integration jobs run the suite on all supported platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/integration-tests/SKILL.md | 4 +- .github/workflows/pr-linux-test.yml | 5 --- .../client/src/test/index.ts | 41 +++++++++++++++++++ scripts/test-integration.bat | 13 ++++-- scripts/test-integration.sh | 9 +++- 5 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 extensions/json-language-features/client/src/test/index.ts diff --git a/.github/skills/integration-tests/SKILL.md b/.github/skills/integration-tests/SKILL.md index 3703edc0d78aa3..ea799a4a097b86 100644 --- a/.github/skills/integration-tests/SKILL.md +++ b/.github/skills/integration-tests/SKILL.md @@ -53,7 +53,9 @@ Filters which **test cases** run by matching against their test titles (e.g. `de Runs only the extension host test suites whose name matches the pattern. Supports comma-separated values and shell glob patterns (on macOS/Linux). Node.js integration tests are skipped. -Available suite names: `api-folder`, `api-workspace`, `colorize`, `terminal-suggest`, `typescript`, `markdown`, `emmet`, `git`, `git-base`, `ipynb`, `notebook-renderers`, `configuration-editing`, `github-authentication`, `css`, `html`. +Available suite names: `api-folder`, `api-workspace`, `colorize`, `terminal-suggest`, `typescript`, `markdown`, `emmet`, `git`, `git-base`, `ipynb`, `notebook-renderers`, `configuration-editing`, `github-authentication`, `css`, `html`, `json`. + +The `css`, `html`, and `json` suites are standalone extension tests that run in Electron's Node.js mode without opening a workbench. They share the same suite selection and grep filtering as extension host tests. ```bash # Run only Git extension tests diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index f567f07e70ba82..dcdf39926c7a75 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -306,11 +306,6 @@ jobs: timeout-minutes: 15 run: npm run test-node - - name: Run JSON schema client tests - if: ${{ inputs.electron_tests && inputs.unit_tests }} - timeout-minutes: 5 - run: node node_modules/mocha/bin/mocha.js --reporter dot extensions/json-language-features/client/out/test/schemaRequests.test.js - - name: 🧪 Run unit tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.unit_tests }} timeout-minutes: 30 diff --git a/extensions/json-language-features/client/src/test/index.ts b/extensions/json-language-features/client/src/test/index.ts new file mode 100644 index 00000000000000..8d86ac19ab5a06 --- /dev/null +++ b/extensions/json-language-features/client/src/test/index.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as glob from 'glob'; +import Mocha from 'mocha'; + +const options: Mocha.MochaOptions = { + ui: 'tdd', + color: true, + timeout: 60000, + grep: process.env.MOCHA_GREP, +}; + +const ciOutputDir = process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE; +if (ciOutputDir) { + const suite = 'Integration JSON Extension Tests'; + options.reporter = 'mocha-multi-reporters'; + options.reporterOptions = { + reporterEnabled: 'spec, mocha-junit-reporter', + mochaJunitReporterReporterOptions: { + testsuitesTitle: `${suite} ${process.platform}`, + mochaFile: path.join(ciOutputDir, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`) + } + }; +} + +const files = glob.sync('**/*.test.js', { cwd: __dirname }); +if (files.length === 0) { + throw new Error('No compiled JSON extension tests found.'); +} + +const mocha = new Mocha(options); +for (const file of files) { + mocha.addFile(path.join(__dirname, file)); +} +mocha.run(failures => { + process.exitCode = failures ? 1 : 0; +}); diff --git a/scripts/test-integration.bat b/scripts/test-integration.bat index 08d17acd68281d..deeb6a0768dc09 100644 --- a/scripts/test-integration.bat +++ b/scripts/test-integration.bat @@ -58,7 +58,7 @@ if defined SHOW_HELP ( echo Available suites: echo api-folder, api-workspace, colorize, terminal-suggest, typescript, echo markdown, emmet, git, git-base, ipynb, notebook-renderers, - echo configuration-editing, github-authentication, copilot, css, html + echo configuration-editing, github-authentication, copilot, css, html, json echo. echo All other options are forwarded to the node.js test runner ^(see scripts\test.bat --help^). echo Note: extra options are not forwarded to extension host suites ^(--suite mode^). @@ -108,12 +108,12 @@ echo Storing log files into '%VSCODELOGSDIR%'. :: Validate --suite filter matches at least one known suite if defined SUITE_FILTER ( set "_any_match=" - for %%s in (api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication copilot css html) do ( + for %%s in (api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication copilot css html json) do ( call :should_run_suite %%s && set "_any_match=1" ) if not defined _any_match ( echo Error: no suites match filter '%SUITE_FILTER%' - echo Available suites: api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication copilot css html + echo Available suites: api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication copilot css html json goto :failed ) ) @@ -310,6 +310,13 @@ call %SCRIPT_DIR%\node-electron.bat %SCRIPT_DIR%\..\extensions\html-language-fea if %errorlevel% neq 0 exit /b %errorlevel% :skip_html +call :should_run_suite json || goto skip_json +echo. +echo ### JSON tests +call "%SCRIPT_DIR%\node-electron.bat" "%SCRIPT_DIR%\..\extensions\json-language-features\client\out\test\index.js" +if %errorlevel% neq 0 goto :failed +:skip_json + :: Cleanup diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index b9072ca039b0e0..986dc1c4123288 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -52,7 +52,7 @@ while [[ $# -gt 0 ]]; do done # Known suite names (used for help text and validation) -KNOWN_SUITES="api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication copilot css html" +KNOWN_SUITES="api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication copilot css html json" if $HELP; then echo "Usage: $0 [options]" @@ -338,6 +338,13 @@ echo cd "$ROOT/extensions/html-language-features/server" && "$ROOT/scripts/node-electron.sh" test/index.js fi +if should_run_suite json; then +echo +echo "### JSON tests" +echo +"$ROOT/scripts/node-electron.sh" "$ROOT/extensions/json-language-features/client/out/test/index.js" +fi + # Cleanup From 538fe0df6ea52a336cb8815bdf24fb1f83392133 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 18 Sep 2026 16:54:02 -0700 Subject: [PATCH 5/5] json: release uncached schema aliases when clearing cache Remove pre-existing schema aliases during cache clearing even when the response had no ETag and no disk-cache entry. Snapshot alias entries so requests completing during the asynchronous clear retain their refreshed aliases. Add failing-first cleanup coverage and a concurrent-refresh control. Include copilot in the documented integration-suite list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/integration-tests/SKILL.md | 2 +- .../client/src/jsonClient.ts | 12 ++-- .../client/src/test/schemaRequestTestUtils.ts | 6 +- .../client/src/test/schemaRequests.test.ts | 62 +++++++++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/.github/skills/integration-tests/SKILL.md b/.github/skills/integration-tests/SKILL.md index ea799a4a097b86..4e351a16444d34 100644 --- a/.github/skills/integration-tests/SKILL.md +++ b/.github/skills/integration-tests/SKILL.md @@ -53,7 +53,7 @@ Filters which **test cases** run by matching against their test titles (e.g. `de Runs only the extension host test suites whose name matches the pattern. Supports comma-separated values and shell glob patterns (on macOS/Linux). Node.js integration tests are skipped. -Available suite names: `api-folder`, `api-workspace`, `colorize`, `terminal-suggest`, `typescript`, `markdown`, `emmet`, `git`, `git-base`, `ipynb`, `notebook-renderers`, `configuration-editing`, `github-authentication`, `css`, `html`, `json`. +Available suite names: `api-folder`, `api-workspace`, `colorize`, `terminal-suggest`, `typescript`, `markdown`, `emmet`, `git`, `git-base`, `ipynb`, `notebook-renderers`, `configuration-editing`, `github-authentication`, `copilot`, `css`, `html`, `json`. The `css`, `html`, and `json` suites are standalone extension tests that run in Electron's Node.js mode without opening a workbench. They share the same suite selection and grep filtering as extension host tests. diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index e15b8204cf19ac..33adeea7847cb3 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -226,7 +226,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(schemaResolutionErrorStatusBarItem); const fileSchemaErrors = new Map(); - const schemaRequestAliases = new Map(); + const schemaRequestAliases = new Map(); let schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); let trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); @@ -240,11 +240,15 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(commands.registerCommand(CommandIds.clearCacheCommandId, async () => { if (isClientReady && runtime.schemaRequests.clearCache) { + // Preserve aliases recorded by requests that finish while the cache is being cleared. + const aliasesToClear = new Map(schemaRequestAliases); const cachedSchemas = await runtime.schemaRequests.clearCache(); const schemaIds = new Set(cachedSchemas); - for (const [schemaId, requestUrl] of schemaRequestAliases) { - if (schemaIds.has(requestUrl)) { + for (const [schemaId, alias] of schemaRequestAliases) { + if (schemaIds.has(alias.requestUrl)) { schemaIds.add(schemaId); + } + if (aliasesToClear.get(schemaId) === alias) { schemaRequestAliases.delete(schemaId); } } @@ -449,7 +453,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP try { const content = await runtime.schemaRequests.getContent(requestUrl.href); if (runtime.schemaRequests.clearCache && requestUrl.href !== uriPath) { - schemaRequestAliases.set(uriPath, requestUrl.href); + schemaRequestAliases.set(uriPath, { requestUrl: requestUrl.href }); } return content; } catch (e) { diff --git a/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts index ed5cc9c4424c14..c251420939f578 100644 --- a/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts +++ b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts @@ -24,6 +24,7 @@ export interface RequestOptions { extensionSchemas?: string[]; cache?: boolean; schemaResponse?: () => { content: string; etag?: string }; + onCacheUpdate?: () => Promise; } export interface RequestRecord { @@ -236,7 +237,10 @@ export async function createSchemaClient(transport: 'browser' | 'node', options: globalState: { keys: () => [...state.keys()], get: (key: string, fallback?: T): T | undefined => state.has(key) ? state.get(key) as T : fallback, - update: async (key: string, value: object) => { state.set(key, value); }, + update: async (key: string, value: object) => { + state.set(key, value); + await options.onCacheUpdate?.(); + }, setKeysForSync: () => { } }, asAbsolutePath: (value: string) => path.resolve(__dirname, value) diff --git a/extensions/json-language-features/client/src/test/schemaRequests.test.ts b/extensions/json-language-features/client/src/test/schemaRequests.test.ts index 8405551af45fef..d936f0c2a10f94 100644 --- a/extensions/json-language-features/client/src/test/schemaRequests.test.ts +++ b/extensions/json-language-features/client/src/test/schemaRequests.test.ts @@ -256,6 +256,68 @@ suite('JSON schema cache invalidation', () => { } }); + test('regression: cache clearing discards aliases for responses without ETags', async () => { + let cacheResponses = false; + const canonical = 'https://json.schemastore.org/package.json'; + const aliases = ['https://json.schemastore.org:443/package.json', 'https://json.schemastore.org/folder/../package.json']; + const client = await createSchemaClient('node', { + trustedDomains: { 'https://json.schemastore.org': true }, + cache: true, + schemaResponse: () => ({ content: '{"type":"object"}', etag: cacheResponses ? 'cached-etag' : undefined }) + }); + try { + for (const alias of aliases) { + await client.request(alias); + } + await client.clearCache(); + cacheResponses = true; + await client.request(canonical); + await client.clearCache(); + + assert.deepStrictEqual({ + requestUrls: client.requests.map(request => request.url), + invalidated: client.schemaNotifications, + }, { + requestUrls: [canonical, canonical, canonical], + invalidated: [[], [canonical]], + }); + } finally { + await client.dispose(); + } + }); + + test('controls: cache clearing preserves aliases refreshed while the cache is being cleared', async () => { + let cacheResponses = false; + const canonical = 'https://json.schemastore.org/package.json'; + const alias = 'https://json.schemastore.org:443/package.json'; + const client = await createSchemaClient('node', { + trustedDomains: { 'https://json.schemastore.org': true }, + cache: true, + schemaResponse: () => ({ content: '{"type":"object"}', etag: cacheResponses ? 'cached-etag' : undefined }), + onCacheUpdate: async () => { + if (!cacheResponses) { + cacheResponses = true; + await client.request(alias); + } + } + }); + try { + await client.request(alias); + await client.clearCache(); + await client.clearCache(); + + assert.deepStrictEqual({ + requestUrls: client.requests.map(request => request.url), + invalidated: client.schemaNotifications, + }, { + requestUrls: [canonical, canonical], + invalidated: [[], [canonical, alias]], + }); + } finally { + await client.dispose(); + } + }); + test('controls: clearing a canonical cache entry preserves its schema ID', async () => { const canonical = 'https://json.schemastore.org/package.json'; const client = await createSchemaClient('node', {