no-ssrf-in-tool
Disallow an MCP tool argument choosing the host an outbound HTTP request is sent to.
CWE: CWE-918 OWASP: A10:2021 – Server-Side Request Forgery
Detects a tool argument that decides the origin of a request sent with fetch, node-fetch, undici, axios, got or http(s), with no host allowlist check on the way. This rule is part of eslint-plugin-mcp-sdk-security.
💼 This rule is set to error in the recommended and strict configs.
Quick Summary
| Aspect | Details |
|---|---|
| CWE Reference | CWE-918 (Server-Side Request Forgery) |
| Severity | High (CVSS 8.6) |
| Auto-Fix | ❌ No auto-fix available |
| Category | Security |
Why this matters
An MCP server usually runs next to things the model must not reach: the cloud
metadata endpoint (169.254.169.254), an admin port on localhost, a service
inside the VPC. A tool that fetches a URL the model supplied lets whatever
steers the model make this server request any of them and read the answer back.
What decides the origin
Only the part of a URL that picks the host matters. An argument interpolated after a fixed origin fills a path or a query and is not reported:
| Shape | Reported |
|---|---|
fetch(url) — the argument is the URL | ✅ |
fetch(`https://${host}/status`) — it is the host | ✅ |
fetch('https://api.example.com' + sub) — .evil.io extends the host | ✅ |
fetch(`${BASE}${path}`) — @evil.io lands in the authority | ✅ |
new URL(target, BASE) — an absolute URL overrides the base | ✅ |
fetch(`https://api.github.com/repos/${owner}`) | — |
new URL(`/v1/${id}`, BASE) | — |
The cut is the first /, ? or # that follows a host character in the fixed
text before the argument. A base this file cannot read (process.env.API_URL)
followed by / fixes the origin; one followed directly by the argument does
not.
Relationship to node-security/no-ssrf
node-security/no-ssrf is source-agnostic. Inside an MCP tool handler the
source is the handler's own parameter, so this rule follows it, applies the
origin cut above, and recognises host allowlists. Both may report the same
line; tool handlers are this plugin's scope (owner decision, 2026-10).
❌ Incorrect
// ❌ the model picks the URL
server.registerTool('fetch_url', cfg, async ({ url }) => ({
content: [{ type: 'text', text: await (await fetch(url)).text() }],
}));
// ❌ the model picks the host
server.registerTool('status', cfg, async ({ host }) =>
axios.get(`https://${host}/status`),
);
// ❌ checking the scheme does not confine the host
server.registerTool('get', cfg, async ({ url }) => {
const u = new URL(url);
if (u.protocol !== 'https:') throw new Error('https only');
return fetch(u);
});How clients, arguments and checks are recognised
- Clients are resolved to their module: the global
fetch(unless a local binding shadows it),node-fetch,undici'sfetch/request/stream,axiosandgot(called directly, through a verb method, or through an instance fromaxios.create()/got.extend(), here or in a relative module), andhttp/httpsget/request.cache.get(key)is not a request. An options object'surl,baseURL,prefixUrl,hostandhostnameare the target. - Arguments are tool arguments,
request.params.argumentsandResourceTemplatevariables, followed through bindings, string derivations, same-file helpers andnew URL(). A request helper's URL parameter takes the origin each caller passes, soget(`${API}/items?q=${q}`)keeps its fixed origin. The origin is also read through alet(every value written to it), a same-file helper's return,await,String(u),u.hrefandu.toString(). - Into helpers, elements and parsed arguments (1.0). A same-file
function or class method (
this.run(args.cmd)) the handler calls carries what its callers pass it, so a sink inside the helper is reported — an object literal it is given is read field by field. Each element of an argument array is the argument (for (const p of paths),paths.map((p) => …)), and a ZodSchema.parse(args)/safeParse(args).datahands the arguments back: parsing checks their shape, not their text. A key the parsing schema restricts toz.enum/z.literal/z.nativeEnumstays the allowlist. Callers are found by resolving the callee, never by a name. - An allowlist check is an
ifthat tests the URL —url.hostname,url.hostorurl.origincompared, a call such asALLOWED_HOSTS.has(u.hostname),target.startsWith('https://api.example.com/')or a validator — and exits before the request or wraps it. A callback in the test (ALLOWED.some((h) => u.hostname === h)) and a host read into aconstcount. A same-file helper that checks its parameter before every return sanitises its result; a check nested inside anotherif(an allowlist that applies only when configured) does not. A caller that checks the host before passing the URL to a helper hands the helper a checked value. Not host checks: aprotocolcheck, a scheme-only prefix (url.startsWith('https://')), a same-file predicate that never reads a host (isValidUrl(url)that only parses it), a string transform such astrim(), or a request made inside the test. - A closed set is the allowlist:
z.enum([...])and friends.
✅ Correct
const API_ORIGIN = 'https://api.example.com';
server.registerTool('get_item', cfg, async ({ id }) =>
fetch(new URL(`/v1/items/${encodeURIComponent(id)}`, API_ORIGIN)),
);
const ALLOWED_HOSTS = new Set(['api.example.com', 'docs.example.com']);
server.registerTool('fetch_doc', cfg, async ({ url }) => {
const target = new URL(url);
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.hostname)) {
throw new Error('host not allowed');
}
return fetch(target, { redirect: 'error' });
});What this rule deliberately does not report
- An argument that only fills a path or query after a fixed origin.
- A URL that is not a tool argument, or a request outside any tool handler.
- A URL checked by a validator this rule cannot see into.
- Redirects.
fetchfollows them by default; setredirect: 'error'(or re-check every hop) when the allowlist matters. - A file that imports no MCP server package.
When Not To Use It
A server whose whole purpose is fetching arbitrary URLs for the model (the
reference fetch server) is SSRF by design. Run it isolated from internal
networks, and disable this rule for that file with a comment saying so.
Further Reading
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.