Skip to content

fetchHeaders has no SSRF protection — arbitrary/internal URLs are fetched unchecked #91

Description

@dmchaledev

Problem

fetchHeaders (src/fetch.ts:5) takes any user-supplied URL and fetches it with no scheme, hostname, or IP validation:

export async function fetchHeaders(url: string, options?: FetchOptions): Promise<Record<string, string>> {
  ...
  const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: controller.signal });
  ...
}

analyze() (src/index.ts:11) passes any string straight through to this function with zero validation in between.

That's fine for the CLI used interactively against a URL a human typed in. It's an SSRF vector the moment this library is embedded in a service that scans user- or customer-supplied targets server-side — which is exactly the stated use case in the README ("ASM platform integrations that need automated header auditing on every deployment") and this repo's own homepage (hailbytes.com/asm).

Concretely, today analyze(untrustedUrl) will happily:

  • Fetch http://169.254.169.254/latest/meta-data/... (AWS/GCP/Azure instance metadata) and return whatever headers come back.
  • Fetch http://localhost:6379, http://10.x.x.x, http://192.168.x.x, or any other RFC1918/loopback/link-local address, probing internal services from wherever the scanning service runs.
  • Follow redirects (redirect: 'follow') from an allowed public host to any of the above, since only the initial URL could plausibly be checked — nothing currently checks any hop.
  • Fetch DNS names that resolve to internal IPs (DNS rebinding), since there's no post-resolution IP check at all today.

Because the failure mode is "returns an HTTP header report" rather than a crash, this is easy to miss in review — it looks like normal library behavior, not a bug.

Proposed fix

In src/fetch.ts:

  1. Before fetching, reject non-http:/https: schemes with a clear error.
  2. Resolve the hostname (e.g. via dns.lookup) and reject loopback, link-local (including 169.254.0.0/16), private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and unique-local/link-local IPv6 (::1, fc00::/7, fe80::/10) targets by default.
  3. Don't rely on redirect: 'follow' validating only the first hop — either use redirect: 'manual' and re-validate each Location header before following (bounded to a small max hop count), or otherwise ensure every hop is checked.
  4. Add an explicit opt-out, e.g. fetchHeaders(url, { allowPrivateNetworks: true }) / a CLI --allow-private flag, so legitimate local/staging use (http://localhost:3000 during dev) keeps working — this should not be secure-by-accident-breakage, it should be an intentional default with an escape hatch.
  5. Add tests in test/fetch.test.ts (this file doesn't exist yet — see Add CLI integration tests — cli.ts and fetch.ts have 0% coverage, including the exit-code CI-gate feature #65) covering: metadata IP rejection, RFC1918 rejection, loopback rejection, non-http(s) scheme rejection, redirect-to-private-IP rejection, and the opt-out flag working.

Why this is high-leverage

  • It's a security header analysis tool shipping an SSRF gap in its own fetch path — that's a credibility risk if it's ever pointed out publicly, on a project whose whole value proposition is security hardening.
  • It's a known, well-understood class of bug (OWASP SSRF) with a small, well-scoped fix — no architecture change needed, contained entirely to fetch.ts plus a couple of new tests.
  • It directly protects the exact deployment model this README advertises (server-side scanning of arbitrary/customer-supplied targets), not just the interactive CLI case.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions