The GraphQL mutation save<Volume>Asset(_file: { url: ... }) fetches an attacker-supplied URL server-side. The anti-SSRF logic introduced by prior advisories (GHSA-gp2f-7wcm-5fhx, GHSA-v2gc-rm6g-wrw9, GHSA-3m9m-24vh-39wx) is incomplete, so a GraphQL token scoped only to volumes.<uid>:create can:
-
Read internal HTTP responses from any target whose IP address passes validateIp() but is not reachable by the attacker directly. validateIp() relies on FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, which does not cover CGNAT 100.64.0.0/10 (RFC 6598) or NAT64 64:ff9b::/96. Requests to those ranges are accepted, and the response is saved as the asset, and the attacker downloads it — full content disclosure.
-
Force internal GET requests to any host (RFC1918/loopback/metadata included). The only IP check runs in Guzzle’s on_stats callback, which fires after cURL has already connected and written the response body to disk. Rejection aborts the asset save, but the outbound request has already been issued.
-
Blindly enumerate internal hosts/ports. The four distinct, client-safe error/success outcomes (returned even with devMode off) plus connect/timeout timing from an oracle for targets whose bodies are withheld.
This is a privilege-boundary violation: a token authorized only to create assets in one volume should not be able to drive arbitrary server-side HTTP requests or read internal services.
Impact
A low-privilege, asset-creation-only authorization is escalated into arbitrary outbound server-side requests and disclosure of internal HTTP content - a privilege/trust-boundary violation in native Craft code.
The GraphQL mutation
save<Volume>Asset(_file: { url: ... })fetches an attacker-supplied URL server-side. The anti-SSRF logic introduced by prior advisories (GHSA-gp2f-7wcm-5fhx, GHSA-v2gc-rm6g-wrw9, GHSA-3m9m-24vh-39wx) is incomplete, so a GraphQL token scoped only tovolumes.<uid>:createcan:Read internal HTTP responses from any target whose IP address passes
validateIp()but is not reachable by the attacker directly.validateIp()relies onFILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, which does not cover CGNAT100.64.0.0/10(RFC 6598) or NAT6464:ff9b::/96. Requests to those ranges are accepted, and the response is saved as the asset, and the attacker downloads it — full content disclosure.Force internal GET requests to any host (RFC1918/loopback/metadata included). The only IP check runs in Guzzle’s
on_statscallback, which fires after cURL has already connected and written the response body to disk. Rejection aborts the asset save, but the outbound request has already been issued.Blindly enumerate internal hosts/ports. The four distinct, client-safe error/success outcomes (returned even with
devModeoff) plus connect/timeout timing from an oracle for targets whose bodies are withheld.This is a privilege-boundary violation: a token authorized only to create assets in one volume should not be able to drive arbitrary server-side HTTP requests or read internal services.
Impact
A low-privilege, asset-creation-only authorization is escalated into arbitrary outbound server-side requests and disclosure of internal HTTP content - a privilege/trust-boundary violation in native Craft code.