Skip to content
Closed
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
100 changes: 100 additions & 0 deletions apps/web/src/lib/__tests__/ssrf-guard-resolution-bypass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, afterEach, vi } from 'vitest';

vi.mock('node:dns/promises', () => ({
lookup: vi.fn(),
}));

import * as dns from 'node:dns/promises';
import { assertPublicHttpUrl } from '@/lib/ssrf-guard';

afterEach(() => {
vi.restoreAllMocks();
vi.mocked(dns.lookup).mockReset();
});

/**
* Detection-robustness coverage for `assertPublicHttpUrl`, distinct from
* `ssrf-guard-dns-leakage.test.ts`.
*
* That file asks whether a rejection *tells the caller too much*. These ask the
* prior question: does the guard reject at all? Both cases below are ways a
* private destination can survive a resolution that looks public at a glance —
* one hides the address inside an alternate IPv6 spelling, the other hides it
* behind a public sibling record. Neither is exercised anywhere else, and both
* are silent failures if they regress: the guard would return a URL and the
* caller would fetch it.
*
* Salvaged from #1428, whose CWE-209 fix was superseded by #1381 (feae3d3) but
* whose detection cases were not carried over. Ported to the merged API, where
* every DNS-path rejection is the single `NOT_PUBLIC` literal.
*
* Each case also asserts that `dns.lookup` was reached. Today a pre-DNS
* rejection throws a different literal (`Blocked host`, `Blocked private IP
* literal`), so it would fail the message assertion loudly — but if those
* literals were ever flattened into `NOT_PUBLIC` too, a hostname added to
* `BLOCKED_HOSTNAMES` would short-circuit before resolution and these tests
* would keep passing while no longer testing resolution at all. That is not a
* hypothetical refactor: #1428 unified all six rejection paths exactly that
* way. The call assertion is what keeps the test honest through it.
*/
const NOT_PUBLIC = 'Host does not resolve to a public address';

describe('assertPublicHttpUrl resolution bypasses', () => {
it('rejects a loopback address written in expanded IPv4-mapped IPv6 form', async () => {
// `0:0:0:0:0:ffff:7f00:1` is 127.0.0.1 spelled without `::` compression and
// without dotted-quad notation. A check that pattern-matched `::ffff:` or
// looked for dots would pass it straight through to a fetch of localhost.
vi.mocked(dns.lookup).mockResolvedValue([
{ address: '0:0:0:0:0:ffff:7f00:1', family: 6 },
] as never);

await expect(assertPublicHttpUrl('https://sneaky.example.com/a.mp3')).rejects.toThrow(
NOT_PUBLIC,
);
expect(dns.lookup).toHaveBeenCalledWith('sneaky.example.com', { all: true });
});

it('rejects when any resolved address is private, even if another is public', async () => {
// Multi-record DNS: the guard must scan every answer, not just the first.
// Returning after one public hit would let an attacker pair a real public A
// record with an internal one and win whichever the fetch layer picks.
vi.mocked(dns.lookup).mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '10.0.0.5', family: 4 },
] as never);

await expect(assertPublicHttpUrl('https://mixed.example.com/a.mp3')).rejects.toThrow(
NOT_PUBLIC,
);
expect(dns.lookup).toHaveBeenCalledWith('mixed.example.com', { all: true });
});

it('rejects a private address that appears after several public ones', async () => {
// Guards against an off-by-one or early-exit that only inspects a prefix of
// the answer set.
vi.mocked(dns.lookup).mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '151.101.1.140', family: 4 },
{ address: '172.16.31.9', family: 4 },
] as never);

await expect(assertPublicHttpUrl('https://tail.example.com/a.mp3')).rejects.toThrow(
NOT_PUBLIC,
);
expect(dns.lookup).toHaveBeenCalledWith('tail.example.com', { all: true });
});

it('still allows a host whose answers are all public', async () => {
// The control: without it, a guard that rejected everything would pass the
// three assertions above.
vi.mocked(dns.lookup).mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '151.101.1.140', family: 4 },
] as never);

const url = await assertPublicHttpUrl('https://public.example.com/a.mp3');

expect(url.hostname).toBe('public.example.com');
expect(dns.lookup).toHaveBeenCalledWith('public.example.com', { all: true });
});
});
Loading