[json] add trustedDomains settings (#287639)

* use trusted schemas

* [json] add trustedDomains settings
This commit is contained in:
Martin Aeschlimann
2026-01-14 00:31:19 +01:00
committed by GitHub
parent 4641b2abb8
commit 067cb03d18
8 changed files with 469 additions and 112 deletions

View File

@@ -7,9 +7,9 @@ export type JSONLanguageStatus = { schemas: string[] };
import {
workspace, window, languages, commands, LogOutputChannel, ExtensionContext, extensions, Uri, ColorInformation,
Diagnostic, StatusBarAlignment, TextEditor, TextDocument, FormattingOptions, CancellationToken, FoldingRange,
Diagnostic, StatusBarAlignment, TextDocument, FormattingOptions, CancellationToken, FoldingRange,
ProviderResult, TextEdit, Range, Position, Disposable, CompletionItem, CompletionList, CompletionContext, Hover, MarkdownString, FoldingContext, DocumentSymbol, SymbolInformation, l10n,
RelativePattern
RelativePattern, CodeAction, CodeActionKind, CodeActionContext
} from 'vscode';
import {
LanguageClientOptions, RequestType, NotificationType, FormattingOptions as LSPFormattingOptions, DocumentDiagnosticReportKind,
@@ -20,8 +20,9 @@ import {
import { hash } from './utils/hash';
import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem } from './languageStatus';
import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem, createSchemaLoadIssueItem, createSchemaLoadStatusItem } from './languageStatus';
import { getLanguageParticipants, LanguageParticipants } from './languageParticipants';
import { matchesUrlPattern } from './utils/urlMatch';
namespace VSCodeContentRequest {
export const type: RequestType<string, string, any> = new RequestType('vscode/content');
@@ -42,6 +43,7 @@ namespace LanguageStatusRequest {
namespace ValidateContentRequest {
export const type: RequestType<{ schemaUri: string; content: string }, LSPDiagnostic[], any> = new RequestType('json/validateContent');
}
interface SortOptions extends LSPFormattingOptions {
}
@@ -110,6 +112,7 @@ export namespace SettingIds {
export const enableKeepLines = 'json.format.keepLines';
export const enableValidation = 'json.validate.enable';
export const enableSchemaDownload = 'json.schemaDownload.enable';
export const trustedDomains = 'json.schemaDownload.trustedDomains';
export const maxItemsComputed = 'json.maxItemsComputed';
export const editorFoldingMaximumRegions = 'editor.foldingMaximumRegions';
export const editorColorDecoratorsLimit = 'editor.colorDecoratorsLimit';
@@ -119,6 +122,17 @@ export namespace SettingIds {
export const colorDecoratorsLimit = 'colorDecoratorsLimit';
}
export namespace CommandIds {
export const workbenchActionOpenSettings = 'workbench.action.openSettings';
export const workbenchTrustManage = 'workbench.trust.manage';
export const retryResolveSchemaCommandId = '_json.retryResolveSchema';
export const configureTrustedDomainsCommandId = '_json.configureTrustedDomains';
export const showAssociatedSchemaList = '_json.showAssociatedSchemaList';
export const clearCacheCommandId = 'json.clearCache';
export const validateCommandId = 'json.validate';
export const sortCommandId = 'json.sort';
}
export interface TelemetryReporter {
sendTelemetryEvent(eventName: string, properties?: {
[key: string]: string;
@@ -143,6 +157,16 @@ export interface SchemaRequestService {
clearCache?(): Promise<string[]>;
}
export enum SchemaRequestServiceErrors {
UntrustedWorkspaceError = 1,
UntrustedSchemaError = 2,
OpenTextDocumentAccessError = 3,
HTTPDisabledError = 4,
HTTPError = 5,
VSCodeAccessError = 6,
UntitledAccessError = 7,
}
export const languageServerDescription = l10n.t('JSON Language Server');
let resultLimit = 5000;
@@ -191,6 +215,8 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
const toDispose: Disposable[] = [];
let rangeFormatting: Disposable | undefined = undefined;
let settingsCache: Settings | undefined = undefined;
let schemaAssociationsCache: Promise<ISchemaAssociation[]> | undefined = undefined;
const documentSelector = languageParticipants.documentSelector;
@@ -200,14 +226,18 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
toDispose.push(schemaResolutionErrorStatusBarItem);
const fileSchemaErrors = new Map<string, string>();
let schemaDownloadEnabled = true;
let schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload);
let trustedDomains = workspace.getConfiguration().get<Record<string, boolean>>(SettingIds.trustedDomains, {});
let isClientReady = false;
const documentSymbolsLimitStatusbarItem = createLimitStatusItem((limit: number) => createDocumentSymbolsLimitItem(documentSelector, SettingIds.maxItemsComputed, limit));
toDispose.push(documentSymbolsLimitStatusbarItem);
toDispose.push(commands.registerCommand('json.clearCache', async () => {
const schemaLoadStatusItem = createSchemaLoadStatusItem((diagnostic: Diagnostic) => createSchemaLoadIssueItem(documentSelector, schemaDownloadEnabled, diagnostic));
toDispose.push(schemaLoadStatusItem);
toDispose.push(commands.registerCommand(CommandIds.clearCacheCommandId, async () => {
if (isClientReady && runtime.schemaRequests.clearCache) {
const cachedSchemas = await runtime.schemaRequests.clearCache();
await client.sendNotification(SchemaContentChangeNotification.type, cachedSchemas);
@@ -215,12 +245,12 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
window.showInformationMessage(l10n.t('JSON schema cache cleared.'));
}));
toDispose.push(commands.registerCommand('json.validate', async (schemaUri: Uri, content: string) => {
toDispose.push(commands.registerCommand(CommandIds.validateCommandId, async (schemaUri: Uri, content: string) => {
const diagnostics: LSPDiagnostic[] = await client.sendRequest(ValidateContentRequest.type, { schemaUri: schemaUri.toString(), content });
return diagnostics.map(client.protocol2CodeConverter.asDiagnostic);
}));
toDispose.push(commands.registerCommand('json.sort', async () => {
toDispose.push(commands.registerCommand(CommandIds.sortCommandId, async () => {
if (isClientReady) {
const textEditor = window.activeTextEditor;
@@ -239,17 +269,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
}
}));
function filterSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] {
const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError);
if (schemaErrorIndex !== -1) {
const schemaResolveDiagnostic = diagnostics[schemaErrorIndex];
fileSchemaErrors.set(uri.toString(), schemaResolveDiagnostic.message);
if (!schemaDownloadEnabled) {
diagnostics = diagnostics.filter(d => !isSchemaResolveError(d));
}
if (window.activeTextEditor && window.activeTextEditor.document.uri.toString() === uri.toString()) {
schemaResolutionErrorStatusBarItem.show();
}
function handleSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] {
schemaLoadStatusItem.update(uri, diagnostics);
if (!schemaDownloadEnabled) {
return diagnostics.filter(d => !isSchemaResolveError(d));
}
return diagnostics;
}
@@ -270,18 +293,18 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
},
middleware: {
workspace: {
didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() })
didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) })
},
provideDiagnostics: async (uriOrDoc, previousResolutId, token, next) => {
const diagnostics = await next(uriOrDoc, previousResolutId, token);
if (diagnostics && diagnostics.kind === DocumentDiagnosticReportKind.Full) {
const uri = uriOrDoc instanceof Uri ? uriOrDoc : uriOrDoc.uri;
diagnostics.items = filterSchemaErrorDiagnostics(uri, diagnostics.items);
diagnostics.items = handleSchemaErrorDiagnostics(uri, diagnostics.items);
}
return diagnostics;
},
handleDiagnostics: (uri: Uri, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) => {
diagnostics = filterSchemaErrorDiagnostics(uri, diagnostics);
diagnostics = handleSchemaErrorDiagnostics(uri, diagnostics);
next(uri, diagnostics);
},
// testing the replace / insert mode
@@ -373,7 +396,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
const uri = Uri.parse(uriPath);
const uriString = uri.toString(true);
if (uri.scheme === 'untitled') {
throw new ResponseError(3, l10n.t('Unable to load {0}', uriString));
throw new ResponseError(SchemaRequestServiceErrors.UntitledAccessError, l10n.t('Unable to load {0}', uriString));
}
if (uri.scheme === 'vscode') {
try {
@@ -382,7 +405,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
const content = await workspace.fs.readFile(uri);
return new TextDecoder().decode(content);
} catch (e) {
throw new ResponseError(5, e.toString(), e);
throw new ResponseError(SchemaRequestServiceErrors.VSCodeAccessError, e.toString(), e);
}
} else if (uri.scheme !== 'http' && uri.scheme !== 'https') {
try {
@@ -390,9 +413,15 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
schemaDocuments[uriString] = true;
return document.getText();
} catch (e) {
throw new ResponseError(2, e.toString(), e);
throw new ResponseError(SchemaRequestServiceErrors.OpenTextDocumentAccessError, e.toString(), e);
}
} else if (schemaDownloadEnabled) {
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));
}
} else if (schemaDownloadEnabled && workspace.isTrusted) {
if (runtime.telemetry && uri.authority === 'schema.management.azure.com') {
/* __GDPR__
"json.schema" : {
@@ -406,13 +435,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
try {
return await runtime.schemaRequests.getContent(uriString);
} catch (e) {
throw new ResponseError(4, e.toString());
throw new ResponseError(SchemaRequestServiceErrors.HTTPError, e.toString(), e);
}
} else {
if (!workspace.isTrusted) {
throw new ResponseError(1, l10n.t('Downloading schemas is disabled in untrusted workspaces'));
}
throw new ResponseError(1, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload));
throw new ResponseError(SchemaRequestServiceErrors.HTTPDisabledError, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload));
}
});
@@ -427,19 +453,6 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
}
return false;
};
const handleActiveEditorChange = (activeEditor?: TextEditor) => {
if (!activeEditor) {
return;
}
const activeDocUri = activeEditor.document.uri.toString();
if (activeDocUri && fileSchemaErrors.has(activeDocUri)) {
schemaResolutionErrorStatusBarItem.show();
} else {
schemaResolutionErrorStatusBarItem.hide();
}
};
const handleContentClosed = (uriString: string) => {
if (handleContentChange(uriString)) {
delete schemaDocuments[uriString];
@@ -484,59 +497,81 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
toDispose.push(workspace.onDidChangeTextDocument(e => handleContentChange(e.document.uri.toString())));
toDispose.push(workspace.onDidCloseTextDocument(d => handleContentClosed(d.uri.toString())));
toDispose.push(window.onDidChangeActiveTextEditor(handleActiveEditorChange));
toDispose.push(commands.registerCommand(CommandIds.retryResolveSchemaCommandId, triggerValidation));
const handleRetryResolveSchemaCommand = () => {
if (window.activeTextEditor) {
schemaResolutionErrorStatusBarItem.text = '$(watch)';
const activeDocUri = window.activeTextEditor.document.uri.toString();
client.sendRequest(ForceValidateRequest.type, activeDocUri).then((diagnostics) => {
const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError);
if (schemaErrorIndex !== -1) {
// Show schema resolution errors in status bar only; ref: #51032
const schemaResolveDiagnostic = diagnostics[schemaErrorIndex];
fileSchemaErrors.set(activeDocUri, schemaResolveDiagnostic.message);
} else {
schemaResolutionErrorStatusBarItem.hide();
toDispose.push(commands.registerCommand(CommandIds.configureTrustedDomainsCommandId, configureTrustedDomains));
toDispose.push(languages.registerCodeActionsProvider(documentSelector, {
provideCodeActions(_document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] {
const codeActions: CodeAction[] = [];
for (const diagnostic of context.diagnostics) {
if (typeof diagnostic.code !== 'number') {
continue;
}
schemaResolutionErrorStatusBarItem.text = '$(alert)';
});
switch (diagnostic.code) {
case ErrorCodes.UntrustedSchemaError: {
const title = l10n.t('Configure Trusted Domains...');
const action = new CodeAction(title, CodeActionKind.QuickFix);
const schemaUri = diagnostic.relatedInformation?.[0]?.location.uri;
if (schemaUri) {
action.command = { command: CommandIds.configureTrustedDomainsCommandId, arguments: [schemaUri.toString()], title };
} else {
action.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.trustedDomains], title };
}
action.diagnostics = [diagnostic];
action.isPreferred = true;
codeActions.push(action);
}
break;
case ErrorCodes.HTTPDisabledError: {
const title = l10n.t('Enable Schema Downloading...');
const action = new CodeAction(title, CodeActionKind.QuickFix);
action.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.enableSchemaDownload], title };
action.diagnostics = [diagnostic];
action.isPreferred = true;
codeActions.push(action);
}
break;
}
}
return codeActions;
}
};
toDispose.push(commands.registerCommand('_json.retryResolveSchema', handleRetryResolveSchemaCommand));
client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations());
toDispose.push(extensions.onDidChange(async _ => {
client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations());
}, {
providedCodeActionKinds: [CodeActionKind.QuickFix]
}));
const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern(
Uri.parse(`vscode://schemas-associations/`),
'**/schemas-associations.json')
);
client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(false));
toDispose.push(extensions.onDidChange(async _ => {
client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true));
}));
const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern(Uri.parse(`vscode://schemas-associations/`), '**/schemas-associations.json'));
toDispose.push(associationWatcher);
toDispose.push(associationWatcher.onDidChange(async _e => {
client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations());
client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true));
}));
// manually register / deregister format provider based on the `json.format.enable` setting avoiding issues with late registration. See #71652.
updateFormatterRegistration();
toDispose.push({ dispose: () => rangeFormatting && rangeFormatting.dispose() });
updateSchemaDownloadSetting();
toDispose.push(workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(SettingIds.enableFormatter)) {
updateFormatterRegistration();
} else if (e.affectsConfiguration(SettingIds.enableSchemaDownload)) {
updateSchemaDownloadSetting();
schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload);
triggerValidation();
} else if (e.affectsConfiguration(SettingIds.editorFoldingMaximumRegions) || e.affectsConfiguration(SettingIds.editorColorDecoratorsLimit)) {
client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() });
client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) });
} else if (e.affectsConfiguration(SettingIds.trustedDomains)) {
trustedDomains = workspace.getConfiguration().get<Record<string, boolean>>(SettingIds.trustedDomains, {});
triggerValidation();
}
}));
toDispose.push(workspace.onDidGrantWorkspaceTrust(updateSchemaDownloadSetting));
toDispose.push(workspace.onDidGrantWorkspaceTrust(() => triggerValidation()));
toDispose.push(createLanguageStatusItem(documentSelector, (uri: string) => client.sendRequest(LanguageStatusRequest.type, uri)));
@@ -572,20 +607,13 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
}
}
function updateSchemaDownloadSetting() {
if (!workspace.isTrusted) {
schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Unable to download schemas in untrusted workspaces.');
schemaResolutionErrorStatusBarItem.command = 'workbench.trust.manage';
return;
}
schemaDownloadEnabled = workspace.getConfiguration().get(SettingIds.enableSchemaDownload) !== false;
if (schemaDownloadEnabled) {
schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Unable to resolve schema. Click to retry.');
schemaResolutionErrorStatusBarItem.command = '_json.retryResolveSchema';
handleRetryResolveSchemaCommand();
} else {
schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Downloading schemas is disabled. Click to configure.');
schemaResolutionErrorStatusBarItem.command = { command: 'workbench.action.openSettings', arguments: [SettingIds.enableSchemaDownload], title: '' };
async function triggerValidation() {
const activeTextEditor = window.activeTextEditor;
if (activeTextEditor && languageParticipants.hasLanguage(activeTextEditor.document.languageId)) {
schemaResolutionErrorStatusBarItem.text = '$(watch)';
schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Validating...');
const activeDocUri = activeTextEditor.document.uri.toString();
await client.sendRequest(ForceValidateRequest.type, activeDocUri);
}
}
@@ -612,6 +640,113 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
});
}
function getSettings(forceRefresh: boolean): Settings {
if (!settingsCache || forceRefresh) {
settingsCache = computeSettings();
}
return settingsCache;
}
async function getSchemaAssociations(forceRefresh: boolean): Promise<ISchemaAssociation[]> {
if (!schemaAssociationsCache || forceRefresh) {
schemaAssociationsCache = computeSchemaAssociations();
runtime.logOutputChannel.info(`Computed schema associations: ${(await schemaAssociationsCache).map(a => `${a.uri} -> [${a.fileMatch.join(', ')}]`).join('\n')}`);
}
return schemaAssociationsCache;
}
async function isTrusted(uri: Uri): Promise<boolean> {
if (uri.scheme !== 'http' && uri.scheme !== 'https') {
return true;
}
const uriString = uri.toString(true);
// Check against trustedDomains setting
if (matchesUrlPattern(uri, trustedDomains)) {
return true;
}
const knownAssociations = await getSchemaAssociations(false);
for (const association of knownAssociations) {
if (association.uri === uriString) {
return true;
}
}
const settingsCache = getSettings(false);
if (settingsCache.json && settingsCache.json.schemas) {
for (const schemaSetting of settingsCache.json.schemas) {
const schemaUri = schemaSetting.url;
if (schemaUri === uriString) {
return true;
}
}
}
return false;
}
async function configureTrustedDomains(schemaUri: string): Promise<void> {
interface QuickPickItemWithAction {
label: string;
description?: string;
execute: () => Promise<void>;
}
const items: QuickPickItemWithAction[] = [];
try {
const uri = Uri.parse(schemaUri);
const domain = `${uri.scheme}://${uri.authority}`;
// Add "Trust domain" option
items.push({
label: l10n.t('Trust Domain: {0}', domain),
description: l10n.t('Allow all schemas from this domain'),
execute: async () => {
const config = workspace.getConfiguration();
const currentDomains = config.get<Record<string, boolean>>(SettingIds.trustedDomains, {});
currentDomains[domain] = true;
await config.update(SettingIds.trustedDomains, currentDomains, true);
await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains);
}
});
// Add "Trust URI" option
items.push({
label: l10n.t('Trust URI: {0}', schemaUri),
description: l10n.t('Allow only this specific schema'),
execute: async () => {
const config = workspace.getConfiguration();
const currentDomains = config.get<Record<string, boolean>>(SettingIds.trustedDomains, {});
currentDomains[schemaUri] = true;
await config.update(SettingIds.trustedDomains, currentDomains, true);
await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains);
}
});
} catch (e) {
runtime.logOutputChannel.error(`Failed to parse schema URI: ${schemaUri}`);
}
// Always add "Configure setting" option
items.push({
label: l10n.t('Configure Setting'),
description: l10n.t('Open settings editor'),
execute: async () => {
await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains);
}
});
const selected = await window.showQuickPick(items, {
placeHolder: l10n.t('Select how to configure trusted schema domains')
});
if (selected) {
await selected.execute();
}
}
return {
dispose: async () => {
await client.stop();
@@ -621,9 +756,9 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP
};
}
async function getSchemaAssociations(): Promise<ISchemaAssociation[]> {
return getSchemaExtensionAssociations()
.concat(await getDynamicSchemaAssociations());
async function computeSchemaAssociations(): Promise<ISchemaAssociation[]> {
const extensionAssociations = getSchemaExtensionAssociations();
return extensionAssociations.concat(await getDynamicSchemaAssociations());
}
function getSchemaExtensionAssociations(): ISchemaAssociation[] {
@@ -680,7 +815,9 @@ async function getDynamicSchemaAssociations(): Promise<ISchemaAssociation[]> {
return result;
}
function getSettings(): Settings {
function computeSettings(): Settings {
const configuration = workspace.getConfiguration();
const httpSettings = workspace.getConfiguration('http');
@@ -781,8 +918,14 @@ function updateMarkdownString(h: MarkdownString): MarkdownString {
return n;
}
function isSchemaResolveError(d: Diagnostic) {
return d.code === /* SchemaResolveError */ 0x300;
export namespace ErrorCodes {
export const SchemaResolveError = 0x10000;
export const UntrustedSchemaError = SchemaResolveError + SchemaRequestServiceErrors.UntrustedSchemaError;
export const HTTPDisabledError = SchemaResolveError + SchemaRequestServiceErrors.HTTPDisabledError;
}
export function isSchemaResolveError(d: Diagnostic) {
return typeof d.code === 'number' && d.code >= ErrorCodes.SchemaResolveError;
}