Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 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 an internal/local target (disabled by default, see Security below)
npx @hailbytes/security-headers http://localhost:3000 --allow-private
```

### Library — analyze a URL
Expand Down Expand Up @@ -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.
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 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');
Expand Down Expand Up @@ -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 <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 } : {}),
...(allowPrivateNetworks ? { allowPrivateNetworks } : {}),
});
if (jsonMode) { console.log(JSON.stringify(report, null, 2)); }
else { printReport(report); }
if (report.grade === 'D' || report.grade === 'F') process.exit(1);
Expand Down
97 changes: 88 additions & 9 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,100 @@
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<void> {
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<Record<string, string>> {
const timeoutMs = options?.timeoutMs ?? 10000;
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 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<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
108 changes: 108 additions & 0 deletions test/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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();
});
});
Loading