← back to Directory Core
test/compliance-ssrf.test.ts
257 lines
// test/compliance-ssrf.test.ts
// Tests for the SSRF guard in src/compliance.ts (assertPublicHost, accessed
// indirectly via fetchCompliant and getRobotsFor).
//
// Strategy:
// - Mock dns.resolve4 / dns.resolve6 via mock.method from node:test.
// - Mock undici fetch via MockAgent so no real HTTP leaves the process.
// - Use unique hostnames per describe block so the module-level robotsCache
// never causes cross-test contamination.
//
// DATABASE_URL must be set before db.ts (transitively imported) loads.
process.env['DATABASE_URL'] = process.env['DATABASE_URL'] || 'postgresql://fake:fake@localhost:5432/fake';
import { describe, it, before, after, mock } from 'node:test';
import assert from 'node:assert/strict';
import dns from 'node:dns/promises';
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
import { fetchCompliant } from '../src/compliance.js';
// ── helpers ───────────────────────────────────────────────────────────────────
type RealDispatcher = ReturnType<typeof getGlobalDispatcher>;
function makeMockAgent(): MockAgent {
const agent = new MockAgent();
agent.disableNetConnect();
setGlobalDispatcher(agent);
return agent;
}
/** Wire up a MockAgent that replies 200 to every path on the given host scheme+host. */
function allow200(agent: MockAgent, schemeHost: string): void {
// robots.txt
agent.get(schemeHost)
.intercept({ path: '/robots.txt', method: 'GET' })
.reply(200, 'User-agent: *\nAllow: /', { headers: { 'content-type': 'text/plain' } });
// actual page
agent.get(schemeHost)
.intercept({ path: '/x', method: 'GET' })
.reply(200, 'OK', { headers: { 'content-type': 'text/plain' } });
}
// ── public host succeeds ──────────────────────────────────────────────────────
describe('fetchCompliant — SSRF: public IP passes through', () => {
const HOST = 'http://ssrf-public-unique.example';
let agent: MockAgent;
let realDispatcher: RealDispatcher;
let r4: ReturnType<typeof mock.method>;
let r6: ReturnType<typeof mock.method>;
before(() => {
realDispatcher = getGlobalDispatcher();
agent = makeMockAgent();
allow200(agent, HOST);
// Resolve to a real public address
r4 = mock.method(dns, 'resolve4', async () => ['1.2.3.4']);
r6 = mock.method(dns, 'resolve6', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
});
after(async () => {
r4.mock.restore();
r6.mock.restore();
setGlobalDispatcher(realDispatcher);
await agent.close();
});
it('resolves to 1.2.3.4 and fetches successfully', async () => {
const res = await fetchCompliant(`${HOST}/x`, { respectRobots: false });
assert.equal(res.status, 200);
});
});
// ── private IPv4 ranges are blocked ──────────────────────────────────────────
const privateIPv4Cases: Array<{ addr: string; label: string }> = [
{ addr: '10.0.0.5', label: '10.x RFC1918' },
{ addr: '192.168.1.1', label: '192.168.x RFC1918' },
{ addr: '172.16.0.1', label: '172.16.x RFC1918' },
{ addr: '127.0.0.1', label: '127.x loopback' },
{ addr: '169.254.169.254', label: '169.254.169.254 cloud metadata' },
{ addr: '100.64.0.1', label: '100.64.x CGNAT' },
];
for (const { addr, label } of privateIPv4Cases) {
describe(`fetchCompliant — SSRF blocked: ${label} (${addr})`, () => {
// Use a unique hostname per case to avoid robotsCache hits from prior cases
const HOST = `http://ssrf-priv-${addr.replace(/\./g, '-')}.example`;
let agent: MockAgent;
let realDispatcher: RealDispatcher;
let r4: ReturnType<typeof mock.method>;
let r6: ReturnType<typeof mock.method>;
before(() => {
realDispatcher = getGlobalDispatcher();
agent = makeMockAgent();
r4 = mock.method(dns, 'resolve4', async () => [addr]);
r6 = mock.method(dns, 'resolve6', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
});
after(async () => {
r4.mock.restore();
r6.mock.restore();
setGlobalDispatcher(realDispatcher);
await agent.close();
});
it(`throws SSRF_BLOCKED when DNS returns ${addr}`, async () => {
await assert.rejects(
() => fetchCompliant(`${HOST}/x`, { respectRobots: false }),
(err: unknown) => {
assert.ok(err instanceof Error);
assert.equal((err as Error & { code?: string }).code, 'SSRF_BLOCKED',
`Expected code=SSRF_BLOCKED, got: ${(err as Error & { code?: string }).code}`);
return true;
},
);
});
});
}
// ── private IPv6 ranges are blocked ──────────────────────────────────────────
const privateIPv6Cases: Array<{ addr: string; label: string }> = [
{ addr: '::1', label: '::1 loopback' },
{ addr: 'fe80::1', label: 'fe80:: link-local' },
];
for (const { addr, label } of privateIPv6Cases) {
describe(`fetchCompliant — SSRF blocked: ${label}`, () => {
const safeLabel = addr.replace(/:/g, '_').replace(/%/g, '_');
const HOST = `http://ssrf-ipv6-${safeLabel}.example`;
let agent: MockAgent;
let realDispatcher: RealDispatcher;
let r4: ReturnType<typeof mock.method>;
let r6: ReturnType<typeof mock.method>;
before(() => {
realDispatcher = getGlobalDispatcher();
agent = makeMockAgent();
// resolve4 returns nothing; resolve6 returns the private address
r4 = mock.method(dns, 'resolve4', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
r6 = mock.method(dns, 'resolve6', async () => [addr]);
});
after(async () => {
r4.mock.restore();
r6.mock.restore();
setGlobalDispatcher(realDispatcher);
await agent.close();
});
it(`throws SSRF_BLOCKED for ${addr}`, async () => {
await assert.rejects(
() => fetchCompliant(`${HOST}/x`, { respectRobots: false }),
(err: unknown) => {
assert.ok(err instanceof Error);
assert.equal((err as Error & { code?: string }).code, 'SSRF_BLOCKED');
return true;
},
);
});
});
}
// ── SSRF check runs BEFORE robots.txt fetch ───────────────────────────────────
describe('fetchCompliant — SSRF check fires before robots.txt', () => {
// If SSRF check is first, dns.resolve4 is called and throws before any HTTP
// (including robots.txt) leaves the process. We verify that robots.txt is
// never fetched for an SSRF-blocked host.
const HOST = 'http://ssrf-order-check.example';
let agent: MockAgent;
let realDispatcher: RealDispatcher;
let r4: ReturnType<typeof mock.method>;
let r6: ReturnType<typeof mock.method>;
let robotsIntercepted = false;
before(() => {
realDispatcher = getGlobalDispatcher();
agent = new MockAgent();
// Do NOT disable net-connect globally — instead intercept robots.txt and
// mark a flag if it's ever called.
agent.get(HOST)
.intercept({ path: '/robots.txt', method: 'GET' })
.reply(() => { robotsIntercepted = true; return { statusCode: 200, data: '' }; });
setGlobalDispatcher(agent);
r4 = mock.method(dns, 'resolve4', async () => ['10.1.2.3']);
r6 = mock.method(dns, 'resolve6', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
});
after(async () => {
r4.mock.restore();
r6.mock.restore();
setGlobalDispatcher(realDispatcher);
await agent.close();
});
it('throws SSRF_BLOCKED and never fetches robots.txt', async () => {
await assert.rejects(
() => fetchCompliant(`${HOST}/x`),
(err: unknown) => {
assert.equal((err as Error & { code?: string }).code, 'SSRF_BLOCKED');
return true;
},
);
assert.equal(robotsIntercepted, false, 'robots.txt should not have been fetched');
});
});
// ── getRobotsFor internal SSRF check ─────────────────────────────────────────
describe('isAllowed — getRobotsFor SSRF guard prevents robots.txt fetch', () => {
// isAllowed calls getRobotsFor which calls assertPublicHost internally.
// If the host resolves to a private IP, getRobotsFor swallows the error
// (permissive on transport error) and allows the URL (empty robots = allow).
// What we verify: the SSRF throw inside getRobotsFor means robots.txt is
// never fetched over the wire.
const HOST = 'http://ssrf-robots-guard.example';
let agent: MockAgent;
let realDispatcher: RealDispatcher;
let r4: ReturnType<typeof mock.method>;
let r6: ReturnType<typeof mock.method>;
let robotsFetched = false;
before(() => {
realDispatcher = getGlobalDispatcher();
agent = new MockAgent();
agent.get(HOST)
.intercept({ path: '/robots.txt', method: 'GET' })
.reply(() => { robotsFetched = true; return { statusCode: 200, data: '' }; });
setGlobalDispatcher(agent);
r4 = mock.method(dns, 'resolve4', async () => ['192.168.0.1']);
r6 = mock.method(dns, 'resolve6', async () => { throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); });
});
after(async () => {
r4.mock.restore();
r6.mock.restore();
setGlobalDispatcher(realDispatcher);
await agent.close();
});
it('does not fetch robots.txt when host resolves to private IP', async () => {
// getRobotsFor swallows SSRF errors — isAllowed returns true (permissive).
// The key assertion is that no HTTP actually went out.
const { isAllowed } = await import('../src/compliance.js');
const result = await isAllowed(`${HOST}/page`);
assert.equal(result, true, 'permissive on unresolvable robots (SSRF swallowed)');
assert.equal(robotsFetched, false, 'robots.txt must not have been fetched over wire');
});
});