Repository navigation
security(browser): validate the URL in capture_screenshot (#13204) - #16544
Conversation
send_to_browser_vm already runs a DNS-resolving public-address check (a7afad8); services/playwright_service.py::capture_screenshot did not, and posts a caller-supplied url straight to the container's /test-frontend endpoint. Guard it with the same autobot_shared.url_safety.is_public_url_async check. test_frontend/send_test_message stay unguarded on purpose: their frontend_url defaults to the internal frontend service and is meant to reach it, matching the admin-surface exemption pattern already documented in api/browser_mcp.py. search_web takes a query string, not a url, so it was never an instance of this gap.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: 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. Comment |
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
AutoBot Phase Validation ResultsSystem Maturity: 96.5% Phase Status:PASS Phase 1: Core Infrastructure: 100.0% Recommendations:
|
|
Independent review at
Non-blocking:
|
aa297f7 to
fc7c07a
Compare
AutoBot Phase Validation ResultsSystem Maturity: 96.5% Phase Status:PASS Phase 1: Core Infrastructure: 100.0% Recommendations:
|
|
Delta |
Thinking Path
#13204 lists four browser/fetch entry points and which ones run the DNS-resolving public-address guard before reaching the network.
content_reach/backends/browser.pyandresearch_browser_manager.pyalready did.api/browser_mcp.py::send_to_browser_vmdid not at filing time, but that half was fixed separately (a7afad817,_reject_non_public_url+is_public_url_async) — its own docstring now documents the exemption for the admin surface that legitimately needs internal hosts. The remaining, unfixed half isservices/playwright_service.py:capture_screenshot(url=...)posts a caller-supplied URL straight to the container's/test-frontendendpoint with no check at all.Two callers reach it:
browser_backends/container.py(the ADR-009 canonical-interface wrapper, whose own docstring says the registry validates the URL before dispatch — so callers arriving throughget_browser()are already covered) andapi/playwright.py's admin-gated/playwright/screenshotendpoint (calls it directly, bypassing the registry). Guarding insidecapture_screenshotitself closes the gap for both paths at once — including any future direct caller — rather than only fixing the one HTTP endpoint, and it is a defense-in-depth check for the registry-routed path (a no-op there since that URL is already public by the time it arrives).search_webandtest_frontend/send_test_messagewere checked and excluded:search_webtakes aquerystring, not aurl— the container decides the destination, there is nothing here to validate.test_frontend/send_test_messagedefault toServiceURLs.FRONTEND_LOCALand exist specifically to reach AutoBot's own internal frontend — guarding those would break their purpose, matching the same admin-surface exemption already documented inapi/browser_mcp.py.This does not attempt the larger ADR-009 migration (#13236 steps 5-6, routing
tool_handler.pyandapi/playwright.pythroughget_browser()and retiringis_url_allowed) — that is real, separately-reviewable architectural work with its own production-behaviour-change implications. This PR is scoped to the one concrete, unguarded SSRF gap #13204 names.What Changed
autobot-backend/services/playwright_service.py:capture_screenshotnow callsautobot_shared.url_safety.is_public_url_async(url)before touching the network, returning{"success": False, "error": "URL is not a public address", "url": url}on rejection — same guard function and same shape of checkapi/browser_mcp.py::_reject_non_public_urlalready applies tosend_to_browser_vm.autobot-backend/services/playwright_service_test.py(new): asserts a non-public URL is rejected beforeis_ready()is even awaited, and that a public URL still reaches the container exactly as before (mockedhttp_client.post).changelog/unreleased/13204-playwright-screenshot-ssrf-guard.md.Verification
Pre-push hook's own pytest run on the changed file, from the actual push:
Mutation argument (not run locally): removing the
is_public_url_asynccheck restores the exact pre-fix behaviour thattest_non_public_url_is_rejected_before_any_http_callasserts against — the function would fall straight into theis_ready()/http_client.postpath forhttp://169.254.169.254/..., and the test'sis_ready.assert_not_awaited()would fail.Risks
Low. The added check runs before any I/O and only tightens behaviour for non-public URLs, which were never a supported use case for this caller-facing screenshot function.
browser_backends/container.pycallers see no behaviour change — the registry already validated their URL first, so the added check is a no-op pass-through for them.Model Used
Claude Sonnet 5 (claude-sonnet-5)
Issue Link
Closes #13204
Changelog fragment
changelog/unreleased/13204-playwright-screenshot-ssrf-guard.mdSingle-issue rationale
Single-issue rationale: this is the one concrete, unguarded call site #13204 names; #13236's remaining steps (5-6) are a separate architectural migration with their own review/rollout needs and are being tracked and scoped independently.
Checklist
CLAUDE.mdgit commitruns them automatically)main— the default branch since the 2026-09-12 rename (this checklist item's "Dev_new_gui" wording is stale template text)🤖 Generated with Claude Code