Skip to content

fix(security): close SSRF guard bypasses in liveness-browser#917

Merged
santifer merged 1 commit into
santifer:mainfrom
aaronjmars:security/ssrf-guard-bypass-fixes
Jun 11, 2026
Merged

fix(security): close SSRF guard bypasses in liveness-browser#917
santifer merged 1 commit into
santifer:mainfrom
aaronjmars:security/ssrf-guard-bypass-fixes

Conversation

@aaronjmars

@aaronjmars aaronjmars commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

rejectPrivateOrInvalid in liveness-browser.mjs is supposed to keep
scan.mjs --verify and check-liveness.mjs from pointing headless
Chromium at internal infrastructure. SECURITY.md lists SSRF in the
*.mjs scripts as in-scope, and the existing block list (127.x,
10.x, 192.168.x, 172.16-31.x, 169.254.x, ::1, fcXX:, fe80:)
shows the intent. But the check runs against new URL(url).hostname,
which the WHATWG URL parser serializes in ways the original regex list
doesn't cover, so several Chromium-routable hosts slip through.

Impact

A misconfigured portals.yml entry, a compromised ATS feed, or a job URL
copy-pasted from a hostile source can drive headless Chromium at:

  • IPv6 loopback (http://[::1]/) — parsed.hostname is '[::1]', so
    /^::1$/ never matches. The same hole affects every IPv6 guard:
    ^fc[0-9a-f]{2}:, ^fe80:, etc. The IPv6 block list is effectively
    dead.
  • IPv4-mapped IPv6 (http://[::ffff:127.0.0.1]/ and the hex form
    http://[::ffff:7f00:1]/) — routes to the embedded IPv4 in Chromium
    but the embedded address isn't decoded before the IPv4 list runs.
  • All-zeros hosts (http://0.0.0.0/, http://[::]/) — reach loopback on
    Linux and aren't in the list at all.
  • FQDN trailing dot (http://localhost./) — anchored /^localhost$/
    doesn't match, but the resolver still treats it as localhost.

The full bypass set covers AWS / GCP metadata ([::ffff:169.254.169.254]),
arbitrary RFC1918 services, and any HTTP endpoint reachable on the host
running scan/liveness. Real impact depends on the running host:

  • On a developer laptop, an attacker can probe locally bound services
    (databases, dev servers, admin consoles).
  • On a server / CI runner, cloud metadata IPs are reachable through the
    IPv4-mapped IPv6 bypass even when the literal IPv4 form is blocked.
  • Page bodies are read into Playwright's process (page.evaluate), and
    any GET-side-effect endpoint on the local host gets triggered.

Location

liveness-browser.mjs:42-76PRIVATE_HOST_PATTERNS + rejectPrivateOrInvalid.

Called by both entry points:

  • scan.mjs:298-381 — the --verify path
  • check-liveness.mjs:62-71 — the standalone liveness CLI

Fix

  • Normalize parsed.hostname (lowercase, strip IPv6 brackets, strip FQDN
    trailing dot) before the regex pass.
  • Detect IPv4-mapped IPv6 in both dotted (::ffff:127.0.0.1) and hex
    (::ffff:7f00:1) form and check the embedded IPv4 against the IPv4
    block list.
  • Add explicit entries for 0.0.0.0, ::, and localhost.localdomain.
  • Export rejectPrivateOrInvalid so it can be unit-tested directly.

The patterns themselves stay regex-driven and the existing code path
(stable blocked_host code for routing in scan.mjs) is unchanged —
nothing downstream needs to move.

Verification

test-all.mjs gains a block of cases exercising every bypass above plus
a small allow-list to keep the guard from false-positiving on legitimate
ATS hostnames:

SSRF guard blocks 12 known bypass vectors
SSRF guard lets legitimate ATS URLs through
SSRF guard rejects unsupported protocol

The existing classifyLiveness / checkUrlLivenessWithFallback /
jitteredDelayMs / isChallengeResult tests are untouched.

Manually verified the bypass mechanics against Node 20+ WHATWG URL
behavior:

  • new URL('http://[::1]/').hostname === '[::1]' (brackets preserved)
  • new URL('http://[::ffff:127.0.0.1]/').hostname === '[::ffff:127.0.0.1]'
  • new URL('http://0.0.0.0/').hostname === '0.0.0.0'

Run locally:

node test-all.mjs --quick

Detected by

Aeon + manual review of the SSRF surface documented in SECURITY.md.

  • Severity: medium (defense-in-depth weakening on a guard the project
    explicitly relies on; reachable when feed data or portals.yml is
    hostile)
  • CWE-918 (SSRF) / CWE-1390 (Weak Authentication via Bypassable
    Identifier)
  • Same vulnerability class as merged fixes in
    abhigyanpatwari/GitNexus #1148
    (IPv4-mapped IPv6) and Doorman11991/smallcode #39
    (IPv4-mapped IPv6 + browser redirects). Pre-merge code uses regex on
    parsed.hostname without bracket/normalization.

Filed by Aeon. Happy to
iterate on the patch if you'd rather take a different shape (e.g. a
post-DNS-resolution check via dns.lookup, or a tighter allow-list keyed
off tracked_companies). The block above is the smallest change that
closes the documented bypass set without altering the existing call
sites.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced URL validation security with stricter normalization patterns and expanded private/loopback address blocking to prevent unauthorized access attempts.
  • Tests

    • Extended test suite with comprehensive SSRF regression testing and protocol validation checks.

The PRIVATE_HOST_PATTERNS check in rejectPrivateOrInvalid runs against
`new URL(url).hostname`, which the WHATWG URL parser serializes in ways
that the original regex list doesn't cover. The guard is meant to keep
`scan.mjs --verify` and `check-liveness.mjs` from pointing headless
Chromium at internal infrastructure, but several Chromium-routable hosts
slip through it today:

- IPv6 hostnames keep their brackets (`new URL('http://[::1]/').hostname`
  is `'[::1]'`), so `^::1$`, `^fc[0-9a-f]{2}:`, and `^fe80:` never match
  any IPv6 URL — the loopback / ULA / link-local guards were effectively
  dead.
- IPv4-mapped IPv6 (`::ffff:127.0.0.1` and the hex form `::ffff:7f00:1`)
  routes to the embedded IPv4 in Chromium but wasn't decoded before the
  IPv4 block list ran.
- `0.0.0.0` and the all-zeros IPv6 `::` both reach loopback on Linux and
  were missing from the list.
- FQDN trailing dot (`localhost.`) bypassed the anchored `^localhost$`
  pattern while still routing to localhost in the resolver.

The fix:

- Normalize `parsed.hostname` (lowercase, strip IPv6 brackets, strip
  trailing dot) before the regex pass.
- Detect IPv4-mapped IPv6 in both dotted and hex form and check the
  embedded IPv4 against the IPv4 patterns.
- Add explicit entries for `0.0.0.0`, `::`, and `localhost.localdomain`.
- Export `rejectPrivateOrInvalid` so it can be unit-tested directly.

`test-all.mjs` gains a block of cases that exercise every bypass above
plus a sanity allow-list for legitimate ATS hostnames so the guard
doesn't false-positive on Greenhouse / Lever / Pracuj.

Detected by Aeon + manual review of the SSRF surface documented in
SECURITY.md. CWE-918 (SSRF) / CWE-1390 (Weak Authentication via
Bypassable Identifier) — defense-in-depth weakening.
@github-actions

Copy link
Copy Markdown
Contributor

Welcome to career-ops, @aaronjmars! Thanks for your first PR.

A few things to know:

  • Tests will run automatically — check the status below
  • Make sure you've linked a related issue (required for features)
  • Read CONTRIBUTING.md if you haven't

We'll review your PR soon. Join our Discord if you have questions.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR strengthens SSRF protection in the liveness browser module by expanding private-host detection patterns, normalizing hostnames (including IPv6 and IPv4-mapped addresses), exporting the blocking function for testing, and validating the changes with comprehensive regression tests covering known bypass vectors.

Changes

SSRF Protection Enhancement

Layer / File(s) Summary
Private-host blocking patterns
liveness-browser.mjs
The private-host regex pattern list removes case-insensitive flag and expands to cover 0.0.0.0, IPv6 prefix blocks (::$), and additional loopback-like addresses.
Hostname normalization utilities
liveness-browser.mjs
New helpers normalizeHost and extractMappedIPv4 preprocess hostnames by lowercasing, removing IPv6 brackets, stripping trailing dots, and extracting embedded dotted-decimal IPv4 from IPv4-mapped IPv6 addresses.
Enhanced rejectPrivateOrInvalid function
liveness-browser.mjs
Function is exported for unit testing and updated to normalize hostnames, extract IPv4-mapped candidates, and test one or more host strings against the expanded pattern list, returning structured blocked_host results.
SSRF regression test suite
test-all.mjs
New test block validates that known bypass vectors (loopback, private, link-local, cloud metadata IPs) are blocked; legitimate ATS URLs are allowed; and file:// protocol is rejected with unsupported_protocol.

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

🔧 scripts

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(security): close SSRF guard bypasses in liveness-browser' clearly and concisely summarizes the main security fix addressing SSRF vulnerabilities in the liveness-browser module.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
liveness-browser.mjs (1)

107-123: ⚠️ Potential issue | 🟠 Major

Enforce private-host checks on redirects and all Playwright subrequests

liveness-browser.mjs applies rejectPrivateOrInvalid(url) only to the initial navigation URL before page.goto(...), but it never re-validates page.url() after redirects and there’s no Playwright request routing/interception to apply PRIVATE_HOST_PATTERNS to subsequent requests/subresources. Implement per-request enforcement (e.g., context.route/page.route or request interception) and treat redirect-to-private/blocked request URLs as uncertain/blocked rather than letting liveness classification proceed.

🤖 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 107 - 123, The current flow only calls
rejectPrivateOrInvalid(url) before page.goto and misses redirects and
subresource requests; add a Playwright request route handler (using
context.route or page.route) that calls rejectPrivateOrInvalid(req.url()) for
every request and aborts or responds with a blocked/uncertain outcome when
PRIVATE_HOST_PATTERNS match, and after navigation re-check page.url() (or the
navigation response/request chain) with rejectPrivateOrInvalid to detect
redirect-to-private targets before proceeding with liveness classification;
update logic around page.goto and any liveness decision points to treat any
blocked/uncertain result from the route handler or post-navigation check as an
overall uncertain/blocked outcome.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@liveness-browser.mjs`:
- Around line 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.

---

Outside diff comments:
In `@liveness-browser.mjs`:
- Around line 107-123: The current flow only calls rejectPrivateOrInvalid(url)
before page.goto and misses redirects and subresource requests; add a Playwright
request route handler (using context.route or page.route) that calls
rejectPrivateOrInvalid(req.url()) for every request and aborts or responds with
a blocked/uncertain outcome when PRIVATE_HOST_PATTERNS match, and after
navigation re-check page.url() (or the navigation response/request chain) with
rejectPrivateOrInvalid to detect redirect-to-private targets before proceeding
with liveness classification; update logic around page.goto and any liveness
decision points to treat any blocked/uncertain result from the route handler or
post-navigation check as an overall uncertain/blocked outcome.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5289b3a0-6927-45c8-b303-fc4218b2e1ad

📥 Commits

Reviewing files that changed from the base of the PR and between f4e65e3 and f5b531e.

📒 Files selected for processing (2)
  • liveness-browser.mjs
  • test-all.mjs

Comment thread liveness-browser.mjs
Comment on lines 59 to 72
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:/,
];

@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.

@santifer santifer merged commit 1f525c4 into santifer:main Jun 11, 2026
8 checks passed
@santifer

Copy link
Copy Markdown
Owner

Welcome to career-ops, @aaronjmars — this was a genuinely good catch. The bracketed-hostname detail meant every IPv6 guard in the file was dead, and your normalize-first fix closes all of it; I also threw percent-encoding, expanded mapped forms, hex/octal/decimal literals and zone-IDs at it and nothing got through. Tests are exactly the right shape (asserting blocked_host, not just running). Merged — your first contribution here. 🚀

One tiny note for the record: new URL() on Node ≥18 serializes [::ffff:127.0.0.1] to the hex form [::ffff:7f00:1], so the dotted branch of extractMappedIPv4 is effectively unreachable for URL-derived hosts — harmless redundancy, just FYI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants