garrytan/gstack · error · Error

Blocked: scheme "${parsed.protocol}" is not allowed. Only ht

Error message

Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, and file: URLs are permitted.

What it means

Navigation-safety guard at url-validation.ts:271-274. After file: is handled and http:/https: are allowed, any remaining scheme is rejected. This is an SSRF/script-injection control blocking data:, javascript:, chrome:, blob:, ftp:, about: and every other protocol from reaching page.goto().

Source

Thrown at browse/src/url-validation.ts:273

    try {
      fsPath = fileURLToPath(parsed);
    } catch (e: any) {
      throw new Error(`Invalid file URL: ${url} (${e.message})`);
    }

    // Reject path traversal after decoding — e.g. file:///tmp/safe%2F..%2Fetc/passwd
    // Note: fileURLToPath doesn't collapse .., so a literal '..' in the decoded path
    // is suspicious. path.resolve will normalize it; check the result against safe dirs.
    validateReadPath(fsPath);

    // Return the canonical file:// URL derived from the filesystem path + original
    // query + hash. This guarantees page.goto() gets a well-formed URL regardless
    // of input shape while preserving SPA route/query params.
    return pathToFileURL(fsPath).href + parsed.search + parsed.hash;
  }

  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    throw new Error(
      `Blocked: scheme "${parsed.protocol}" is not allowed. Only http:, https:, and file: URLs are permitted.`
    );
  }

  const hostname = normalizeHostname(parsed.hostname.toLowerCase());

  if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname) || isBlockedIpv6(hostname)) {
    throw new Error(
      `Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.`
    );
  }

  // DNS rebinding protection: resolve hostname and check if it points to metadata IPs.
  // Skip for loopback/private IPs — they can't be DNS-rebinded and the async DNS
  // resolution adds latency that breaks concurrent E2E tests under load.
  const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
  const isPrivateNet = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/.test(hostname);
  if (!isLoopback && !isPrivateNet && await resolvesToBlockedIp(hostname)) {

View on GitHub (pinned to 94993f7401)

Solutions

  1. Serve the content from http://localhost:<port> instead
  2. For inline HTML, use the load-html command (setContent) rather than goto
  3. Write the data URI's payload to a temp file and navigate via file:///
  4. Use a plain http(s) URL for any real page

Example fix

// before
await goto('data:text/html,<h1>hello</h1>')
// after — use load-html for inline content
await handleWriteCommand('load-html', ['--from-file', 'payload.json'], session, bm)
Defensive patterns

Strategy: validation

Validate before calling

const ALLOWED = new Set(['http:', 'https:', 'file:'])
function isAllowedScheme(u: string): boolean {
  try { return ALLOWED.has(new URL(u).protocol) } catch { return false }
}

Type guard

function isNavigableUrl(u: string): u is string {
  try {
    const p = new URL(u)
    return p.protocol === 'http:' || p.protocol === 'https:' || p.protocol === 'file:'
  } catch { return false }
}

Prevention

When it happens

Trigger: Calling goto/validateNavigationUrl with a URL whose protocol is not http:, https:, or file: — e.g. data:text/html,<h1>hi, javascript:alert(1), chrome://settings, blob:..., about:blank, ftp://host/f.

Common situations: Trying to load an inline HTML data URI as a fixture; using about:blank as a starting page; scraped content containing a blob: or chrome: link; test code that defaults to an empty data URI.

Related errors


AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12). Data as JSON: /api/errors/99694baa3529a773. Report an issue: GitHub.