Back to Blog
high SEVERITY7 min read

esearch() SSRF: requests.get() Trusted Any Host in the URL

A citation format-conversion script used by an AI research skill built HTTP URLs from user-supplied PMIDs, DOIs, arXiv IDs, and free-text queries, then passed the resulting string straight to `requests.get()` with no check that it still pointed at an intended API host. The fix introduces an `ALLOWED_HOSTS` set containing the three real upstream APIs and an `_is_allowed_url()` helper that compares `urlparse(url).hostname` against it before the request is issued. This closes a CWE-918 server-side

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

Answer Summary

The affected code is the `esearch()` helper in a first-party academic citation format-conversion CLI (Python, `requests`); no published package or version range is involved. Before the fix, `esearch()` interpolated user-supplied search terms and identifiers into a URL string and called `requests.get(url, timeout=30)` with no scheme, host, or private-IP validation, so any input that influenced the request target could redirect the fetch at `http://169.254.169.254/latest/meta-data/`, `http://127.0.0.1:<port>/`, or internal RFC1918 hosts — yielding cloud credentials, access to localhost-only admin APIs, and timing-based internal port scanning. The fix adds `ALLOWED_HOSTS = {"eutils.ncbi.nlm.nih.gov", "api.crossref.org", "export.arxiv.org"}` plus an `_is_allowed_url()` check using `urlparse(url).hostname`, which returns an empty result list instead of issuing the request when the host is not on the list; there is no released fixed version because this is first-party code. The weakness class is CWE-918 (Server-Side Request Forgery).

Vulnerability at a Glance

cweCWE-918
fixAdded an `ALLOWED_HOSTS` set and an `_is_allowed_url()` guard that checks `urlparse(url).hostname` before the request
riskAn influenced request target lets the script fetch cloud metadata, localhost admin APIs, or internal hosts on behalf of the caller
languagePython
root cause`requests.get()` received a URL assembled from user-supplied identifiers and queries with no host or scheme validation
vulnerabilityServer-Side Request Forgery (SSRF) in an outbound HTTP fetch helper

Summary

A high-severity server-side request forgery (SSRF) path existed in the esearch() helper of a first-party academic citation format-conversion CLI. The helper assembled a PubMed E-utilities URL from caller-supplied search terms and handed it directly to requests.get(). Nothing in the code verified that the string still pointed at eutils.ncbi.nlm.nih.gov by the time the request was made. The fix adds an explicit host allowlist and a _is_allowed_url() check that runs before every outbound fetch in that function.

Introduction

This tool is a CLI that converts academic citations between formats. It accepts a PMID, a DOI, an arXiv ID, or a free-text query, and resolves that input against one of three public APIs: NCBI E-utilities, Crossref, and the arXiv Atom API. The resolution step is plain requests code — build a URL string, fetch it, parse the response.

The problem was that the "build a URL string" and "fetch it" steps had no boundary between them. In esearch():

def esearch(query, max_results=5):
    params = {"db": "pubmed", "term": query, "retmax": max_results, "retmode": "xml"}
    url = f"{EUTILS_BASE}/esearch.fcgi?{urlencode(params)}"
    try:
        resp = requests.get(url, timeout=30)

url is a str built by interpolation, and requests.get() will fetch whatever str you give it: http://, https://, an IP literal, a link-local address, localhost:8080. The function's intent is "fetch PubMed," but its contract is "fetch an arbitrary URL." That gap is CWE-918, and it is one of the most common ways SSRF gets introduced into otherwise-boring API client code.

This matters more than usual here because the script is packaged as a skill invoked by an AI agent. The arguments it receives — query, identifiers, and anything else a caller decides to pass — can originate from document text the agent just read. Prompt-injected content in a paper abstract, an issue comment, or a scraped page becomes CLI input, and CLI input becomes an outbound HTTP request made from inside your network with your instance's credentials attached to the network path.

Affected Versions

Affected not applicable (first-party code) — the esearch() fetch path prior to the linked fix commit
Fixed in not applicable (first-party code) — fixed by the commit adding ALLOWED_HOSTS and _is_allowed_url()
Ecosystem not applicable (first-party Python script using requests)
CVE / GHSA not assigned
CWE CWE-918 (Server-Side Request Forgery)

There is no package version to pin here. If your copy of this converter calls requests.get() on an interpolated URL without a host check, you are exposed regardless of when you copied it.

The Vulnerability Explained

The vulnerable pattern

Three module-level constants define the intended hosts:

EUTILS_BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
CROSSREF_BASE = "https://api.crossref.org/works"
ARXIV_BASE = "https://export.arxiv.org/api/query"

Those constants look like a security control, but they are not one. They are a default. The line that actually decides where the packet goes is requests.get(url, timeout=30), and it accepts any string. Every guarantee about the destination lives in the assumption that nothing between the constant and the call site changed the host — an assumption the code never checks and no test can enforce.

Why urlencode() is not the mitigation

It is tempting to look at urlencode(params) and conclude the input is already safe. urlencode() percent-encodes query and max_results, so a search term cannot introduce a raw &, #, or / into the query string. That is real protection — against query string injection.

It does nothing about the authority component. The host, port, scheme, and userinfo of the final URL are all decided by EUTILS_BASE, which sits entirely outside urlencode()'s reach. Sanitizing the terms does not constrain the destination, and the destination is what SSRF is about.

How the host becomes attacker-influenced

