Skip to main content
interlace
Plugin: mcp-sdk-securityRules

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

AspectDetails
CWE ReferenceCWE-918 (Server-Side Request Forgery)
SeverityHigh (CVSS 8.6)
Auto-Fix❌ No auto-fix available
CategorySecurity

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:

ShapeReported
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's fetch / request / stream, axios and got (called directly, through a verb method, or through an instance from axios.create() / got.extend(), here or in a relative module), and http / https get / request. cache.get(key) is not a request. An options object's url, baseURL, prefixUrl, host and hostname are the target.
  • Arguments are tool arguments, request.params.arguments and ResourceTemplate variables, followed through bindings, string derivations, same-file helpers and new URL(). A request helper's URL parameter takes the origin each caller passes, so get(`${API}/items?q=${q}`) keeps its fixed origin. The origin is also read through a let (every value written to it), a same-file helper's return, await, String(u), u.href and u.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 Zod Schema.parse(args) / safeParse(args).data hands the arguments back: parsing checks their shape, not their text. A key the parsing schema restricts to z.enum / z.literal / z.nativeEnum stays the allowlist. Callers are found by resolving the callee, never by a name.
  • An allowlist check is an if that tests the URL — url.hostname, url.host or url.origin compared, a call such as ALLOWED_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 a const count. A same-file helper that checks its parameter before every return sanitises its result; a check nested inside another if (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: a protocol check, 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 as trim(), 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. fetch follows them by default; set redirect: '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.