Files
oc/tests/fetch.test.js
T
only-cli 3ca462e353 fix: validate resolved IPs, not hostname strings, in the SSRF guard
PR #5's guard pattern-matched the URL's hostname against a regex, which
both under- and over-blocked: IPv4-mapped IPv6 loopback ([::ffff:127.0.0.1]),
0.0.0.0, and any DNS name that merely resolves to a private address all
sailed through, while a legitimate public hostname like 10.example.com was
wrongly rejected because it starts with "10.". It also only checked the
original URL, so a public URL that redirects to an internal address was
never re-validated.

This replaces the regex with net.isIP + dns.lookup: IP literals are checked
directly (including decoding IPv4-mapped/-compatible IPv6), and DNS names
are resolved first so every address they point to is validated before
connecting. The same check now reruns on every redirect hop for both the
impers and native-fetch transports. Resolving before connecting doesn't
pin the address for the actual connection (neither impers nor fetch expose
that here), so a name that re-resolves differently between this check and
the real connect remains a known, documented residual gap.
2026-08-19 14:43:43 -04:00

75 lines
3.1 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
const { fetchPage } = await import('../src/fetch.js');
const BLOCKED_MESSAGE = 'blocked: private or internal URL';
test('fetchPage blocks literal loopback and RFC 1918 / link-local hosts', async () => {
const blocked = [
'localhost',
'localhost:8080',
'127.0.0.1',
'127.0.0.1/admin',
'10.0.0.1',
'192.168.1.1/admin',
'172.16.0.5',
'172.31.255.255',
'169.254.169.254/latest/meta-data/',
'0.0.0.0',
'[::1]',
];
for (const host of blocked) {
await assert.rejects(() => fetchPage(host), new RegExp(BLOCKED_MESSAGE), `expected ${host} to be blocked`);
}
});
test('fetchPage does not block an ordinary public hostname', async () => {
// A live fetch of example.com should succeed outright, or at worst fail for
// a network reason - it must never be rejected by the private-URL guard.
try {
await fetchPage('example.com');
} catch (err) {
assert.ok(!err.message.includes(BLOCKED_MESSAGE), `unexpected block: ${err.message}`);
}
});
test('fetchPage does not false-positive on a public hostname that merely starts with a private-looking numeric label', async () => {
// Regression check: an earlier version of this guard matched the URL's
// hostname STRING against ^-anchored prefixes like "10." and could not
// tell a private IPv4 octet from an ordinary DNS label, so a domain like
// 10.example.com (subdomain "10" of example.com) was wrongly blocked as if
// it were 10.0.0.0/8. Validating the resolved address instead of the
// string fixes this.
try {
await fetchPage('10.example.com');
} catch (err) {
assert.ok(!err.message.includes(BLOCKED_MESSAGE), `unexpected block: ${err.message}`);
}
});
test('fetchPage blocks an IPv4-mapped IPv6 loopback literal', async () => {
// new URL('https://[::ffff:127.0.0.1]/').hostname === '::ffff:7f00:1'
// (compressed hex) - a string blocklist checking for "127." or "::1" never
// matches this form, so it has to be decoded and checked as the IPv4
// address it embeds.
await assert.rejects(() => fetchPage('https://[::ffff:127.0.0.1]/'), new RegExp(BLOCKED_MESSAGE));
});
test('fetchPage blocks a hostname that merely resolves to a loopback address (DNS rebinding shape)', async () => {
// localtest.me is a public, real-world domain that resolves to 127.0.0.1 /
// ::1. Its hostname string looks nothing like a private address, so this
// can only be caught by resolving it and validating the resulting IP -
// exactly the shape of a DNS-rebinding attack.
await assert.rejects(() => fetchPage('localtest.me'), new RegExp(BLOCKED_MESSAGE));
});
test('fetchPage re-validates every redirect hop, not just the original URL', async () => {
// httpbin.org is a public host with no reason to be blocked itself; its
// /redirect-to endpoint 302s wherever it's told, which is exactly the
// shape of an SSRF that hides the real target behind a public-looking
// first hop.
const redirector = `https://httpbin.org/redirect-to?url=${encodeURIComponent('http://127.0.0.1/admin')}`;
await assert.rejects(() => fetchPage(redirector), new RegExp(BLOCKED_MESSAGE));
});