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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions