Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ function printHelp() {
console.log(' npx @hailbytes/security-headers <url> [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');
Expand Down Expand Up @@ -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 <url> [--json] [--timeout ms] [--help] [--version]');
console.error('Usage: security-headers <url> [--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);
Expand Down
106 changes: 97 additions & 9 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Record<string, string>> {
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<string, string> = {};
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<string, string> = {};
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);
}
Expand Down
109 changes: 109 additions & 0 deletions test/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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();
});
});
Loading