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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"slugify": "^1.6.6",
"undici": "^6.28.0",
"winston": "^3.19.0",
"xls-reader": "^0.7.0",
"yaml": "^2.8.3"
Expand Down
101 changes: 101 additions & 0 deletions src/__tests__/unit/outbound-fetch-pinning-live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* The one part of the F-11 pin that unit tests with a mocked `fetch` cannot
* prove: that the undici connector contract this code relies on is actually
* satisfied by the installed Node/undici at runtime.
*
* `pinnedDispatcher` hands undici a custom `lookup` that answers with an
* ARRAY of `{address, family}` records. If that shape were wrong for this
* runtime, every guarded outbound call in the product would fail at connect
* time — and no mock-fetch test would ever notice, because a mocked fetch
* never reaches the connector. So this test opens a real HTTP server on
* loopback and makes a real request through the real dispatcher.
*
* It does NOT go through `safeFetch` on purpose: `safeFetch` rejects
* loopback by design (that is the SSRF guard working), so a live end-to-end
* test of it would have to disable the very thing under test. What is
* verified here is the connector plumbing; the guard's own decisions are
* covered in outbound-fetch.test.ts.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { createServer, type Server } from 'node:http';
import { Agent, buildConnector } from 'undici';
import { fetch as undiciFetch } from 'undici';

let server: Server | undefined;

function startServer(): Promise<number> {
return new Promise((resolve) => {
server = createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('pinned-ok');
});
server.listen(0, '127.0.0.1', () => {
const address = server!.address();
resolve(typeof address === 'object' && address ? address.port : 0);
});
});
}

afterEach(async () => {
await new Promise<void>((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
server = undefined;
});
});

describe('pinned dispatcher — real connection', () => {
it('connects to the pinned address even though the URL hostname resolves elsewhere', async () => {
const port = await startServer();

// Mirrors pinnedDispatcher() in outboundFetch.ts exactly, including the
// array-shaped lookup callback and the keep-alive settings.
const dispatcher = new Agent({
connect: buildConnector({
lookup: (_hostname, _options, callback) => {
callback(null, [{ address: '127.0.0.1', family: 4 }]);
},
}),
keepAliveTimeout: 1,
keepAliveMaxTimeout: 1,
});

try {
// `example.invalid` can never resolve in DNS (RFC 2606) — reaching the
// local server proves the pinned address, not DNS, decided the socket.
const response = await undiciFetch(`http://example.invalid:${port}/`, { dispatcher });
expect(response.status).toBe(200);
expect(await response.text()).toBe('pinned-ok');
// The Host header still carries the URL's hostname, which is what keeps
// TLS SNI/cert validation honest on the https path.
} finally {
await dispatcher.destroy();
}
});

it('fails over to the second pinned address when the first is unreachable', async () => {
const port = await startServer();

const dispatcher = new Agent({
connect: buildConnector({
timeout: 1_000,
lookup: (_hostname, _options, callback) => {
callback(null, [
// TEST-NET-1 (RFC 5737): routable-looking, never actually answers.
{ address: '192.0.2.1', family: 4 },
{ address: '127.0.0.1', family: 4 },
]);
},
}),
keepAliveTimeout: 1,
keepAliveMaxTimeout: 1,
});

try {
const response = await undiciFetch(`http://example.invalid:${port}/`, { dispatcher });
expect(response.status).toBe(200);
} finally {
await dispatcher.destroy();
}
}, 15_000);
});
78 changes: 78 additions & 0 deletions src/__tests__/unit/outbound-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { lookup } from 'node:dns/promises';
import {
getConfigSource,
setConfigSource,
Expand All @@ -20,6 +21,19 @@ import {
safeFetch,
} from '@/lib/security/outboundFetch';

/** Node's global RequestInit type doesn't declare `dispatcher` (undici's addition). */
type FetchInit = RequestInit & { dispatcher?: unknown };

/** `dns/promises`' overloaded `lookup` type infers the single-address
* overload against `vi.mocked(lookup)`; the guard always calls it with
* `{ all: true }`, so tests stub the array-returning shape directly. */
function mockLookupOnce(...results: Array<{ address: string; family: 4 | 6 }[]>): void {
const mocked = lookup as unknown as { mockImplementationOnce: (fn: () => Promise<unknown>) => unknown };
for (const result of results) {
mocked.mockImplementationOnce(async () => result);
}
}

const original = getConfigSource();

function sourceWith(overrides: Record<string, string>): ConfigSource {
Expand Down Expand Up @@ -136,6 +150,31 @@ describe('safeFetch', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('pins the connection to the resolved address instead of leaving fetch to re-resolve DNS itself', async () => {
const fetchMock = vi.fn(() => new Response('ok', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await safeFetch('https://public.example.com/api');

expect(fetchMock).toHaveBeenCalledTimes(1);
expect((fetchMock.mock.calls[0] as unknown as [URL, FetchInit?])[1]?.dispatcher).toBeDefined();
});

it('does not attempt to pin a literal-IP URL (nothing to pin)', async () => {
const fetchMock = vi.fn(() => new Response('ok', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await safeFetch('https://93.184.216.34/api');

expect((fetchMock.mock.calls[0] as unknown as [URL, FetchInit?])[1]?.dispatcher).toBeUndefined();
});

it('does not pin when the private-network guard itself was bypassed', async () => {
const fetchMock = vi.fn(() => new Response('ok', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await safeFetch('https://internal.corp.example/api', undefined, { allowPrivate: true });

expect((fetchMock.mock.calls[0] as unknown as [URL, FetchInit?])[1]?.dispatcher).toBeUndefined();
});

it('re-validates redirect hops and blocks redirects into private space', async () => {
const fetchMock = vi.fn(async () => new Response(null, {
status: 302,
Expand Down Expand Up @@ -180,3 +219,42 @@ describe('safeFetch', () => {
.rejects.toThrow();
});
});

// F-11 (finance-institution assessment, 2026-09-05): assertPublicUrl resolved
// DNS once to decide a hostname was public, then handed the HOSTNAME (not the
// resolved address) to fetch, which resolves it AGAIN at connect time. An
// attacker controlling DNS for the target can answer publicly for the check
// and privately for the connection -- classic TOCTOU / DNS rebinding. Every
// case below uses its own hostname so the 30s privacy cache from earlier
// tests in this file can't hide a lookup call these tests need to count.
describe('DNS rebinding: the address fetch connects to must be the one just checked', () => {
it('rejects a target whose SECOND (connection-time) resolution differs and is private, even though the first (policy) resolution was public', async () => {
mockLookupOnce(
[{ address: '93.184.216.34', family: 4 }], // policy check
[{ address: '127.0.0.1', family: 4 }], // rebind at connect time
);

const fetchMock = vi.fn(() => new Response('ok', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);

await expect(safeFetch('https://rebind-attack.example.com/api'))
.rejects.toThrow(OutboundNetworkError);
expect(fetchMock).not.toHaveBeenCalled();
});

it('still succeeds when the second resolution legitimately differs but is also public', async () => {
mockLookupOnce(
[{ address: '93.184.216.34', family: 4 }],
[{ address: '203.0.113.9', family: 4 }], // still public, just different
);

const fetchMock = vi.fn(() => new Response('ok', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);

const res = await safeFetch('https://rebind-legit.example.com/api');

expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((fetchMock.mock.calls[0] as unknown as [URL, FetchInit?])[1]?.dispatcher).toBeDefined();
});
});
Loading
Loading