From b9c457ce5a2446a73c40978d4bc2e2cfb9693570 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:12:04 +0000 Subject: [PATCH] fix: add SSRF protections to fetchHeaders (closes #91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchHeaders took any user-supplied URL and fetched it with no scheme, hostname, or IP validation, and followed redirects unchecked — an SSRF vector when this library scans customer-supplied targets server-side (its advertised ASM/CI-gate use case). It could reach cloud metadata endpoints, loopback, and RFC1918 addresses, including via a redirect hop to an otherwise-disallowed target. Reject non-http(s) schemes, resolve and reject loopback/link-local/ private hostnames by default, and validate each redirect hop instead of only the initial URL (bounded to 5 hops). Add an explicit opt-out (allowPrivateNetworks / --allow-private) for legitimate local/staging scans. Adds test/fetch.test.ts, which previously had 0% coverage (#65). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M9yz9hxzVPHy9QdTvA4tA4 --- CHANGELOG.md | 5 +++ README.md | 11 +++++ src/cli.ts | 17 ++++--- src/fetch.ts | 97 ++++++++++++++++++++++++++++++++++++---- test/fetch.test.ts | 108 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 test/fetch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bec473..9cef251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [Unreleased] + +### Security +- `fetchHeaders`/`analyze(url)` now reject non-`http(s)` schemes and, by default, refuse to fetch hostnames that resolve to loopback, link-local (including the cloud metadata endpoint `169.254.169.254`), or private (RFC1918) addresses. Redirects are validated hop-by-hop instead of only checking the initial URL, and bounded to 5 hops. Opt out with `{ allowPrivateNetworks: true }` or CLI `--allow-private` for local/staging targets. + ## [1.0.1] - 2025-05-19 ### Added diff --git a/README.md b/README.md index d85cb02..96ebf96 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ npx @hailbytes/security-headers https://example.com --json # Use as a CI gate (exits 1 on grade D or F) npx @hailbytes/security-headers https://staging.example.com || echo "Security headers gate failed" + +# Scan an internal/local target (disabled by default, see Security below) +npx @hailbytes/security-headers http://localhost:3000 --allow-private ``` ### Library — analyze a URL @@ -130,6 +133,14 @@ interface HeaderFinding { --- +## Security + +`analyze(url)` / `fetchHeaders(url)` refuse non-`http(s)` schemes and, by default, refuse to fetch hostnames that resolve to loopback, link-local (including the `169.254.169.254` cloud metadata endpoint), or private (RFC1918) addresses — including via a redirect chain, which is validated hop-by-hop rather than trusting only the initial URL. This matters when the URL being scanned comes from an untrusted source (e.g. a customer-supplied target in an ASM pipeline), where an unguarded fetch is an SSRF vector. + +For legitimate local/staging use, pass `{ allowPrivateNetworks: true }` (library) or `--allow-private` (CLI) to opt out. + +--- + ## Who Is This For Security engineers, DevSecOps teams, and ASM platform integrations that need automated header auditing on every deployment, pentesters who run this against every target scope, and developers who want to verify their app's security posture without leaving the terminal. diff --git a/src/cli.ts b/src/cli.ts index a6d2e8b..dfe6d1a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,10 +37,11 @@ function printHelp() { console.log(' npx @hailbytes/security-headers [options]'); console.log(''); console.log(`${B}Options:${R}`); - console.log(' --json Output report as JSON'); - console.log(' --timeout ms Fetch timeout in milliseconds (default: 10000)'); - console.log(' --version Print version and exit'); - console.log(' --help Print this help and exit'); + console.log(' --json Output report as JSON'); + console.log(' --timeout ms Fetch timeout in milliseconds (default: 10000)'); + console.log(' --allow-private Allow scanning hosts that resolve to private/internal IPs'); + console.log(' --version Print version and exit'); + console.log(' --help Print this help and exit'); console.log(''); console.log(`${B}Examples:${R}`); console.log(' security-headers https://example.com'); @@ -80,16 +81,20 @@ async function main() { } const jsonMode = args.includes('--json'); + const allowPrivateNetworks = args.includes('--allow-private'); const timeoutArg = args.find((a, i) => a === '--timeout' && args[i + 1]); const timeoutMs = timeoutArg ? parseInt(args[args.indexOf('--timeout') + 1], 10) : undefined; const url = args.find(a => !a.startsWith('--') && a !== String(timeoutMs)); if (!url) { - console.error('Usage: security-headers [--json] [--timeout ms] [--help] [--version]'); + console.error('Usage: security-headers [--json] [--timeout ms] [--allow-private] [--help] [--version]'); console.error('Run with --help for full usage information.'); process.exit(1); } try { - const report = await analyze(url, timeoutMs !== undefined ? { timeoutMs } : undefined); + const report = await analyze(url, { + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(allowPrivateNetworks ? { allowPrivateNetworks } : {}), + }); if (jsonMode) { console.log(JSON.stringify(report, null, 2)); } else { printReport(report); } if (report.grade === 'D' || report.grade === 'F') process.exit(1); diff --git a/src/fetch.ts b/src/fetch.ts index 22dc35c..9aec643 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,5 +1,72 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + export interface FetchOptions { timeoutMs?: number; + /** Allow fetching hosts that resolve to loopback/private/link-local addresses. Default: false. */ + allowPrivateNetworks?: boolean; +} + +const MAX_REDIRECTS = 5; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +function ipv4ToLong(ip: string): number { + return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; +} + +function isPrivateIPv4(ip: string): boolean { + const long = ipv4ToLong(ip); + const inRange = (base: string, bits: number) => { + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; + return (long & mask) === (ipv4ToLong(base) & mask); + }; + return ( + inRange('0.0.0.0', 8) || // "this" network + inRange('10.0.0.0', 8) || // private + inRange('100.64.0.0', 10) || // carrier-grade NAT + inRange('127.0.0.0', 8) || // loopback + inRange('169.254.0.0', 16) || // link-local (incl. cloud metadata endpoint) + inRange('172.16.0.0', 12) || // private + inRange('192.168.0.0', 16) || // private + inRange('198.18.0.0', 15) || // benchmarking + inRange('224.0.0.0', 4) || // multicast + inRange('240.0.0.0', 4) // reserved + ); +} + +function isPrivateIPv6(ip: string): boolean { + const lc = ip.toLowerCase(); + if (lc === '::' || lc === '::1') return true; + if (lc.startsWith('fe80:') || lc.startsWith('fc') || lc.startsWith('fd')) return true; // link-local + unique-local (fc00::/7) + const mapped = lc.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateIPv4(mapped[1]); + return false; +} + +function isPrivateOrReservedIP(ip: string): boolean { + const family = isIP(ip); + if (family === 4) return isPrivateIPv4(ip); + if (family === 6) return isPrivateIPv6(ip); + return true; // unclassifiable address — fail closed +} + +async function assertPublicUrl(url: URL, allowPrivateNetworks: boolean | undefined): Promise { + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Refusing to fetch unsupported scheme: ${url.protocol}`); + } + if (allowPrivateNetworks) return; + const hostname = url.hostname.replace(/^\[|\]$/g, ''); + let addresses: { address: string }[]; + try { + addresses = await lookup(hostname, { all: true }); + } catch { + throw new Error(`Could not resolve host: ${hostname}`); + } + for (const { address } of addresses) { + if (isPrivateOrReservedIP(address)) { + throw new Error(`Refusing to fetch private/internal address: ${hostname} resolved to ${address}`); + } + } } export async function fetchHeaders(url: string, options?: FetchOptions): Promise> { @@ -7,15 +74,27 @@ export async function fetchHeaders(url: string, options?: FetchOptions): Promise const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - // Use GET rather than HEAD: many sites (and CDNs/edge workers) emit security - // headers — notably Content-Security-Policy — only on full responses, so a - // HEAD request systematically under-reports them. We only need the headers, - // so the response body is discarded without being read. - const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: controller.signal }); - const headers: Record = {}; - res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); - try { await res.body?.cancel(); } catch { /* body may be absent or already closed */ } - return headers; + let current = new URL(url); + for (let hop = 0; ; hop++) { + await assertPublicUrl(current, options?.allowPrivateNetworks); + // Use GET rather than HEAD: many sites (and CDNs/edge workers) emit security + // headers — notably Content-Security-Policy — only on full responses, so a + // HEAD request systematically under-reports them. We only need the headers, + // so the response body is discarded without being read. + // redirect: 'manual' so each hop can be validated against the private-network + // check above before it's followed, instead of trusting only the first URL. + const res = await fetch(current, { method: 'GET', redirect: 'manual', signal: controller.signal }); + if (REDIRECT_STATUSES.has(res.status) && res.headers.has('location')) { + try { await res.body?.cancel(); } catch { /* body may be absent or already closed */ } + if (hop >= MAX_REDIRECTS) throw new Error(`Too many redirects (> ${MAX_REDIRECTS})`); + current = new URL(res.headers.get('location')!, current); + continue; + } + const headers: Record = {}; + res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); + try { await res.body?.cancel(); } catch { /* body may be absent or already closed */ } + return headers; + } } finally { clearTimeout(timer); } diff --git a/test/fetch.test.ts b/test/fetch.test.ts new file mode 100644 index 0000000..9fa4070 --- /dev/null +++ b/test/fetch.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('node:dns/promises', () => ({ + lookup: vi.fn(), +})); + +import { lookup } from 'node:dns/promises'; +import { fetchHeaders } from '../src/fetch.js'; + +function fakeResponse(status: number, headers: Record) { + return { + status, + headers: { + has: (k: string) => k.toLowerCase() in headers, + get: (k: string) => headers[k.toLowerCase()] ?? null, + forEach: (cb: (value: string, key: string) => void) => { + for (const [k, v] of Object.entries(headers)) cb(v, k); + }, + }, + body: { cancel: vi.fn().mockResolvedValue(undefined) }, + }; +} + +describe('fetchHeaders', () => { + beforeEach(() => { + vi.mocked(lookup).mockReset(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetches and lower-cases headers for a public host', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + vi.mocked(fetch).mockResolvedValue(fakeResponse(200, { 'Content-Security-Policy': "default-src 'self'" }) as never); + + const headers = await fetchHeaders('https://example.com'); + expect(headers['content-security-policy']).toBe("default-src 'self'"); + }); + + it('rejects non-http(s) schemes', async () => { + await expect(fetchHeaders('file:///etc/passwd')).rejects.toThrow(/unsupported scheme/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects hosts that resolve to loopback addresses', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '127.0.0.1', family: 4 }] as never); + await expect(fetchHeaders('http://localhost')).rejects.toThrow(/private\/internal/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects the cloud metadata endpoint (link-local range)', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '169.254.169.254', family: 4 }] as never); + await expect(fetchHeaders('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/private\/internal/i); + }); + + it('rejects RFC1918 private addresses', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '10.0.0.5', family: 4 }] as never); + await expect(fetchHeaders('http://internal.example.com')).rejects.toThrow(/private\/internal/i); + }); + + it('rejects IPv6 loopback and unique-local addresses', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '::1', family: 6 }] as never); + await expect(fetchHeaders('http://ipv6-loopback.example.com')).rejects.toThrow(/private\/internal/i); + + vi.mocked(lookup).mockResolvedValue([{ address: 'fd12:3456::1', family: 6 }] as never); + await expect(fetchHeaders('http://ipv6-ula.example.com')).rejects.toThrow(/private\/internal/i); + }); + + it('rejects a redirect that targets a private address', async () => { + vi.mocked(lookup).mockImplementation(async (hostname: string) => { + if (hostname === 'public.example.com') return [{ address: '93.184.216.34', family: 4 }] as never; + return [{ address: '169.254.169.254', family: 4 }] as never; + }); + vi.mocked(fetch).mockResolvedValue( + fakeResponse(302, { location: 'http://internal.example.com/latest/meta-data/' }) as never + ); + + await expect(fetchHeaders('https://public.example.com')).rejects.toThrow(/private\/internal/i); + }); + + it('follows a bounded number of redirects to a public host', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + vi.mocked(fetch) + .mockResolvedValueOnce(fakeResponse(301, { location: 'https://example.com/final' }) as never) + .mockResolvedValueOnce(fakeResponse(200, { 'x-frame-options': 'DENY' }) as never); + + const headers = await fetchHeaders('https://example.com/start'); + expect(headers['x-frame-options']).toBe('DENY'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('throws after exceeding the redirect limit', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + vi.mocked(fetch).mockImplementation(async () => fakeResponse(302, { location: 'https://example.com/next' }) as never); + + await expect(fetchHeaders('https://example.com/start')).rejects.toThrow(/too many redirects/i); + }); + + it('allows private networks when allowPrivateNetworks is set', async () => { + vi.mocked(fetch).mockResolvedValue(fakeResponse(200, { 'x-frame-options': 'DENY' }) as never); + + const headers = await fetchHeaders('http://localhost:3000', { allowPrivateNetworks: true }); + expect(headers['x-frame-options']).toBe('DENY'); + expect(lookup).not.toHaveBeenCalled(); + }); +});