Back to Blog
high SEVERITY8 min read

stream_media_file SSRF: src Parameter Reaches requests.get()

A media-download helper accepted a fully attacker-controlled URL from the `src` query parameter and passed it straight to `requests.get()`, turning the service into an open HTTP proxy for internal networks and cloud metadata endpoints. The fix introduces an `assert_safe_url()` guard that resolves the hostname with `getaddrinfo()` and rejects private, loopback, link-local, reserved, and multicast addresses before any request is issued. The guard is now called at the top of both `download_media_fi

O
By Orbis AppSec
Published September 20, 2026Reviewed September 20, 2026

Answer Summary

The affected code is first-party: the `download_media_file()` and `stream_media_file()` helpers in an add-on module of a self-hosted media service, reached through a download/raw endpoint that reads `request.args.get('src')`. Because the URL was handed directly to `requests.get()` with no validation, an attacker could make the server fetch `http://169.254.169.254/latest/meta-data/iam/security-credentials/` and read back cloud IAM credentials, or probe and read internal-only services such as `http://localhost:8080/admin`. The fix adds an `assert_safe_url()` check that resolves the hostname and raises `ValueError` for private, loopback, link-local, reserved, or multicast addresses before the request is made; there is no package version, so the remediation is the fix commit itself. No CVE or GHSA was assigned and the finding recorded no CWE, though this is textbook server-side request forgery.

Vulnerability at a Glance

cweN/A (not recorded in the finding; SSRF class)
fixNew `assert_safe_url()` resolves the hostname and rejects private/loopback/link-local/reserved/multicast targets before any fetch
riskServer-side HTTP requests to cloud metadata endpoints and internal services, leaking IAM credentials and internal responses back to the caller
languagePython
root causeThe `src` request parameter flowed unvalidated into `requests.get()` inside `download_media_file()` and `stream_media_file()`
vulnerabilityServer-Side Request Forgery (SSRF) via unvalidated user-supplied fetch URL

Summary

A media-download helper accepted a fully attacker-controlled URL from the src query parameter and passed it straight to requests.get(), turning the service into an open HTTP proxy for internal networks and cloud metadata endpoints. The fix introduces an assert_safe_url() guard that resolves the hostname with getaddrinfo() and rejects private, loopback, link-local, reserved, and multicast addresses before any request is issued. The guard is now called at the top of both download_media_file() and stream_media_file().

A Download Proxy That Would Fetch Anything

The add-on layer of this service exposes two small helpers for pulling remote media: download_media_file(url, path_without_ext, ext), which saves a file to disk, and stream_media_file(url, src, headers, cookies), which streams bytes back to the client. Both are reachable from HTTP endpoints that read the target from the query string — request.args.get('src').

Neither helper looked at the URL before using it. stream_media_file() went straight to parsing optional JSON headers and cookies and then calling requests.get() on the caller's src. That single missing check is the whole vulnerability: the process making the outbound request sits inside the container's network namespace, so it can reach everything the container can reach — the loopback interface, sibling services on the internal bridge network, and the cloud instance metadata endpoint at 169.254.169.254. The response is then handed back to whoever supplied the URL.

If you maintain any "fetch this for me" feature — thumbnail proxies, webhook validators, avatar importers, RSS fetchers — this is the exact shape of bug to look for. The dangerous part is never the HTTP client; it is that a request parameter is allowed to choose the destination.

Affected Versions

Affected not applicable (first-party code) — the download_media_file() and stream_media_file() helpers prior to the fix commit
Fixed in not applicable (first-party code) — remediated by the security fix pull request that adds assert_safe_url()
Ecosystem N/A (application code; the HTTP client is requests on PyPI)
CVE / GHSA not assigned
CWE unknown (not recorded in the finding; the pattern is server-side request forgery)

The Vulnerability Explained

Here is the pre-fix body of the streaming helper, trimmed to the lines that matter:

def stream_media_file(url: str, src: str, headers: str|None = None, cookies: str|None = None):
    """Stream raw file with requests.get"""
    try:
        headers_dict = json.loads(headers) if headers else {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...'
        }
        # ... requests.get(src, ...) follows

And the download helper:

def download_media_file(url: str, path_without_ext: str, ext: str|None = None):
    """Download raw file with requests.get with selected filename"""
    response = requests.get(url, stream=True, proxies=proxies)
    response.raise_for_status()

The problematic pattern is that src and url arrive from request.args.get('src') and are used as the destination with no inspection at all. There is no scheme allow-list, no host allow-list, and no check that the resolved address is routable on the public internet. requests.get() will happily connect to 127.0.0.1, 10.0.0.0/8, or 169.254.0.0/16.

Attack scenario

An attacker who can reach the download endpoint sends:

GET /download?src=http://169.254.169.254/latest/meta-data/iam/security-credentials/

