diff --git a/.github/skills/integration-tests/SKILL.md b/.github/skills/integration-tests/SKILL.md index 3703edc0d78aa3..4e351a16444d34 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`, `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. ```bash # Run only Git extension tests diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index fa1c6d10209921..33adeea7847cb3 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'); @@ -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, {}); @@ -239,8 +240,19 @@ 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(); - await client.sendNotification(SchemaContentChangeNotification.type, cachedSchemas); + const schemaIds = new Set(cachedSchemas); + for (const [schemaId, alias] of schemaRequestAliases) { + if (schemaIds.has(alias.requestUrl)) { + schemaIds.add(schemaId); + } + if (aliasesToClear.get(schemaId) === alias) { + schemaRequestAliases.delete(schemaId); + } + } + await client.sendNotification(SchemaContentChangeNotification.type, [...schemaIds]); } window.showInformationMessage(l10n.t('JSON schema cache cleared.')); })); @@ -419,10 +431,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 +448,14 @@ 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); + const content = await runtime.schemaRequests.getContent(requestUrl.href); + if (runtime.schemaRequests.clearCache && requestUrl.href !== uriPath) { + schemaRequestAliases.set(uriPath, { requestUrl: requestUrl.href }); + } + return content; } catch (e) { throw new ResponseError(SchemaRequestServiceErrors.HTTPError, e.toString(), e); } @@ -689,20 +711,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 +735,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 +1021,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/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/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..c251420939f578 --- /dev/null +++ b/extensions/json-language-features/client/src/test/schemaRequestTestUtils.ts @@ -0,0 +1,274 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +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[]; + cache?: boolean; + schemaResponse?: () => { content: string; etag?: string }; + onCacheUpdate?: () => Promise; +} + +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) { + 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 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() { } }); + 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(type: MessageType, value: string | string[]) { + if (type.method === 'json/schemaContent') { + schemaNotifications.push(typeof value === 'string' ? [value] : [...value]); + } + } + } + + 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: (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, showInformationMessage: async () => undefined }, + 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 + }, + '@vscode/extension-telemetry': class { dispose() { } }, + fs: { + promises: { + ...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 }); + const response = options.schemaResponse?.() ?? { content: schema }; + return { status: 200, headers: { etag: response.etag }, responseText: response.content }; + } + } + }; + 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: 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); + await options.onCacheUpdate?.(); + }, + setKeysForSync: () => { } + }, + 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, + 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() { + 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 new file mode 100644 index 00000000000000..d936f0c2a10f94 --- /dev/null +++ b/extensions/json-language-features/client/src/test/schemaRequests.test.ts @@ -0,0 +1,336 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +const encodedSeparators = ['%252F', '%252f']; + +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); + }); + + 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, + '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: [] }); + }); + }); + + 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'); + 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: [] + }); + }); + }); + }); +} + +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('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', { + 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 a870c2d072626c..df782c80aa5956 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 effective URL used for schema trust checks and request dispatch. + */ +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, path: url.pathname }); +} + /** * 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