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
62 changes: 56 additions & 6 deletions liveness-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,68 @@ export function jitteredDelayMs(baseMs) {
// Defensive guards: URLs come from ATS feeds (mostly trusted) but a misconfigured
// portals.yml entry or a hijacked feed shouldn't be able to point Playwright at
// internal infrastructure. Only allow http(s) and reject loopback/private/link-local.
//
// The hostname coming out of `new URL(...)` needs normalization before the regex
// pass, because the WHATWG URL parser surfaces several encodings that bypass a
// naive match against `parsed.hostname`:
// 1. IPv6 hosts are serialized with brackets — `new URL('http://[::1]/').hostname`
// is `'[::1]'`, so a regex like `/^::1$/` never fires unless brackets are stripped.
// 2. FQDN trailing dot is preserved — `localhost.` reaches the network as
// localhost, but `/^localhost$/` doesn't match it.
// 3. IPv4-mapped IPv6 (`::ffff:127.0.0.1` or the hex form `::ffff:7f00:1`)
// routes to the embedded IPv4 in Chromium, so the embedded address must
// also be matched against the IPv4 block list.
// `0.0.0.0` and the all-zeros IPv6 `::` both reach loopback on Linux and need
// explicit entries; the original list omitted them.
const PRIVATE_HOST_PATTERNS = [
/^localhost$/i,
/^localhost$/,
/^localhost\.localdomain$/,
/^0\.0\.0\.0$/,
/^127\./,
/^10\./,
/^192\.168\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^169\.254\./,
/^::1$/,
/^fc[0-9a-f]{2}:/i,
/^fe80:/i,
/^::$/,
/^fc[0-9a-f]{2}:/,
/^fe80:/,
];
Comment on lines 59 to 72

@coderabbitai coderabbitai Bot Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

fd00::/8 remains unblocked.

fc00::/7 spans both fc... and fd..., but Line 70 only matches fc. That still allows http://[fd00::1]/-style ULA hosts through the SSRF guard, which defeats the private-IPv6 goal.

Suggested fix
-  /^fc[0-9a-f]{2}:/,
+  /^f[cd][0-9a-f]{2}:/,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@liveness-browser.mjs` around lines 59 - 72, The IPv6 ULA range fc00::/7 is
only partially covered by the /^fc[0-9a-f]{2}:/ pattern in
PRIVATE_HOST_PATTERNS, leaving fd00::/8 addresses allowed; update the pattern
for PRIVATE_HOST_PATTERNS (the entry currently /^fc[0-9a-f]{2}:/) to match both
fc and fd prefixes (e.g., use a regex that matches f followed by c or d like
/^f[cd][0-9a-f]{2}:/ with case-insensitive matching) so fc00::/7 ULA addresses
are fully blocked by the SSRF guard.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much u have to chat about the day

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@williamjacksonw919-design, just enough to keep your code secure! 😄

To bring us back on topic — there's still an open security finding in this PR worth addressing: the fd00::/8 ULA range is not blocked by the current PRIVATE_HOST_PATTERNS. The fix is a one-liner:

-  /^fc[0-9a-f]{2}:/,
+  /^f[cd][0-9a-f]{2}:/,

This ensures the full fc00::/7 private IPv6 range is covered, not just the fc half of it.


// Lowercase, strip IPv6 brackets, strip FQDN trailing dot. The `hostname`
// returned by `new URL(...)` is already percent-decoded and IDNA-normalized,
// but it preserves brackets around IPv6 hosts and trailing dots on FQDNs.
function normalizeHost(rawHostname) {
if (!rawHostname) return '';
let h = String(rawHostname).toLowerCase();
if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1);
if (h.endsWith('.')) h = h.slice(0, -1);
return h;
}

// IPv4-mapped IPv6 (RFC 4291 §2.5.5.2): `::ffff:0:0/96` routes to the embedded
// IPv4 address. Two textual forms — dotted (`::ffff:127.0.0.1`) and pure-hex
// (`::ffff:7f00:1`). Return the embedded IPv4 in dotted-decimal form, or null
// if `host` is not an IPv4-mapped IPv6.
function extractMappedIPv4(host) {
const dotted = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) return dotted[1];
const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hex) {
const a = parseInt(hex[1], 16);
const b = parseInt(hex[2], 16);
return `${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`;
}
return null;
}

// Returns null when the URL is safe to fetch, otherwise a structured guard
// result with a stable `code` (used for routing in scan.mjs) plus a human
// `reason`. Stable codes — not regex on reason strings — drive downstream
// dispatch so the wording can change freely without breaking callers.
function rejectPrivateOrInvalid(url) {
//
// Exported for unit tests; the main entry point is checkUrlLiveness.
export function rejectPrivateOrInvalid(url) {
let parsed;
try {
parsed = new URL(url);
Expand All @@ -69,8 +114,13 @@ function rejectPrivateOrInvalid(url) {
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { code: 'unsupported_protocol', reason: `unsupported protocol ${parsed.protocol}` };
}
if (PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(parsed.hostname))) {
return { code: 'blocked_host', reason: `blocked host ${parsed.hostname}` };
const host = normalizeHost(parsed.hostname);
const mappedIPv4 = extractMappedIPv4(host);
const candidates = mappedIPv4 ? [host, mappedIPv4] : [host];
for (const candidate of candidates) {
if (PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(candidate))) {
return { code: 'blocked_host', reason: `blocked host ${parsed.hostname}` };
}
}
return null;
}
Expand Down
54 changes: 54 additions & 0 deletions test-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,60 @@ try {
} else {
fail(`No-display degrade path wrong: ${noHeadedAvailable.result} (${noHeadedAvailable.code})`);
}

// SSRF guard — `rejectPrivateOrInvalid` has to refuse every URL whose host
// resolves to loopback / private / link-local space. The earlier guard only
// matched literal IPv4 patterns and bracketless IPv6, so several Chromium-
// routable bypasses (0.0.0.0, [::], [::1] (bracketed), [::ffff:127.0.0.1],
// localhost.) slipped through. These cases keep that regression covered.
const { rejectPrivateOrInvalid } = await import(
pathToFileURL(join(ROOT, 'liveness-browser.mjs')).href
);
const blockCases = [
['http://0.0.0.0/admin', 'IPv4 all-zeros (Linux routes to loopback)'],
['http://[::]/', 'IPv6 all-zeros (Linux routes to loopback)'],
['http://[::1]/', 'IPv6 loopback (brackets included in url.hostname)'],
['http://[::ffff:127.0.0.1]/', 'IPv4-mapped IPv6 loopback (dotted form)'],
['http://[::ffff:7f00:1]/', 'IPv4-mapped IPv6 loopback (hex form)'],
['http://[::ffff:169.254.169.254]/', 'IPv4-mapped IPv6 link-local (cloud metadata)'],
['http://[fc00::1]/', 'IPv6 ULA (private)'],
['http://[fe80::1]/', 'IPv6 link-local'],
['http://localhost./', 'FQDN-trailing-dot localhost'],
['http://localhost.localdomain/', 'localhost.localdomain alias'],
['http://169.254.169.254/latest/meta-data/', 'cloud metadata IPv4 link-local'],
['http://10.0.0.5/', 'IPv4 RFC1918'],
];
let blockMissed = 0;
for (const [url, label] of blockCases) {
const verdict = rejectPrivateOrInvalid(url);
if (verdict?.code !== 'blocked_host') {
fail(`SSRF guard missed ${label}: ${url} → ${verdict ? verdict.code : 'allowed'}`);
blockMissed += 1;
}
}
if (blockMissed === 0) pass(`SSRF guard blocks ${blockCases.length} known bypass vectors`);

const allowCases = [
'https://boards.greenhouse.io/example/jobs/123',
'https://jobs.lever.co/example/abc-def',
'https://example.com/careers/role',
'https://www.pracuj.pl/praca/role,oferta,1234567',
];
let allowDenied = 0;
for (const url of allowCases) {
if (rejectPrivateOrInvalid(url) !== null) {
fail(`SSRF guard false-positive on legitimate ATS URL: ${url}`);
allowDenied += 1;
}
}
if (allowDenied === 0) pass('SSRF guard lets legitimate ATS URLs through');

const protoCase = rejectPrivateOrInvalid('file:///etc/passwd');
if (protoCase?.code === 'unsupported_protocol') {
pass('SSRF guard rejects unsupported protocol');
} else {
fail(`SSRF guard let unsupported protocol through: ${protoCase?.code ?? 'allowed'}`);
}
} catch (e) {
fail(`Liveness classification tests crashed: ${e.message}`);
}
Expand Down
Loading