Repository: microsoft/vscode (TypeScript)
Date: 2020-10-14 01:44:19+00:00
Task type: Bug Fix

Issue:
Trusted Domains link protection parses IP addresses as having subdomains
Issue Type: <b>Bug</b>

Write http://192.168.1.7:3000/ into a text file. Command-click it to try to open the link.

You will get an alert asking whether you want to open the external website. Click “Configure Trusted Domains”.

<img width="1025" alt="link and dialog asking to confirm opening external website" src="https://user-images.githubusercontent.com/79168/69498548-0a219b00-0eb7-11ea-8b4a-741ded63f8af.png">

Look at the second option in the menu. It says “Trust 1.7:3000 and all its subdomains”. But 1.7:3000 is **not a valid address**.

<img width="1024" alt="link and menu with buggy entry “Trust 1.7:3000 and all its subdomains”" src="https://user-images.githubusercontent.com/79168/69498551-0db52200-0eb7-11ea-879a-52c05ba106c7.png">

I can only guess that VS Code thinks that “7” is a TLD and “1.7” is a full domain name.

What I actually want to do is whitelist opening all links to IP addresses like `192.168.1.*`. In other words, white-list the subnet `192.168.1.0/24`. At least, I think that makes sense. But I am not a network engineer, so if it makes more sense to recommend to the user whitelisting some other subnet such as `192.168.0.0/16`, or whitelisting only the address `192.168.1.7` but on any port, that would be fine too.

VS Code version: Code 1.40.1 (8795a9889db74563ddd43eb0a897a2384129a619, 2019-11-13T16:47:44.719Z)
OS version: Darwin x64 18.7.0


<!-- generated by issue reporter -->


Issue discussion:

Solution patch:
diff --git a/src/vs/workbench/contrib/url/browser/trustedDomains.ts b/src/vs/workbench/contrib/url/browser/trustedDomains.ts
--- 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<ConfigureTrustedDomainsQuickPickItem>(
-		[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
--- 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
--- 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));
+};


Test patch:
diff --git a/src/vs/workbench/contrib/url/test/browser/trustedDomains.test.ts b/src/vs/workbench/contrib/url/test/browser/trustedDomains.test.ts
--- 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']);