# Open WebUI: SSRF into internal services via DNS rebinding in the Playwright web loader

Open WebUI's Playwright web loader validated a URL's resolved IP in Python, then let Chromium re-resolve it, enabling DNS rebinding SSRF to internal services and cloud metadata.

- Payout: Research
- Severity: high
- Program: Open WebUI
- Found by: baeseungwon1010
- Disclosed: 2026-09-10
- CVE: CVE-2026-87996
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-4v28-j6q3-5m4r
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-4v28-j6q3-5m4r
- Tags: area:ai domain:ai domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:chromium tech:python technique:source-review vuln:race-condition vuln:ssrf

**Target:** SafePlaywrightURLLoader request interceptor in backend/open_webui/retrieval/web/utils.py
**Root cause:** Address validation resolved the hostname in Python and inspected the result, but the request was then handed to Playwright/Chromium which resolved the name again and connected to whatever it got. Playwright's request-fetch API offers no way to pin the connection to the already-validated address, so the check was an opinion about an earlier lookup rather than a constraint on the connection.
**Steps:**
1. Enable `WEB_LOADER_ENGINE=playwright` with a reachable Playwright browser (local or `PLAYWRIGHT_WS_URL`).
2. Control authoritative DNS for a submitted hostname with short TTL, answering the first lookup with a public address and the second with an internal one.
3. Submit the URL as an authenticated user for ingestion or trigger a web search.
4. Python-side validation resolves the hostname and sees the public address, passing the check.
5. Chromium re-resolves the hostname, receives the internal address, and connects to it.
6. The internal response body is fulfilled back into the page and returned as the document in the search/ingestion result.
**How found:** Reporter observed that the address check and the browser's own resolution are two separate lookups performed by two different resolvers in two different processes, so the check cannot constrain where the browser actually connects. Reproduced against unmodified validation/interception code with only config constants stubbed, driving a real Chromium in an unprivileged netns against an authoritative DNS server alternating answers.
**Exploitation:** Attacker with any authenticated account submits a URL on a DNS zone they control. The first resolution (Python validation) returns a public IP; the browser's second resolution returns an internal IP such as cloud metadata or a loopback admin API. Because the intercepted request forwards the original method and headers, an attacker-controlled page can also drive header-gated metadata endpoints and non-GET requests. Response bodies land in the web-search/ingestion result returned to the user, yielding a read primitive; on IMDSv1 hosts this leaks instance IAM credentials.
**Escalation:** Chaining the read primitive with attacker-controlled page content to drive header-gated metadata sequences and non-GET requests, extending reach beyond plain GETs.
**Insight:** When a URL fetcher validates an address in one process and then hands the request to a separate browser/HTTP engine, the validation is only a TOCTOU opinion — look for any fetch path (Playwright, headless browser, subprocess curl) that lacks connection-layer pinning, since the fix applied to the main HTTP client won't cover it.

