Skip to content

fix(browser): seal eval-path SSRF bypass + land snapshot/vision guards (#44731) - #54132

Merged
teknium1 merged 4 commits into
mainfrom
hermes/hermes-d1f50126
Jun 28, 2026
Merged

fix(browser): seal eval-path SSRF bypass + land snapshot/vision guards (#44731)#54132
teknium1 merged 4 commits into
mainfrom
hermes/hermes-d1f50126

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Closes the eval return-value SSRF bypass from #44731 and lands the contributor's snapshot/vision guards, so the browser's private-network policy holds across all four content sinks instead of just navigation.

Root cause: browser_navigate blocks private/internal URLs in cloud-browser mode (and containerized-terminal mode), but page-context JS can re-route the main frame — browser_console(expression="location.href='http://127.0.0.1/'") then browser_snapshot() reads the private page back. The guard was only on the navigate path.

Changes

  • tools/browser_tool.py
    • snapshot + vision (@liuhao1024): re-check window.location.href against the SSRF guard before returning content, on the same gate as navigate (not local backend · not local sidecar · not allow_private_urls).
    • eval (sibling fix): _browser_eval returned arbitrary JS results with no URL check — two same-class bypasses stayed open. Now guarded:
      1. direct fetch — pre-scan the expression for private/always-blocked URL literals (fetch('http://127.0.0.1/secret') never updates location.href, so the page recheck can't see it).
      2. navigate-then-read — re-check window.location.href after the eval at both success-return sites (supervisor fast-path + subprocess fallback).
    • Cloud-metadata floor (IMDS, 169.254.169.254) covered via _is_always_blocked_url in both new guards.
    • Probe failures fail-open, matching the snapshot/vision guards.
  • tests/tools/test_browser_snapshot_ssrf.py (@liuhao1024) — 17 tests, snapshot + vision guards.
  • tests/tools/test_browser_eval_ssrf.py — 12 tests, both eval sub-paths + skip cases (local backend / sidecar / allow_private) + helper units.

Validation

Path Before After
browser_navigate → private blocked blocked
browser_snapshot after eval-nav → private leaks blocked
browser_vision after eval-nav → private leaks blocked
browser_console(fetch private) leaks blocked
browser_console(location.href=private) + read DOM leaks blocked
public eval / public page works works

E2E with real is_safe_url (real DNS/loopback resolution, no mocks): both attack paths blocked, canary never leaks, legit public eval still works. 29/29 new tests + 434/434 existing browser tests green.

Credit

Snapshot/vision guards cherry-picked from @liuhao1024's #45133 (supersedes #44755, #45101) with authorship preserved. Eval sibling fix added on top. Reporter: @YLChen-007 (#44731).

Infographic

SSRF guard — eval path sealed

liuhao1024 and others added 4 commits June 28, 2026 02:23
browser_snapshot() now checks the current page URL before returning
content. When browser_console() changes location.href to a private or
internal address (e.g., http://127.0.0.1:8080/), the snapshot returns
an error instead of exposing the private page content.

This closes the SSRF bypass where an attacker could:
1. Navigate to a public page
2. Use browser_console to eval location.href = 'http://127.0.0.1:port/'
3. Use browser_snapshot to read the private page content

The fix reuses the existing _is_safe_url() and _allow_private_urls()
infrastructure, and fails open if the URL check itself fails.

Fixes #44731
The SSRF bypass in #44731 was only patched for browser_snapshot(), but
browser_vision() exposes the same vulnerability — it takes a screenshot
and sends it to the vision model without checking if eval-driven
navigation moved the page to a private/internal URL.

Add the same current-page URL safety check to browser_vision() before
any screenshot is captured, encoded, or forwarded to the vision model.
This covers both the normal screenshot path and the Lightpanda Chrome
fallback path.

7 new tests: blocks private URL, allows public URL, skips in local
backend, skips when private URLs allowed, handles eval failure/empty/exception.
The private-network guard in browser_snapshot() and browser_vision()
blocked all private URLs, including those accessed via local sidecar
sessions (hybrid routing). Local sidecar sessions intentionally access
private URLs — the cloud provider never sees the URL in that case.

Add `_is_local_sidecar_key(effective_task_id)` check to both guards,
matching the existing pattern in browser_navigate().

Fixes #45101 review feedback from egilewski.
The snapshot/vision guards re-check the page URL before returning content,
but browser_console(expression=...) -> _browser_eval returns arbitrary JS
results directly, leaving two same-class bypasses open:

  1. Direct fetch: fetch('http://127.0.0.1/secret').then(r=>r.text()) reads
     a private endpoint and returns the body — the page URL stays public so
     the post-eval recheck never sees it.
  2. Navigate-then-read: location.href='http://127.0.0.1/' then a later eval
     reads document.body.innerText.

Guard _browser_eval on the same condition as navigate/snapshot/vision
(not local backend, not local sidecar, not allow_private_urls):
  - pre-scan the expression for private/always-blocked URL literals
  - re-check window.location.href after the eval at both success-return
    sites (supervisor fast-path + subprocess fallback)

Probe failures fail-open (matching the snapshot/vision guards).
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-d1f50126 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11626 on HEAD, 11624 on base (🆕 +2)

🆕 New issues (2):

Rule Count
unresolved-import 2
First entries
tests/tools/test_browser_eval_ssrf.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_browser_snapshot_ssrf.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues: none

Unchanged: 6104 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@teknium1
teknium1 merged commit 7ef04ae into main Jun 28, 2026
30 checks passed
@teknium1
teknium1 deleted the hermes/hermes-d1f50126 branch June 28, 2026 09:42
@alt-glitch alt-glitch added type/security Security vulnerability or hardening tool/browser Browser automation (CDP, Playwright) sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P1 High — major feature broken, no workaround labels Jun 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 High — major feature broken, no workaround sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/browser Browser automation (CDP, Playwright) type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants