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
•Technically reviewed by Anupam Mediratta•Published September 24, 2026•Reviewed October 1, 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

axios 1.18.1 CVE-2026-101898: HTTP/2 Ignores proxy and lookup

A dependency upgrade moved `axios` from 1.18.1 to 1.20.0 to pick up the fix for CVE-2026-101898, in which proxy and DNS resolution settings supplied through axios request configuration were not applied on the HTTP/2 request path. Applications that rely on `config.proxy`, `HTTP_PROXY`/`NO_PROXY` environment variables, or a custom `lookup` function as an SSRF or egress control could have those controls silently skipped for HTTP/2 requests.

critical

Cloudflare Worker Reverse Proxy SSRF via String Concatenation

A Cloudflare Worker reverse proxy built its upstream request URL by string-concatenating an environment variable with the incoming request's pathname and search string, letting a crafted path or query redirect requests to attacker-chosen hosts. The fix replaces concatenation with the `URL` object's `pathname` and `search` setters, which normalize input against the configured origin instead of blindly appending it.

high

fast-uri 3.1.2 IDN Host Bypass: CVE-2026-13676 Upgrade

A dependency tree in this repository resolved `fast-uri` 3.1.2, a version affected by CVE-2026-13676, in which improper Unicode hostname canonicalization lets a crafted URI parse to one host while the HTTP client connects to another. Because `fast-uri` is what `ajv` uses to parse and resolve URIs for `format: "uri"` validation and `$ref` resolution, any host allowlist built on its parsed output could be bypassed. The fix upgrades the resolved copy to `fast-uri` 4.1.4 and pins it with an npm `ove

high

boardcards.js parseArgs() SSRF: --board Flag Reaches Metadata IPs

The `parseArgs()` function in boardcards.js accepted a user-controlled `--board` command-line argument and passed it directly to fetch() calls without validating the URL scheme or hostname. An attacker could supply `http://169.254.169.254/latest/meta-data/` or other internal addresses to extract cloud credentials or scan internal networks.

high

fetch() with Automatic Redirects Leaks HTTPS Trust to api.tomys.top

The `withProxy()` fetch wrapper allowed automatic HTTP redirects from `api.tomys.top`, trusting the external API's domain integrity without validating final destinations. A compromised or malicious redirect response could send users to attacker-controlled phishing sites while maintaining HTTPS protocol and hostname checks that falsely appear secure.

high

Meta.get() Honored @uploadURL From Script Headers: SSRF Fix

A userscript manager's metadata parser accepted the `@uploadURL` directive from a script's own header block, so any installed or auto-updated script could silently redirect the options-page `uploadScript()` fetch to an attacker-chosen destination — including loopback, link-local metadata endpoints, and LAN addresses. The fix makes `Meta.get()` ignore `@uploadURL` when it appears in a script header, leaving the User Metadata field (validated in `Meta.getUserMeta()`) as the only way to set it, and