
CVE-2026-13233 (Drupal OpenAI Provider, SA-CONTRIB-2026-053): response-URL SSRF / local file read. Untrusted upstream, not the prompt. Safe reproducer + detections. Fixed in 1.1.1/1.2.2.
Untrusted upstream: the provider that fetched the URL its response told it to.
Drupal OpenAI Provider (ai_provider_openai) · SSRF (CWE-918) · CVE-2026-13233 · SA-CONTRIB-2026-053
Status: Coordinated disclosure complete (2026-07-10). Reported privately to the Drupal Security Team, fixed, and published as CVE-2026-13233 / advisory SA-CONTRIB-2026-053 (rated Moderately critical). Author credited as finder & remediation developer.
A study of a classic authorization / input-validation defect on an AI "connection point": an
AI image-generation provider fetched the URL contained in the upstream API response using a
general-purpose function with no scheme allowlist. On deployments where the configured upstream
is not the trusted default (bring-your-own-endpoint / proxy / self-hosted gateway / compromised
relay), the response could point the server at file:// or an internal address.
The untrusted input that reaches the sink is the upstream response URL — not the user's prompt.
file:// local file read; the author's
internal analysis also mapped it to CWE-73 / CWE-441.3.1 / conditional 5.3
(Medium); prior 6.5 / 7.4 retracted, and the mid rating bore that out.ai_provider_openai) < 1.1.1 and 1.2.0–1.2.1 (audited 1.2.1) on Drupal core 11.2All verification ran in an owned, network-isolated sandbox against a mock upstream. The real OpenAI API was never called. Leak oracles were synthetic only.
Confirmed (dynamically demonstrated):
settings.php read confirmed by sha256 byte-match (35,182 B) — content never printed; DB credentials
are therefore in scope.Unverified (explicitly not claimed):
Not affected: default api.openai.com over TLS. The host setting is admin / config-sync / deploy
controlled — a runtime non-admin attacker cannot change it.
The response URL was passed to a general-purpose fetch that honors every registered PHP stream
wrapper (file, php, http, data, phar, …). response_format defaults to url, so the vulnerable
branch is the default path. file:// is PHP's default filesystem wrapper and is not gated by
allow_url_fopen (an earlier "requires allow_url_fopen" claim was wrong and is retracted).
Fix the primitive, not just the call site:
http/https before the request; disable redirects (a 30x can bounce to file://).file://…, http://169.254.169.254/…, and a 302→file://
redirect must all be refused.Full direction: docs/fix-direction.md.
.
├── README.md # this file (English, canonical)
├── README_ja.md # Japanese mirror
├── SECURITY.md # reporting policy + responsible-disclosure disclaimer
├── .gitignore # blocks secrets / primary evidence from being committed
├── docs/
│ └── fix-direction.md # root-cause fix + variant hunt + minimal-diff sketch (EN)
├── reproducer/ # SAFE reproducer only (sandbox, mock upstream, benign oracle)
│ ├── README.md # how to stand up the network-isolated sandbox
│ ├── verdict.md # what "success" means: raw-byte match + 200-vs-403 + exit codes
│ ├── mock-openai-server.py # benign mock; default happy-path PNG, ssrf_demo → file:///etc/hostname only
│ └── docker-compose.yml # TEMPLATE: isolated Drupal sandbox + mock (you add the vulnerable target)
├── detections/
│ ├── README.md
│ ├── sigma/web-egress-to-internal-after-imagegen.yml
│ ├── sigma/php-sensitive-file-open.yml
│ ├── sentinel/imagegen-egress-correlation.kql
│ └── splunk/imagegen-content-type-mismatch.spl
└── timeline.md # disclosure timeline (single source of truth)
Docs are currently Japanese (
.ja.md); English canonical translations are a TODO. Withheld from this repo: arming payloads and any turnkey file-read PoC. The publicreproducer/demonstrates the defect mechanism against a mock using a single benign oracle (/etc/hostname) — no arbitrary-path read, nosettings.phpgrab, no IMDS. It is not a weapon against live sites. Update to 1.1.1 / 1.2.2.
Goal: let a researcher confirm the defect class without providing a live-target exploit or exposing secrets. Everything runs offline.