Report privately through either channel:
- GitHub private vulnerability reporting (preferred): report it here. The report is visible only to you and the maintainers until we publish the advisory alongside the fix, credited to you.
- Email: [email protected]
Include:
- a description of the issue,
- reproduction steps or a PoC,
- which version you found it on.
We aim to acknowledge within 2 business days and have a fix scheduled within 14 days for confirmed reports.
Please don't put the details of a security problem in a public issue until the fix has shipped. If neither channel above works, an issue that says only that you need a private contact -- no technical detail -- is fine, and we will reach you from there.
In-scope for this repo:
- SSRF bypasses in the pre-flight URL validator or per-hop redirect re-validator (
src/security.ts,src/http.ts) - Leakage of request headers or body between requests, or across cross-origin redirects
- Response-size cap bypass (
max_bytes) leading to OOM or DOS - Timeout bypass that allows a slow-loris request to hold a worker indefinitely
- DNS-rebinding techniques that evade the pre-flight IP check
- Any path that lets an attacker-controlled URL cause the MCP host process to exfil or mutate local-filesystem/network resources
Out of scope:
- Rate limiting, DoS protection of the upstream server you're fetching (that's your responsibility)
- An operator launching with
FETCH_MCP_ALLOW_PRIVATE_HOSTS=1and then being surprised that calls withallow_private_hosts: truereach private hosts - Vulnerabilities in the MCP client, transport, or LLM you plug this into
The MCP host (your editor, your agent) runs this server. An LLM talks to the server over stdio and can call tools with arguments the LLM itself chose. The LLM is not trusted -- a malicious prompt injected via a previously-fetched page could try to coerce the LLM into calling http_get against http://169.254.169.254/latest/meta-data/ to exfiltrate cloud credentials, or file:///etc/passwd, or http://10.0.0.1/admin to reach an internal dashboard.
The server refuses those calls by default.
- Scheme allow-list. Only
http:andhttps:are allowed.file:,gopher:,ftp:,data:,javascript:are refused before any network call. - IP literal check. URLs whose host is a literal IP are rejected if the IP falls into a loopback, RFC1918 private, link-local (incl. 169.254.169.254 cloud metadata), CGNAT, ULA, multicast, or reserved range. IPv4-mapped IPv6 literals are re-checked against the IPv4 rules, as is the IPv4 embedded in IPv4-compatible (
::/96), IPv4-translated (::ffff:0:0:0/96) and 6to4 (2002::/16) addresses; Teredo (2001::/32), NAT64 (64:ff9b::/96,64:ff9b:1::/48), site-local (fec0::/10) and discard-only (100::/64) are refused outright, and so is the Azure WireServer address168.63.129.16. Beyond those, IPv6 is an allow-list: only global unicast (2000::/3) passes, minus3fff::/20,2001:2::/48and2001:10::/28. localhost*hostname check. The nameslocalhostand*.localhostare refused even though DNS could map them anywhere.- DNS pre-resolve with IP pinning. For every non-literal host we resolve via
dns.lookupand check every returned address. If any address is in a blocked range, the request is refused -- with a message that does not name the resolved address, so a refusal cannot be used to map internal hostnames to IPs. The IP we verified is then pinned into an undici dispatcher so the subsequent TCP connection dials THAT address -- not a different one returned by a concurrent DNS query. This closes the classic DNS-rebinding TOCTOU window. - Per-hop redirect re-validation. Redirect responses are handled manually with
redirect: "manual". EveryLocationtarget goes through steps 1-4 before we dial it: the scheme allow-list and the literal-IP /localhost*checks run insidesendHop()on every hop (not only on the initial URL), and hostnames are additionally resolved and pinned. A 302 from a public host tohttp://127.0.0.1,http://169.254.169.254,http://[::1],http://2130706433orftp://...is caught. (Versions 0.3.0-0.7.0 skipped the IP check for literal-IP redirect targets, and no version before 0.7.1 re-checked the scheme on a redirect -- the latter without practical impact, since the runtime's fetch rejects non-http(s) URLs. Fixed in 0.7.1.) - Cross-origin credential stripping.
Authorization(from explicitheaders,basic_auth, orbearer_token),CookieandProxy-Authorizationheaders are stripped when a redirect leaves the initial origin (scheme, host or port changes). Matches the behavior ofcurl/ fetch forAuthorization;CookieandProxy-Authorizationjoined it in 0.7.1. - Streaming size cap with
AbortController. Response bodies are read as a stream and the request is aborted oncemax_bytesis hit. We never buffer a full response and then truncate. Hard ceiling: 100 MiB. A gzipped sitemap (fetch_sitemapdecompresses.xml.gzitself) is also capped atmax_bytesDECOMPRESSED, so a small gzip bomb cannot inflate past the limit. - Request timeout and cancellation via
AbortController.timeout_msbounds each attempt end to end -- DNS, every redirect hop, headers and body -- defaulting to 10s and capped at 120s by the tool schema. Retries get a fresh budget, and a whole call is capped at 5 minutes -- afetch_sitemapcall too, across every document it fetches. A connect in progress is abandoned the moment the deadline or a cancellation fires. Cancelling the MCP tool call aborts the hop in flight (DNS included), any Retry-After wait and every later attempt. - Redirect body drain. 3xx response bodies are cancelled (not buffered) before following the
Location, preventing a hostile server from ballooning memory use via a large redirect body. - JSON auto-parse is content-type gated. The response is only auto-parsed into
.jsonwhen the server declaresapplication/jsonor+json. An HTML page containing a JSON fragment never ends up in.json. - Operator gate on
allow_private_hosts. The per-callallow_private_hosts: trueopt-in is a tool argument, and the LLM chooses tool arguments -- so on its own it would hand the injected prompt above a one-flag bypass. It is refused (before any DNS or network work) unless the operator launched the server withFETCH_MCP_ALLOW_PRIVATE_HOSTS=1. The value is parsed strictly --1/true/yes/on, anything unrecognised is OFF and named on stderr -- and under the oam sandbox (FETCH_MCP_SANDBOX=1) the launcher grants that one variable and no other. Enforced inhttpRequest(), the single path every tool takes to the network, from a policy eachcreateFetchServer()call binds into its own tools -- servers in one process never share it. Before 0.7.1 the flag was honoured unconditionally; in 0.7.1 the setting was process-wide.
- We do not perform full HTML parsing for SSRF-adjacent threats (e.g. SSRF via
<img>in reader mode) because this server does not fetch subresources -- only the URL the tool was asked to fetch. allow_private_hosts: truebypasses defenses 2-5 entirely for that call. It only works when the operator setFETCH_MCP_ALLOW_PRIVATE_HOSTS=1(defense 11); set that only where the model may legitimately reach your internal network.- Credential stripping (defense 6) recognises headers by name. Arbitrary custom headers that carry secrets (
X-Api-Key,X-Auth-Token, ...) still follow a cross-origin redirect -- pass secrets throughbearer_token/basic_auth, or setmax_redirects: 0, when the target may redirect off-origin. - A 307/308 redirect re-sends the request body to the new location, including a cross-origin one, as the fetch spec and
curl -Ldo. Setmax_redirects: 0for bodies that must not leave the origin. - We rely on the operating system resolver. If the resolver itself is compromised (e.g.
/etc/hostspoisoning), the pinning step still only dials an address we were handed -- the blast radius is bounded to what that resolver already allows.
Researchers acting in good faith will not face legal action from Yaw Labs. If you find something, tell us and we will credit you in the release notes and, for reports made through GitHub, in the published advisory.