diff --git a/README.md b/README.md index d85cb02..3be3e10 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 a local/internal target (disables SSRF protection — see below) +npx @hailbytes/security-headers http://localhost:3000 --allow-private ``` ### Library — analyze a URL @@ -77,6 +80,22 @@ for (const h of report.headers) { --- +## SSRF Protection + +`analyze()`/`fetchHeaders()` reject non-`http(s)` schemes and, by default, refuse to fetch loopback, link-local, and private-use addresses (RFC1918/RFC4193, cloud metadata endpoints like `169.254.169.254`, etc.) — including when a redirect chain leads there. This matters if you embed this library in a service that scans user- or customer-supplied URLs. + +To scan local/staging targets, opt in explicitly: + +```ts +await analyze('http://localhost:3000', { allowPrivateNetworks: true }); +``` + +```bash +npx @hailbytes/security-headers http://localhost:3000 --allow-private +``` + +--- + ## Report Shape ```ts diff --git a/src/cli.ts b/src/cli.ts index a6d2e8b..85b951e 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 loopback/private/link-local targets (e.g. localhost)'); + 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 allowPrivate = 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 } : {}), + ...(allowPrivate ? { allowPrivateNetworks: true } : {}), + }); 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..855c338 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,21 +1,109 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + export interface FetchOptions { timeoutMs?: number; + /** + * By default, requests to loopback, link-local, and private-use addresses + * (RFC1918/RFC4193, cloud metadata endpoints, etc.) are rejected to prevent + * SSRF when this library scans user- or customer-supplied URLs server-side. + * Set true to scan local/staging targets (e.g. http://localhost:3000). + */ + allowPrivateNetworks?: boolean; +} + +const MAX_REDIRECTS = 5; + +function ipv4ToInt(ip: string): number { + return ip.split('.').reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0; +} + +const IPV4_PRIVATE_RANGES: [string, string][] = [ + ['0.0.0.0', '0.255.255.255'], + ['10.0.0.0', '10.255.255.255'], + ['100.64.0.0', '100.127.255.255'], + ['127.0.0.0', '127.255.255.255'], + ['169.254.0.0', '169.254.255.255'], + ['172.16.0.0', '172.31.255.255'], + ['192.0.0.0', '192.0.0.255'], + ['192.168.0.0', '192.168.255.255'], + ['198.18.0.0', '198.19.255.255'], + ['224.0.0.0', '255.255.255.255'], +]; + +function isPrivateIPv4(ip: string): boolean { + const int = ipv4ToInt(ip); + return IPV4_PRIVATE_RANGES.some(([start, end]) => int >= ipv4ToInt(start) && int <= ipv4ToInt(end)); +} + +function isPrivateIPv6(ip: string): boolean { + const normalized = ip.toLowerCase(); + if (normalized === '::1' || normalized === '::') return true; + if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true; // fe80::/10 link-local + if (/^f[cd][0-9a-f]{2}:/.test(normalized)) return true; // fc00::/7 unique local + const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateIPv4(mapped[1]); + return false; +} + +function isPrivateAddress(ip: string): boolean { + const family = isIP(ip); + if (family === 4) return isPrivateIPv4(ip); + if (family === 6) return isPrivateIPv6(ip); + return true; // unresolvable family — treat as unsafe rather than silently allow +} + +async function assertPublicUrl(url: URL): Promise { + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Refusing to fetch unsupported URL scheme "${url.protocol}" (only http/https are allowed)`); + } + const hostname = url.hostname.replace(/^\[|\]$/g, ''); + if (isIP(hostname)) { + if (isPrivateAddress(hostname)) { + throw new Error(`Refusing to fetch private/internal address: ${hostname}`); + } + return; + } + const { address } = await lookup(hostname); + if (isPrivateAddress(address)) { + throw new Error(`Refusing to fetch host "${hostname}" — it resolves to private/internal address ${address}`); + } } export async function fetchHeaders(url: string, options?: FetchOptions): Promise> { const timeoutMs = options?.timeoutMs ?? 10000; + const allowPrivateNetworks = options?.allowPrivateNetworks ?? false; 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 currentUrl = new URL(url); + for (let redirectCount = 0; ; redirectCount++) { + if (allowPrivateNetworks) { + if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') { + throw new Error(`Refusing to fetch unsupported URL scheme "${currentUrl.protocol}" (only http/https are allowed)`); + } + } else { + await assertPublicUrl(currentUrl); + } + + // redirect: 'manual' so every hop — not just the initial URL — is + // validated above before being followed, closing the SSRF-via-redirect gap. + const res = await fetch(currentUrl, { method: 'GET', redirect: 'manual', signal: controller.signal }); + + if (res.status >= 300 && res.status < 400 && res.headers.has('location')) { + try { await res.body?.cancel(); } catch { /* body may be absent or already closed */ } + if (redirectCount >= MAX_REDIRECTS) { + throw new Error(`Too many redirects (> ${MAX_REDIRECTS})`); + } + currentUrl = new URL(res.headers.get('location')!, currentUrl); + 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..59ce49c --- /dev/null +++ b/test/fetch.test.ts @@ -0,0 +1,109 @@ +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 mockResponse(status: number, headers: Record) { + return { + status, + headers: { + has: (k: string) => k.toLowerCase() in headers, + get: (k: string) => headers[k.toLowerCase()] ?? null, + forEach: (cb: (v: string, k: string) => void) => { + for (const [k, v] of Object.entries(headers)) cb(v, k); + }, + }, + body: { cancel: vi.fn() }, + }; +} + +describe('fetchHeaders — SSRF protection', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + vi.mocked(lookup).mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('rejects non-http(s) schemes', async () => { + await expect(fetchHeaders('file:///etc/passwd')).rejects.toThrow(/scheme/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects literal loopback IPs', async () => { + await expect(fetchHeaders('http://127.0.0.1/')).rejects.toThrow(/private\/internal/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects cloud metadata endpoint', async () => { + await expect(fetchHeaders('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/private\/internal/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects RFC1918 private ranges', async () => { + await expect(fetchHeaders('http://10.0.0.5/')).rejects.toThrow(/private\/internal/i); + await expect(fetchHeaders('http://192.168.1.1/')).rejects.toThrow(/private\/internal/i); + await expect(fetchHeaders('http://172.16.0.1/')).rejects.toThrow(/private\/internal/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects IPv6 loopback and unique-local addresses', async () => { + await expect(fetchHeaders('http://[::1]/')).rejects.toThrow(/private\/internal/i); + await expect(fetchHeaders('http://[fc00::1]/')).rejects.toThrow(/private\/internal/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects hostnames that resolve to a private address', async () => { + vi.mocked(lookup).mockResolvedValue({ address: '169.254.169.254', family: 4 } as any); + await expect(fetchHeaders('http://metadata.internal/')).rejects.toThrow(/resolves to private\/internal/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('allows public hostnames that resolve to a public address', async () => { + vi.mocked(lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 } as any); + vi.mocked(fetch).mockResolvedValue(mockResponse(200, { 'content-security-policy': "default-src 'self'" }) as any); + const headers = await fetchHeaders('https://example.com/'); + expect(headers['content-security-policy']).toBe("default-src 'self'"); + }); + + it('rejects a redirect hop that points at a private address', async () => { + vi.mocked(lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 } as any); + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(302, { location: 'http://169.254.169.254/latest/meta-data/' }) as any); + await expect(fetchHeaders('https://example.com/')).rejects.toThrow(/private\/internal/i); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('follows redirects between public hosts', async () => { + vi.mocked(lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 } as any); + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse(301, { location: 'https://example.com/next' }) as any) + .mockResolvedValueOnce(mockResponse(200, { 'x-frame-options': 'DENY' }) as any); + const headers = await fetchHeaders('https://example.com/'); + expect(headers['x-frame-options']).toBe('DENY'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('gives up after too many redirects', async () => { + vi.mocked(lookup).mockResolvedValue({ address: '93.184.216.34', family: 4 } as any); + vi.mocked(fetch).mockResolvedValue(mockResponse(302, { location: 'https://example.com/loop' }) as any); + await expect(fetchHeaders('https://example.com/')).rejects.toThrow(/too many redirects/i); + }); + + it('allowPrivateNetworks opts out of address validation', async () => { + vi.mocked(fetch).mockResolvedValue(mockResponse(200, { 'x-content-type-options': 'nosniff' }) as any); + const headers = await fetchHeaders('http://localhost:3000/', { allowPrivateNetworks: true }); + expect(headers['x-content-type-options']).toBe('nosniff'); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('still rejects non-http(s) schemes even with allowPrivateNetworks', async () => { + await expect(fetchHeaders('ftp://internal/', { allowPrivateNetworks: true })).rejects.toThrow(/scheme/i); + expect(fetch).not.toHaveBeenCalled(); + }); +});