On a cloud host with IMDSv1 enabled, the server fetches the metadata document and the streaming path returns the role name — and then, with a second request appending that role name to the path, the temporary AccessKeyId, SecretAccessKey, and Token. That is a full pivot from an unauthenticated media-download feature to the cloud account's IAM permissions.

The same primitive works against internal HTTP surfaces. ?src=http://localhost:8080/admin reaches an admin panel that was only ever exposed on loopback because "nothing outside the container can talk to it." Because the helper accepts a JSON headers blob and a cookie string, the attacker can also attach arbitrary request headers to the forged request — useful for internal services that trust a header for authentication. And because response bodies are streamed back, the endpoint doubles as an internal port scanner: differences in status codes, error text, and timing map out the private network.

Two properties make this worse than a blind SSRF: the response is returned to the attacker, and the request is fully attacker-shaped (method aside), including headers and cookies.

The Fix

The fix adds one guard function and calls it on the value that actually reaches the network:

def assert_safe_url(url: str):
    """Raise ValueError if url resolves to a private/internal/loopback address (SSRF guard)"""
    import ipaddress, socket
    hostname = urlparse(url).hostname
    if not hostname:
        raise ValueError('Invalid URL: missing hostname')
    for family, _, _, _, sockaddr in socket.getaddrinfo(hostname, None):
        ip = ipaddress.ip_address(sockaddr[0])
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
            raise ValueError(f'Refusing to fetch URL resolving to disallowed address: {hostname}')

Three design details are worth calling out:

  1. It validates the resolved address, not the string. Checking for the literal text 169.254.169.254 or localhost is trivially bypassed with http://metadata.attacker-domain.test/ pointing at the metadata IP, or with decimal/hex IP encodings. Calling socket.getaddrinfo() and testing ipaddress.ip_address() properties defeats all of those, because it asks the question that matters: where would a socket actually connect?

  2. It iterates every getaddrinfo() result. A hostname with both a public A record and a private AAAA record is rejected, rather than passing because the first answer looked fine.

  3. is_link_local is what specifically closes the metadata hole. 169.254.169.254 is in 169.254.0.0/16, which is link-local rather than private, so a guard that only tested is_private and is_loopback would have left the credential-theft path wide open. is_reserved and is_multicast round out the remaining odd ranges.

The call sites are the other half of the change:

def download_media_file(url: str, path_without_ext: str, ext: str|None = None):
    assert_safe_url(url)
    ...

def stream_media_file(url: str, src: str, headers: str|None = None, cookies: str|None = None):
    try:
        assert_safe_url(src)

Note the asymmetry: the download helper validates url, while the streaming helper validates src. That is deliberate — stream_media_file() receives two URL-ish arguments and src is the one taken from the request and handed to requests.get(). Guarding url there would have looked correct in review and fixed nothing.

The diff also changes gen_pathname() from sha1(url.encode()) to sha1(url.encode(), usedforsecurity=False). This is not part of the SSRF fix. That hash only derives a cache directory name from a URL, and the flag declares that non-cryptographic intent so the call does not fail on FIPS-restricted Python builds and no longer trips weak-hash linters.

Residual risk to be aware of

assert_safe_url() validates the URL you hand it, once. Two gaps remain in this code path and are worth tracking as follow-up hardening:

  • Redirects. requests.get() follows redirects by default, and only the first URL is checked. A public host returning 302 Location: http://169.254.169.254/... still lands on the metadata service. Closing this requires allow_redirects=False with per-hop re-validation.
  • DNS rebinding / TOCTOU. The guard resolves the name, then requests resolves it again when it connects. A hostname with a very low TTL can answer public on the first lookup and private on the second.

Neither undermines the fix — it removes the direct, one-request path to credentials — but a guard that resolves and then discards the resolution is a known-imperfect control.

Key Takeaways

  • A src (or url, or image_url) query parameter that reaches requests.get() is a destination-choosing primitive for the attacker, not just data — validate before the first byte leaves.
  • Testing only is_private and is_loopback leaves cloud metadata reachable: 169.254.169.254 is link-local, so is_link_local is the check that actually protects IAM credentials.
  • Resolve the hostname with getaddrinfo() and test the resulting ipaddress object; string blocklists for localhost and 127.0.0.1 are bypassed by DNS names, IPv6 forms, and integer-encoded IPs.
  • When a helper takes more than one URL argument — as stream_media_file(url, src, ...) does — guard the exact parameter that is passed to the HTTP client, not the one that reads best.
  • An SSRF guard placed only at the entry point is incomplete while requests follows redirects by default; treat allow_redirects as part of the control.