There are several realistic routes, and none of them require an exotic bug:

  1. A configurable base. The moment someone adds a mirror or proxy knob — an --api-base flag, a NCBI_BASE_URL environment variable, a settings file for an air-gapped deployment — the destination is caller-controlled and the fetch is an open proxy. This is the single most common way latent CWE-918 becomes live.
  2. Redirect following. requests.get() follows redirects by default. Any hop the script is willing to make can point somewhere the script would never have chosen itself.
  3. A refactor to a URL-taking helper. Fetch helpers in scripts like this drift toward a shared fetch(url). Once the parameter is a URL, the caller owns the host, and callers here include an LLM relaying untrusted text.
  4. Agent-supplied arguments. The skill's arguments are chosen by a model reading untrusted content. Treating them as trusted developer input is the same mistake as trusting a query parameter.

Attack scenario

Suppose the converter runs on an EC2 instance as part of an agent workflow, and a "use this mirror" argument is threaded into EUTILS_BASE. Injected text in a document the agent ingests causes it to invoke the converter with a base of http://169.254.169.254/latest/meta-data/iam/security-credentials. esearch() appends /esearch.fcgi?db=pubmed&term=..., requests.get() fetches it, and the response text is handed to the XML parser. Even if the parse fails, the raw body frequently ends up in agent-visible error output or logs — and a leaked IMDS role credential is a full cloud compromise, not a missing citation.

Softer but equally useful variants against the same call:

  • http://127.0.0.1:5000/admin/shutdown — reach a service that only trusts loopback.
  • http://10.0.4.19:8500/v1/kv/?recurse — read an internal Consul KV store.
  • Iterating http://10.0.0.0/24 ports and measuring which requests time out at 30 seconds versus fail immediately — a port scanner with the script's network identity, the classic "blind SSRF" primitive.

The timeout=30 on the call is a performance guardrail, not a security one; it is precisely the signal that makes timing-based internal scanning legible.

The Fix

The change adds a positive allowlist and enforces it at the call site.

Before — the destination is whatever the interpolated string says:

url = f"{EUTILS_BASE}/esearch.fcgi?{urlencode(params)}"
try:
    resp = requests.get(url, timeout=30)

After — the destination is checked against a closed set first:

ALLOWED_HOSTS = {"eutils.ncbi.nlm.nih.gov", "api.crossref.org", "export.arxiv.org"}


def _is_allowed_url(url):
    """Only allow requests to the known, hardcoded API hosts (mitigates SSRF)."""
    return urlparse(url).hostname in ALLOWED_HOSTS

and, inside esearch():

if not _is_allowed_url(url):
    print("  ESearch error: URL host not in allowlist")
    return []
resp = requests.get(url, timeout=30)

Three details are worth calling out, because they are what make this fix correct rather than merely present:

  • hostname, not netloc. urlparse(url).hostname lowercases the host and strips both userinfo and port. That means https://eutils.ncbi.nlm.nih.gov@evil.example/esearch.fcgi resolves to evil.example and is rejected, and https://EUTILS.NCBI.NLM.NIH.GOV/... is accepted. A netloc comparison, or a url.startswith(...) check, would have been bypassed by the @ form — this is the single most common broken SSRF allowlist.
  • An allowlist, not a denylist. The alternative approach — blocking 127.0.0.1, 169.254.169.254, and RFC1918 ranges — is a game of whack-a-mole against [::1], 0.0.0.0, decimal-encoded IPs, localtest.me-style DNS tricks, and every new metadata endpoint. Three hostnames are known, finite, and auditable. Matching them exactly is the strongest form of this control.
  • urlencode and urlparse imported together. The import line changed from from urllib.parse import urlencode to also pull in urlparse, which is the only supporting change required — the validation needs no new dependency.

The failure mode is also deliberately boring: the guard prints ESearch error: URL host not in allowlist and returns [], matching the function's existing behaviour on network or parse failure. Callers already handle an empty result list, so the fix cannot turn an SSRF attempt into an unhandled exception or a stack trace in agent output.

What this change does not cover

Two gaps remain, and both should be closed in follow-up work:

  1. Only esearch() is guarded. ALLOWED_HOSTS already contains api.crossref.org and export.arxiv.org, but the DOI and arXiv fetch helpers do not yet call _is_allowed_url(). They need the same two lines before their requests.get().
  2. Redirects are still followed. _is_allowed_url() validates the initial URL only. A 302 from an allowed host to an internal address would still be fetched. Passing allow_redirects=False, or re-running _is_allowed_url() on each hop in resp.history, makes the invariant hold for the whole request chain.

Key Takeaways

  • A hardcoded base URL constant is a default, not a control. EUTILS_BASE being a literal in the module says nothing about the string that re

Prevention and further reading

Frequently Asked Questions

Does `_is_allowed_url()` reject a URL like `https://eutils.ncbi.nlm.nih.gov@attacker.example/esearch.fcgi`?

Yes. The helper compares `urlparse(url).hostname`, which strips userinfo and port, so that URL resolves to `attacker.example` and fails the `ALLOWED_HOSTS` membership test. Using `netloc` instead would have been fooled by the `@` trick.

Do the Crossref and arXiv fetch paths get the same allowlist check as `esearch()`?

Not in this change. `ALLOWED_HOSTS` already lists `api.crossref.org` and `export.arxiv.org`, but only `esearch()` calls `_is_allowed_url()`, so the DOI and arXiv fetch helpers still need the identical two-line guard.

Does the allowlist stop a redirect from `eutils.ncbi.nlm.nih.gov` to `169.254.169.254`?

No. `requests.get()` follows redirects by default and `_is_allowed_url()` only validates the first URL; pass `allow_redirects=False` or re-validate each hop to close that gap.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #232

Related Articles

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

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

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

high

JOSMFileHack TransformerFactory XXE: External DTD Processing Enabled

OSM2World's JOSMFileHack utility, which processed OpenStreetMap files generated by the JOSM editor, contained an insecure TransformerFactory configuration that permitted external DTD and stylesheet access. The vulnerability was resolved by completely removing the vulnerable code path rather than hardening it in place.