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:
-
It validates the resolved address, not the string. Checking for the literal text
169.254.169.254orlocalhostis trivially bypassed withhttp://metadata.attacker-domain.test/pointing at the metadata IP, or with decimal/hex IP encodings. Callingsocket.getaddrinfo()and testingipaddress.ip_address()properties defeats all of those, because it asks the question that matters: where would a socket actually connect? -
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. -
is_link_localis what specifically closes the metadata hole.169.254.169.254is in169.254.0.0/16, which is link-local rather than private, so a guard that only testedis_privateandis_loopbackwould have left the credential-theft path wide open.is_reservedandis_multicastround 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 returning302 Location: http://169.254.169.254/...still lands on the metadata service. Closing this requiresallow_redirects=Falsewith per-hop re-validation. - DNS rebinding / TOCTOU. The guard resolves the name, then
requestsresolves 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(orurl, orimage_url) query parameter that reachesrequests.get()is a destination-choosing primitive for the attacker, not just data — validate before the first byte leaves. - Testing only
is_privateandis_loopbackleaves cloud metadata reachable:169.254.169.254is link-local, sois_link_localis the check that actually protects IAM credentials. - Resolve the hostname with
getaddrinfo()and test the resultingipaddressobject; string blocklists forlocalhostand127.0.0.1are 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
requestsfollows redirects by default; treatallow_redirectsas part of the control.
How Orbis AppSec Detected This
- Source: the
srcHTTP query parameter, read viarequest.args.get('src')and passed as thesrcargument ofstream_media_file()and as theurlargument ofdownload_media_file(). - Sink:
requests.get()called with that attacker-controlled URL (withstream=Trueand 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/16link-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 withsocket.getaddrinfo()and raisesValueErrorfor 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.