diff --git a/src/vs/workbench/contrib/url/browser/trustedDomains.ts b/src/vs/workbench/contrib/url/browser/trustedDomains.ts index 9f71f4b6f4bd093c0bde2ee9834f7a6281263871..0c7ed2b207063d53f593a5919d5614d55e47b245 100644 --- a/src/vs/workbench/contrib/url/browser/trustedDomains.ts +++ b/src/vs/workbench/contrib/url/browser/trustedDomains.ts @@ -36,10 +36,8 @@ export const manageTrustedDomainSettingsCommand = { } }; -type ConfigureTrustedDomainChoice = 'trustDomain' | 'trustSubdomain' | 'trustAll' | 'manage'; -interface ConfigureTrustedDomainsQuickPickItem extends IQuickPickItem { - id: ConfigureTrustedDomainChoice; -} +type ConfigureTrustedDomainsQuickPickItem = IQuickPickItem & ({ id: 'manage'; } | { id: 'trust'; toTrust: string }); + type ConfigureTrustedDomainsChoiceClassification = { choice: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; }; @@ -59,34 +57,54 @@ export async function configureOpenerTrustedDomainsHandler( const toplevelDomainSegements = parsedDomainToConfigure.authority.split('.'); const domainEnd = toplevelDomainSegements.slice(toplevelDomainSegements.length - 2).join('.'); const topLevelDomain = '*.' + domainEnd; + const options: ConfigureTrustedDomainsQuickPickItem[] = []; - const trustDomainAndOpenLinkItem: ConfigureTrustedDomainsQuickPickItem = { + options.push({ type: 'item', label: localize('trustedDomain.trustDomain', 'Trust {0}', domainToConfigure), - id: 'trustDomain', + id: 'trust', + toTrust: domainToConfigure, picked: true - }; - const trustSubDomainAndOpenLinkItem: ConfigureTrustedDomainsQuickPickItem = { - type: 'item', - label: localize('trustedDomain.trustSubDomain', 'Trust {0} and all its subdomains', domainEnd), - id: 'trustSubdomain' - }; - const openAllLinksItem: ConfigureTrustedDomainsQuickPickItem = { + }); + + const isIP = + toplevelDomainSegements.length === 4 && + toplevelDomainSegements.every(segment => + Number.isInteger(+segment) || Number.isInteger(+segment.split(':')[0])); + + if (isIP) { + if (parsedDomainToConfigure.authority.includes(':')) { + const base = parsedDomainToConfigure.authority.split(':')[0]; + options.push({ + type: 'item', + label: localize('trustedDomain.trustAllPorts', 'Trust {0} on all ports', base), + toTrust: base + ':*', + id: 'trust' + }); + } + } else { + options.push({ + type: 'item', + label: localize('trustedDomain.trustSubDomain', 'Trust {0} and all its subdomains', domainEnd), + toTrust: topLevelDomain, + id: 'trust' + }); + } + + options.push({ type: 'item', label: localize('trustedDomain.trustAllDomains', 'Trust all domains (disables link protection)'), - id: 'trustAll' - }; - const manageTrustedDomainItem: ConfigureTrustedDomainsQuickPickItem = { + toTrust: '*', + id: 'trust' + }); + options.push({ type: 'item', label: localize('trustedDomain.manageTrustedDomains', 'Manage Trusted Domains'), id: 'manage' - }; + }); const pickedResult = await quickInputService.pick( - [trustDomainAndOpenLinkItem, trustSubDomainAndOpenLinkItem, openAllLinksItem, manageTrustedDomainItem], - { - activeItem: trustDomainAndOpenLinkItem - } + options, { activeItem: options[0] } ); if (pickedResult && pickedResult.id) { @@ -104,13 +122,8 @@ export async function configureOpenerTrustedDomainsHandler( notificationService.prompt(Severity.Info, localize('configuringURL', "Configuring trust for: {0}", resource.toString()), [{ label: 'Copy', run: () => clipboardService.writeText(resource.toString()) }]); return trustedDomains; - case 'trustDomain': - case 'trustSubdomain': - case 'trustAll': - const itemToTrust = pickedResult.id === 'trustDomain' - ? domainToConfigure - : pickedResult.id === 'trustSubdomain' ? topLevelDomain : '*'; - + case 'trust': + const itemToTrust = pickedResult.toTrust; if (trustedDomains.indexOf(itemToTrust) === -1) { storageService.remove(TRUSTED_DOMAINS_CONTENT_STORAGE_KEY, StorageScope.GLOBAL); storageService.store( diff --git a/src/vs/workbench/contrib/url/browser/trustedDomainsFileSystemProvider.ts b/src/vs/workbench/contrib/url/browser/trustedDomainsFileSystemProvider.ts index b34f0b5ba6f44b1840cec30cdbb640a630dc189f..e96b47b314aed68df7d6d9c2010fdb3c5c4b3745 100644 --- a/src/vs/workbench/contrib/url/browser/trustedDomainsFileSystemProvider.ts +++ b/src/vs/workbench/contrib/url/browser/trustedDomainsFileSystemProvider.ts @@ -28,11 +28,15 @@ const TRUSTED_DOMAINS_STAT: IStat = { const CONFIG_HELP_TEXT_PRE = `// Links matching one or more entries in the list below can be opened without link protection. // The following examples show what entries can look like: // - "https://microsoft.com": Matches this specific domain using https +// - "https://microsoft.com:8080": Matches this specific domain on this port using https +// - "https://microsoft.com:*": Matches this specific domain on any port using https // - "https://microsoft.com/foo": Matches https://microsoft.com/foo and https://microsoft.com/foo/bar, // but not https://microsoft.com/foobar or https://microsoft.com/bar // - "https://*.microsoft.com": Match all domains ending in "microsoft.com" using https // - "microsoft.com": Match this specific domain using either http or https // - "*.microsoft.com": Match all domains ending in "microsoft.com" using either http or https +// - "http://192.168.0.1: Matches this specific IP using http +// - "http://192.168.0.*: Matches all IP's with this prefix using http // - "*": Match all domains using either http or https // `; diff --git a/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts b/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts index e8efdfeba2e1dd272208233673be44141e1e72e7..235931c0287d990fdc0d32672fd4fd9869aa1c67 100644 --- a/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts +++ b/src/vs/workbench/contrib/url/browser/trustedDomainsValidator.ts @@ -188,76 +188,97 @@ export function isURLDomainTrusted(url: URI, trustedDomains: string[]) { return true; } - const domain = `${url.scheme}://${url.authority}`; - for (let i = 0; i < trustedDomains.length; i++) { if (trustedDomains[i] === '*') { return true; } - if (trustedDomains[i] === domain) { + if (isTrusted(url.toString(), trustedDomains[i])) { return true; } + } - let parsedTrustedDomain; - if (/^https?:\/\//.test(trustedDomains[i])) { - parsedTrustedDomain = URI.parse(trustedDomains[i]); - if (url.scheme !== parsedTrustedDomain.scheme) { - continue; - } - } else { - parsedTrustedDomain = URI.parse('https://' + trustedDomains[i]); - } + return false; +} - if (url.authority === parsedTrustedDomain.authority) { - if (pathMatches(url.path, parsedTrustedDomain.path)) { - return true; - } else { - continue; - } - } +export const isTrusted = (url: string, trustedURL: string): boolean => { + const normalize = (url: string) => url.replace(/\/+$/, ''); + trustedURL = normalize(trustedURL); + url = normalize(url); - if (trustedDomains[i].indexOf('*') !== -1) { + const memo = Array.from({ length: url.length + 1 }).map(() => + Array.from({ length: trustedURL.length + 1 }).map(() => undefined), + ); - let reversedAuthoritySegments = url.authority.split('.').reverse(); - const reversedTrustedDomainAuthoritySegments = parsedTrustedDomain.authority.split('.').reverse(); + if (/^[^./:]*:\/\//.test(trustedURL)) { + return doURLMatch(memo, url, trustedURL, 0, 0); + } - if ( - reversedTrustedDomainAuthoritySegments.length < reversedAuthoritySegments.length && - reversedTrustedDomainAuthoritySegments[reversedTrustedDomainAuthoritySegments.length - 1] === '*' - ) { - reversedAuthoritySegments = reversedAuthoritySegments.slice(0, reversedTrustedDomainAuthoritySegments.length); - } + const scheme = /^(https?):\/\//.exec(url)?.[1]; + if (scheme) { + return doURLMatch(memo, url, `${scheme}://${trustedURL}`, 0, 0); + } - const authorityMatches = reversedAuthoritySegments.every((val, i) => { - return reversedTrustedDomainAuthoritySegments[i] === '*' || val === reversedTrustedDomainAuthoritySegments[i]; - }); + return false; +}; - if (authorityMatches && pathMatches(url.path, parsedTrustedDomain.path)) { - return true; - } - } +const doURLMatch = ( + memo: (boolean | undefined)[][], + url: string, + trustedURL: string, + urlOffset: number, + trustedURLOffset: number, +): boolean => { + if (memo[urlOffset]?.[trustedURLOffset] !== undefined) { + return memo[urlOffset][trustedURLOffset]!; } - return false; -} + const options = []; -function pathMatches(open: string, rule: string) { - if (rule === '/') { - return true; + // Endgame. + // Fully exact match + if (urlOffset === url.length) { + return trustedURLOffset === trustedURL.length; } - if (rule[rule.length - 1] === '/') { - rule = rule.slice(0, -1); + // Some path remaining in url + if (trustedURLOffset === trustedURL.length) { + const remaining = url.slice(urlOffset); + return remaining[0] === '/'; } - const openSegments = open.split('/'); - const ruleSegments = rule.split('/'); - for (let i = 0; i < ruleSegments.length; i++) { - if (ruleSegments[i] !== openSegments[i]) { - return false; + if (url[urlOffset] === trustedURL[trustedURLOffset]) { + // Exact match. + options.push(doURLMatch(memo, url, trustedURL, urlOffset + 1, trustedURLOffset + 1)); + } + + if (trustedURL[trustedURLOffset] + trustedURL[trustedURLOffset + 1] === '*.') { + // Any subdomain match. Either consume one thing that's not a / or : and don't advance base or consume nothing and do. + if (!['/', ':'].includes(url[urlOffset])) { + options.push(doURLMatch(memo, url, trustedURL, urlOffset + 1, trustedURLOffset)); } + options.push(doURLMatch(memo, url, trustedURL, urlOffset, trustedURLOffset + 2)); } - return true; -} + if (trustedURL[trustedURLOffset] + trustedURL[trustedURLOffset + 1] === '.*' && url[urlOffset] === '.') { + // IP mode. Consume one segment of numbers or nothing. + let endBlockIndex = urlOffset + 1; + do { endBlockIndex++; } while (/[0-9]/.test(url[endBlockIndex])); + if (['.', ':', '/', undefined].includes(url[endBlockIndex])) { + options.push(doURLMatch(memo, url, trustedURL, endBlockIndex, trustedURLOffset + 2)); + } + } + + if (trustedURL[trustedURLOffset] + trustedURL[trustedURLOffset + 1] === ':*') { + // any port match. Consume a port if it exists otherwise nothing. Always comsume the base. + if (url[urlOffset] === ':') { + let endPortIndex = urlOffset + 1; + do { endPortIndex++; } while (/[0-9]/.test(url[endPortIndex])); + options.push(doURLMatch(memo, url, trustedURL, endPortIndex, trustedURLOffset + 2)); + } else { + options.push(doURLMatch(memo, url, trustedURL, urlOffset, trustedURLOffset + 2)); + } + } + + return (memo[urlOffset][trustedURLOffset] = options.some(a => a === true)); +}; diff --git a/src/vs/workbench/contrib/url/test/browser/trustedDomains.test.ts b/src/vs/workbench/contrib/url/test/browser/trustedDomains.test.ts index 8f314d5a134933362466b4fc83f89cb7810d20e9..6f8ec1ad0abf93fee8b75c06e3c34856e695279d 100644 --- a/src/vs/workbench/contrib/url/test/browser/trustedDomains.test.ts +++ b/src/vs/workbench/contrib/url/test/browser/trustedDomains.test.ts @@ -10,10 +10,10 @@ import { URI } from 'vs/base/common/uri'; import { extractGitHubRemotesFromGitConfig } from 'vs/workbench/contrib/url/browser/trustedDomains'; function linkAllowedByRules(link: string, rules: string[]) { - assert.ok(isURLDomainTrusted(URI.parse(link), rules), `Link\n${link}\n should be protected by rules\n${JSON.stringify(rules)}`); + assert.ok(isURLDomainTrusted(URI.parse(link), rules), `Link\n${link}\n should be allowed by rules\n${JSON.stringify(rules)}`); } function linkNotAllowedByRules(link: string, rules: string[]) { - assert.ok(!isURLDomainTrusted(URI.parse(link), rules), `Link\n${link}\n should NOT be protected by rules\n${JSON.stringify(rules)}`); + assert.ok(!isURLDomainTrusted(URI.parse(link), rules), `Link\n${link}\n should NOT be allowed by rules\n${JSON.stringify(rules)}`); } suite('GitHub remote extraction', () => { @@ -63,11 +63,6 @@ suite('Link protection domain matching', () => { test('* star', () => { linkAllowedByRules('https://a.x.org', ['https://*.x.org']); linkAllowedByRules('https://a.b.x.org', ['https://*.x.org']); - linkAllowedByRules('https://a.x.org', ['https://a.x.*']); - linkAllowedByRules('https://a.x.org', ['https://a.*.org']); - linkAllowedByRules('https://a.x.org', ['https://*.*.org']); - linkAllowedByRules('https://a.b.x.org', ['https://*.b.*.org']); - linkAllowedByRules('https://a.a.b.x.org', ['https://*.b.*.org']); }); test('no scheme', () => { @@ -102,6 +97,25 @@ suite('Link protection domain matching', () => { linkAllowedByRules('https://github.com', ['https://github.com/foo/bar', 'https://github.com']); }); + test('ports', () => { + linkNotAllowedByRules('https://x.org:8080/foo/bar', ['https://x.org:8081/foo']); + linkAllowedByRules('https://x.org:8080/foo/bar', ['https://x.org:*/foo']); + linkAllowedByRules('https://x.org/foo/bar', ['https://x.org:*/foo']); + linkAllowedByRules('https://x.org:8080/foo/bar', ['https://x.org:8080/foo']); + }); + + test('ip addresses', () => { + linkAllowedByRules('http://192.168.1.7/', ['http://192.168.1.7/']); + linkAllowedByRules('http://192.168.1.7/', ['http://192.168.1.7']); + linkAllowedByRules('http://192.168.1.7/', ['http://192.168.1.*']); + + linkNotAllowedByRules('http://192.168.1.7:3000/', ['http://192.168.*.6:*']); + linkAllowedByRules('http://192.168.1.7:3000/', ['http://192.168.1.7:3000/']); + linkAllowedByRules('http://192.168.1.7:3000/', ['http://192.168.1.7:*']); + linkAllowedByRules('http://192.168.1.7:3000/', ['http://192.168.1.*:*']); + linkNotAllowedByRules('http://192.168.1.7:3000/', ['http://192.168.*.6:*']); + }); + test('case normalization', () => { // https://github.com/microsoft/vscode/issues/99294 linkAllowedByRules('https://github.com/microsoft/vscode/issues/new', ['https://github.com/microsoft']);