(url: string)
| 226 | * - browser-manager.ts:restoreState |
| 227 | */ |
| 228 | export async function validateNavigationUrl(url: string): Promise<string> { |
| 229 | // Normalize non-standard file:// shapes before the URL parser sees them. |
| 230 | let normalized = url; |
| 231 | if (url.toLowerCase().startsWith('file:')) { |
| 232 | normalized = normalizeFileUrl(url); |
| 233 | } |
| 234 | |
| 235 | let parsed: URL; |
| 236 | try { |
| 237 | parsed = new URL(normalized); |
| 238 | } catch { |
| 239 | throw new Error(`Invalid URL: ${url}`); |
| 240 | } |
| 241 | |
| 242 | // file:// path: validate against safe-dirs and allow; otherwise defer to http(s) logic. |
| 243 | if (parsed.protocol === 'file:') { |
| 244 | // Reject non-empty non-localhost hosts (UNC / network paths). |
| 245 | if (parsed.host !== '' && parsed.host.toLowerCase() !== 'localhost') { |
| 246 | throw new Error( |
| 247 | `Unsupported file URL host: ${parsed.host}. Use file:///<absolute-path> for local files.` |
| 248 | ); |
| 249 | } |
| 250 | |
| 251 | // Convert URL → filesystem path with proper decoding (handles %20, %2F, etc.) |
| 252 | // fileURLToPath strips query + hash; we reattach them after validation so SPA |
| 253 | // fixture URLs like file:///tmp/app.html?route=home#login survive intact. |
| 254 | let fsPath: string; |
| 255 | try { |
| 256 | fsPath = fileURLToPath(parsed); |
| 257 | } catch (e: any) { |
| 258 | throw new Error(`Invalid file URL: ${url} (${e.message})`); |
| 259 | } |
| 260 | |
| 261 | // Reject path traversal after decoding — e.g. file:///tmp/safe%2F..%2Fetc/passwd |
| 262 | // Note: fileURLToPath doesn't collapse .., so a literal '..' in the decoded path |
| 263 | // is suspicious. path.resolve will normalize it; check the result against safe dirs. |
| 264 | validateReadPath(fsPath); |
| 265 | |
| 266 | // Return the canonical file:// URL derived from the filesystem path + original |
| 267 | // query + hash. This guarantees page.goto() gets a well-formed URL regardless |
| 268 | // of input shape while preserving SPA route/query params. |
| 269 | return pathToFileURL(fsPath).href + parsed.search + parsed.hash; |
| 270 | } |
| 271 | |
| 272 | if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { |
| 273 | throw new Error( |
| 274 | `Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, and file: URLs are permitted.` |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | const hostname = normalizeHostname(parsed.hostname.toLowerCase()); |
| 279 | |
| 280 | if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname) || isBlockedIpv6(hostname)) { |
| 281 | throw new Error( |
| 282 | `Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.` |
| 283 | ); |
| 284 | } |
| 285 |
no test coverage detected