How Orbis AppSec Detected This

  • Source: the src HTTP query parameter, read via request.args.get('src') and passed as the src argument of stream_media_file() and as the url argument of download_media_file().
  • Sink: requests.get() called with that attacker-controlled URL (with stream=True and a configured proxy), returning the response body to the caller.
  • Missing control: no scheme or host allow-list and no resolved-address check — nothing prevented the URL from resolving to loopback, RFC 1918 space, or the 169.254.0.0/16 link-local range used by cloud instance metadata services.
  • CWE: unknown — the finding did not record a CWE, and no CVE or GHSA was assigned; the pattern is server-side request forgery.
  • Fix: an assert_safe_url() guard now resolves the hostname with socket.getaddrinfo() and raises ValueError for private, loopback, link-local, reserved, or multicast addresses before either helper performs a request.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

The distance between "convenient media proxy" and "credential exfiltration endpoint" was one unvalidated argument. download_media_file() and stream_media_file() trusted the src query parameter to name a public URL, and requests.get() did exactly what it was told — including reaching 169.254.169.254 and localhost:8080.

The assert_safe_url() guard fixes that by answering the only question that matters before connecting: what IP would this actually hit? Resolving the hostname and rejecting private, loopback, link-local, reserved, and multicast addresses closes the direct path to cloud metadata and internal services. The remaining work — disabling automatic redirect following and re-validating each hop — is the natural next step for any code that fetches URLs on a user's behalf.

Prevention and further reading

Frequently Asked Questions

Why does `assert_safe_url()` validate `src` in `stream_media_file()` but `url` in `download_media_file()`?

`stream_media_file(url, src, headers, cookies)` receives two URLs: `url` is the internal/canonical reference, while `src` is the value that actually comes from `request.args.get('src')` and is the one passed to `requests.get()`. The guard is applied to whichever value reaches the network call in each function.

Does `assert_safe_url()` stop an attacker who uses a public URL that redirects to 169.254.169.254?

Not by itself. `requests.get()` follows redirects by default, and the guard only inspects the initial URL, so a redirect-based bypass requires additional hardening such as `allow_redirects=False` plus re-validation of each hop.

Is the `sha1(url.encode(), usedforsecurity=False)` change in `gen_pathname()` a security fix?

No. `gen_pathname()` uses SHA-1 only to derive a cache directory name from a URL, and the `usedforsecurity=False` flag declares that intent so the call keeps working on FIPS-restricted Python builds and stops tripping crypto linters.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #29

Related Articles

high

ip-address 10.2.0 SSRF: Inconsistent Parsing Bypasses IP Checks

The `ip-address` npm package version 10.2.0 contains an inconsistent parsing vulnerability that allows attackers to bypass IP-based access controls. By representing IPv4 addresses in IPv4-mapped IPv6 notation, attackers can trick applications into allowing requests to blocked internal addresses. Upgrading to 10.3.1 resolves this through stricter address normalization.

medium

How gitlab.bandit.B501 happens in Python and how to fix it

The `proverbia-scraper.py` script disabled TLS certificate verification on its `requests.get()` call and silenced the resulting security warnings, exposing the scraper to man-in-the-middle attacks. The fix removes the `verify=False` flag and the warning suppression, restoring proper certificate validation while keeping the existing 30-second timeout intact.

high

How Server-Side Request Forgery (SSRF) happens in Go HTTP handlers and how to fix it

A Server-Side Request Forgery (SSRF) vulnerability was discovered in `internal/web/controller/server.go` where the `applySubTemplate` endpoint accepted arbitrary URLs from user input and passed them directly to `serverService.ApplySubTemplateFromGithub()` without any host validation. An attacker could exploit this to make the server issue HTTP requests to internal network resources, cloud metadata endpoints, or redirect-controlled destinations. The fix introduces a strict allowlist that restrict

critical

How SSRF via Vulnerable Dependency Versions Happens in Node.js and How to Fix It

A permissive semver range in `package.json` allowed npm to install axios versions vulnerable to SSRF (CVE-2024-39338). By bumping the minimum version from `^1.6.0` to `^1.7.4`, all downstream consumers of this SDK are now protected from server-side request forgery attacks. This critical fix required changing just one line in the dependency manifest.

critical

How Server-Side Request Forgery happens in Python FastAPI and how to fix it

A critical Server-Side Request Forgery (SSRF) vulnerability was discovered in app.py where the `/parse` and `/parse-video` endpoints accepted user-supplied URLs with only substring validation. The application checked if 'doubao.com' appeared anywhere in the URL string, allowing attackers to bypass this check and access internal services, cloud metadata endpoints, or scan the internal network. The fix implemented proper hostname parsing with an allowlist of legitimate domains.

high

markitdown_bridge.py Path Traversal: Arbitrary File Read via sys.argv

The markitdown_bridge.py script, used by MDView for DOCX-to-Markdown conversion, accepted file paths directly from command-line arguments without validating they stayed within intended directories. An attacker could exploit this to read arbitrary files from the filesystem by passing path traversal sequences in the source_path parameter.