url-validation.ts
299 lines · browse/src/url-validation.ts at 3f79d81
1/**
2 * URL validation for navigation commands — blocks dangerous schemes and cloud metadata endpoints.
3 * Localhost and private IPs are allowed (primary use case: QA testing local dev servers).
4 */
6import { fileURLToPath, pathToFileURL } from 'node:url';
7import * as path from 'node:path';
8import * as os from 'node:os';
9import { validateReadPath } from './path-security';
11/** Metadata services reached by name rather than address. */
12const BLOCKED_METADATA_HOSTNAMES = new Set([
13 'metadata.google.internal', // GCP metadata
14 'metadata.azure.internal', // Azure IMDS
15]);
17/**
18 * Classify one host literal (URL hostname, cookie domain, or a resolved A/AAAA
19 * answer). Returns null when the host is not an IP literal. Single source of
20 * truth for navigation (literal, DNS answers, redirects, page-driven
21 * navigations) and for session-persist's cookie-domain guard.
22 *
23 * - 'blocked': IPv4 link-local 169.254.0.0/16 (cloud metadata and container
24 * credential services such as 169.254.170.2), Alibaba's 100.100.100.200,
25 * IPv6 link-local fe80::/10 and ULA fc00::/7, and any of these embedded in
26 * IPv4-mapped (::ffff:), IPv4-compatible (::) or NAT64 (64:ff9b::) IPv6.
27 * - 'loopback': 127.0.0.0/8 and ::1.
28 * - 'other': every other address (RFC 1918 dev servers stay allowed).
29 *
30 * The URL parser canonicalizes decimal, octal and hex IPv4 forms
31 * (2852039166, 0251.0376.0251.0376, 0xA9FEA9FE) and IPv6 spellings first.
32 */
33export function classifyAddress(host: string): 'blocked' | 'loopback' | 'other' | null {
34 let h = host.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
35 if (!h) return null;
36 if (h.includes(':')) {
37 try { h = new URL(`http://[${h}]/`).hostname.slice(1, -1); } catch { return null; }
38 const groups = expandIpv6(h);
39 if (!groups) return null;
40 const head = groups.slice(0, 6).map(g => g.toString(16)).join(':');
41 if (head === '0:0:0:0:0:ffff' || head === '64:ff9b:0:0:0:0' || (head === '0:0:0:0:0:0' && groups[6] !== 0)) {
42 return classifyIpv4([groups[6] >> 8, groups[6] & 255, groups[7] >> 8, groups[7] & 255]);
43 }
44 if (groups.every((g, i) => g === (i === 7 ? 1 : 0))) return 'loopback';
45 if ((groups[0] & 0xfe00) === 0xfc00 || (groups[0] & 0xffc0) === 0xfe80) return 'blocked';
46 return 'other';
47 }
48 try { h = new URL(`http://${h}/`).hostname; } catch { return null; }
49 const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
50 return m ? classifyIpv4(m.slice(1).map(Number)) : null;
51}
53function classifyIpv4([a, b, c, d]: number[]): 'blocked' | 'loopback' | 'other' {
54 if (a === 169 && b === 254) return 'blocked';
55 if (a === 100 && b === 100 && c === 100 && d === 200) return 'blocked';
56 return a === 127 ? 'loopback' : 'other';
57}
59/** Expand a canonical IPv6 literal to eight 16-bit groups. */
60function expandIpv6(addr: string): number[] | null {
61 const halves = addr.split('::');
62 if (halves.length > 2) return null;
63 const parse = (part: string) => (part ? part.split(':') : []).map(g => parseInt(g, 16));
64 const head = parse(halves[0]);
65 const tail = halves.length === 2 ? parse(halves[1]) : [];
66 const fill = 8 - head.length - tail.length;
67 if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
68 const groups = [...head, ...Array(fill).fill(0), ...tail];
69 return groups.every(g => Number.isInteger(g) && g >= 0 && g <= 0xffff) ? groups : null;
70}
72/**
73 * Resolve a hostname and report whether any A or AAAA answer is blocked.
74 * Each family is tried independently; a missing family is not a risk. DNS
75 * infrastructure failure fails open. Connection-time rebinding (a different
76 * answer when Chromium connects) is out of scope.
77 */
78async function resolvesToBlockedIp(hostname: string): Promise<boolean> {
79 try {
80 const { resolve4, resolve6 } = (await import('node:dns')).promises;
81 const check = (lookup: Promise<string[]>) => lookup.then(
82 (addresses) => addresses.some(addr => classifyAddress(addr) === 'blocked'),
83 () => false,
84 );
85 const [v4, v6] = await Promise.all([check(resolve4(hostname)), check(resolve6(hostname))]);
86 return v4 || v6;
87 } catch {
88 return false;
89 }
90}
92/**
93 * Why an http(s) navigation target is refused, or null when it is allowed.
94 * Applied to explicit navigations (validateNavigationUrl) and to every
95 * navigation request the browser makes, including redirect hops and
96 * page-driven navigations (BrowserManager's navigation guard).
97 */
98export async function blockedNavigationReason(url: string): Promise<string | null> {
99 let parsed: URL;
100 try { parsed = new URL(url); } catch { return null; }
101 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
102 const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
103 const kind = classifyAddress(hostname);
104 if (kind === 'blocked' || BLOCKED_METADATA_HOSTNAMES.has(hostname)) {
105 return `Blocked: ${parsed.hostname} is a cloud metadata or link-local address. Access is denied for security.`;
106 }
107 if (kind !== null || hostname === 'localhost') return null;
108 if (await resolvesToBlockedIp(hostname)) {
109 return `Blocked: ${parsed.hostname} resolves to a cloud metadata or link-local address. Possible DNS rebinding attack.`;
110 }
111 return null;
112}
114/**
115 * Normalize non-standard file:// URLs into absolute form before the WHATWG URL parser
116 * sees them. Handles cwd-relative, home-relative, and bare-segment shapes that the
117 * standard parser would otherwise mis-interpret as hostnames.
118 *
119 * file:///abs/path.html → unchanged
120 * file://./<rel> → file://<cwd>/<rel>
121 * file://~/<rel> → file://<HOME>/<rel>
122 * file://<single-segment>/... → file://<cwd>/<single-segment>/... (cwd-relative)
123 * file://localhost/<abs> → unchanged
124 * file://<host-like>/... → unchanged (caller rejects via host heuristic)
125 *
126 * Rejects empty (file://) and root-only (file:///) URLs — these would silently
127 * trigger Chromium's directory listing, which is a different product surface.
128 */
129export function normalizeFileUrl(url: string): string {
130 if (!url.toLowerCase().startsWith('file:')) return url;
132 // Split off query + fragment BEFORE touching the path — SPAs + fixture URLs rely
133 // on these. path.resolve would URL-encode `?` and `#` as `%3F`/`%23` (and
134 // pathToFileURL drops them entirely), silently routing preview URLs to the
135 // wrong fixture. Extract, normalize the path, reattach at the end.
136 //
137 // Parse order: `?` before `#` per RFC 3986 — '?' in a fragment is literal.
138 // Find the FIRST `?` or `#`, whichever comes first, and take everything
139 // after (including the delimiter) as the trailing segment.
140 const qIdx = url.indexOf('?');
141 const hIdx = url.indexOf('#');
142 let delimIdx = -1;
143 if (qIdx >= 0 && hIdx >= 0) delimIdx = Math.min(qIdx, hIdx);
144 else if (qIdx >= 0) delimIdx = qIdx;
145 else if (hIdx >= 0) delimIdx = hIdx;
147 const pathPart = delimIdx >= 0 ? url.slice(0, delimIdx) : url;
148 const trailing = delimIdx >= 0 ? url.slice(delimIdx) : '';
150 const rest = pathPart.slice('file:'.length);
152 // file:/// or longer → standard absolute; pass through unchanged (caller validates path).
153 if (rest.startsWith('///')) {
154 // Reject bare root-only (file:/// with nothing after)
155 if (rest === '///' || rest === '////') {
156 throw new Error('Invalid file URL: file:/// has no path. Use file:///<absolute-path>.');
157 }
158 return pathPart + trailing;
159 }
161 // Everything else: must start with // (we accept file://... only)
162 if (!rest.startsWith('//')) {
163 throw new Error(`Invalid file URL: ${url}. Use file:///<absolute-path> or file://./<rel> or file://~/<rel>.`);
164 }
166 const afterDoubleSlash = rest.slice(2);
168 // Reject empty (file://) and trailing-slash-only (file://./ listing cwd).
169 if (afterDoubleSlash === '') {
170 throw new Error('Invalid file URL: file:// is empty. Use file:///<absolute-path>.');
171 }
172 if (afterDoubleSlash === '.' || afterDoubleSlash === './') {
173 throw new Error('Invalid file URL: file://./ would list the current directory. Use file://./<filename> to render a specific file.');
174 }
175 if (afterDoubleSlash === '~' || afterDoubleSlash === '~/') {
176 throw new Error('Invalid file URL: file://~/ would list the home directory. Use file://~/<filename> to render a specific file.');
177 }
179 // Home-relative: file://~/<rel>
180 if (afterDoubleSlash.startsWith('~/')) {
181 const rel = afterDoubleSlash.slice(2);
182 const absPath = path.join(os.homedir(), rel);
183 return pathToFileURL(absPath).href + trailing;
184 }
186 // cwd-relative with explicit ./ : file://./<rel>
187 if (afterDoubleSlash.startsWith('./')) {
188 const rel = afterDoubleSlash.slice(2);
189 const absPath = path.resolve(process.cwd(), rel);
190 return pathToFileURL(absPath).href + trailing;
191 }
193 // localhost host explicitly allowed: file://localhost/<abs> (pass through to standard parser).
194 if (afterDoubleSlash.toLowerCase().startsWith('localhost/')) {
195 return pathPart + trailing;
196 }
198 // Ambiguous: file://<segment>/<rest> — treat as cwd-relative ONLY if <segment> is a
199 // simple path name (no dots, no colons, no backslashes, no percent-encoding, no
200 // IPv6 brackets, no Windows drive letter pattern).
201 const firstSlash = afterDoubleSlash.indexOf('/');
202 const segment = firstSlash === -1 ? afterDoubleSlash : afterDoubleSlash.slice(0, firstSlash);
204 // Reject host-like segments: dotted names (docs.v1), IPs (127.0.0.1), IPv6 ([::1]),
205 // drive letters (C:), percent-encoded, or backslash paths.
206 const looksLikeHost = /[.:\\%]/.test(segment) || segment.startsWith('[');
207 if (looksLikeHost) {
208 throw new Error(
209 `Unsupported file URL host: ${segment}. Use file:///<absolute-path> for local files (network/UNC paths are not supported).`
210 );
211 }
213 // Simple-segment cwd-relative: file://docs/page.html → cwd/docs/page.html
214 const absPath = path.resolve(process.cwd(), afterDoubleSlash);
215 return pathToFileURL(absPath).href + trailing;
216}
218/**
219 * Validate a navigation URL and return a normalized version suitable for page.goto().
220 *
221 * Callers MUST use the return value — normalization of non-standard file:// forms
222 * only takes effect at the navigation site, not at the original URL.
223 *
224 * Callers (keep this list current, grep before removing):
225 * - write-commands.ts:goto
226 * - meta-commands.ts:diff (both URL args)
227 * - browser-manager.ts:newTab
228 * - browser-manager.ts:restoreState
229 */
230export async function validateNavigationUrl(url: string): Promise<string> {
231 // Normalize non-standard file:// shapes before the URL parser sees them.
232 let normalized = url;
233 if (url.toLowerCase().startsWith('file:')) {
234 normalized = normalizeFileUrl(url);
235 }
237 let parsed: URL;
238 try {
239 parsed = new URL(normalized);
240 } catch {
241 throw new Error(`Invalid URL: ${url}`);
242 }
244 // file:// path: validate against safe-dirs and allow; otherwise defer to http(s) logic.
245 if (parsed.protocol === 'file:') {
246 // Reject non-empty non-localhost hosts (UNC / network paths).
247 if (parsed.host !== '' && parsed.host.toLowerCase() !== 'localhost') {
248 throw new Error(
249 `Unsupported file URL host: ${parsed.host}. Use file:///<absolute-path> for local files.`
250 );
251 }
253 // Convert URL → filesystem path with proper decoding (handles %20, %2F, etc.)
254 // fileURLToPath strips query + hash; we reattach them after validation so SPA
255 // fixture URLs like file:///tmp/app.html?route=home#login survive intact.
256 let fsPath: string;
257 try {
258 fsPath = fileURLToPath(parsed);
259 } catch (e: any) {
260 throw new Error(`Invalid file URL: ${url} (${e.message})`);
261 }
263 // Reject path traversal after decoding — e.g. file:///tmp/safe%2F..%2Fetc/passwd
264 // Note: fileURLToPath doesn't collapse .., so a literal '..' in the decoded path
265 // is suspicious. path.resolve will normalize it; check the result against safe dirs.
266 validateReadPath(fsPath);
268 // Return the canonical file:// URL derived from the filesystem path + original
269 // query + hash. This guarantees page.goto() gets a well-formed URL regardless
270 // of input shape while preserving SPA route/query params.
271 return pathToFileURL(fsPath).href + parsed.search + parsed.hash;
272 }
274 // about:blank ONLY — the canonical empty page, and the one the daemon opens its own
275 // first tab on. Blocking it meant `browse newtab about:blank` failed, which is what
276 // `make-pdf setup` runs as its Chromium smoke test: make-pdf reported "Chromium failed
277 // to launch" against a perfectly healthy Chromium, and any browse session whose daemon
278 // restarted could never recreate the blank tab it starts from.
279 //
280 // Deliberately not the whole `about:` scheme. about:blank has no origin, loads nothing
281 // and runs nothing; about:config, about:net-internals and friends are real surfaces.
282 // Exact href match, not a prefix test, so `about:blankfoo` stays blocked.
283 // Compared lower-cased: the URL parser normalises the PROTOCOL but not the opaque part,
284 // so `ABOUT:BLANK` parses to href `about:BLANK` and an exact === would reject it.
285 if (parsed.protocol === 'about:' && parsed.href.toLowerCase() === 'about:blank') {
286 return 'about:blank';
287 }
289 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
290 throw new Error(
291 `Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, file:, and about:blank URLs are permitted.`
292 );
293 }
295 const blocked = await blockedNavigationReason(parsed.href);
296 if (blocked) throw new Error(blocked);
298 return url;
299}