@sms-florin/mcp-ssrf-scan
v0.5.0
Published
A static scanner for MCP servers that catches the exact bug class behind most real MCP SSRF reports: a tool handler making an outbound request to a caller-controlled URL with no host validation.
Maintainers
Readme
mcp-ssrf-scan
A static scanner for MCP (Model Context Protocol) servers, built around one specific, common, real bug class: a tool handler making an outbound request to a URL the caller controls, with no check on where that URL actually points.
npx @sms-florin/mcp-ssrf-scan ./srcWhy this exists
I found this exact bug in production, on my own server, before I ever
thought about building a scanner for it. sms-florin
is a real phone-number-rental API I run — a GOIP webhook route on it
forwards SMS content from hardware gateways, and an early version of that
route made an outbound call using a value that, with the wrong input, could
have reached internal infrastructure instead of the intended destination.
It got caught and fixed before it mattered, but it's the kind of bug that
doesn't announce itself — the code runs, the tests pass, nothing looks wrong
until someone points it at 169.254.169.254 or localhost:6379.
MCP tool handlers are the same shape, at scale: a function that takes caller-supplied arguments and, somewhere inside, makes a network call. BlueRock scanned 8,000+ public MCP servers and found 36.7% have unbounded URI fetch / SSRF flaws — this isn't a theoretical category, it's the single most common finding in the largest public scan of real MCP servers to date.
What it checks
- SSRF (the headline check): a tool handler (
server.tool(...)/setRequestHandler(...)) that passes a caller-controlled value intofetch/axios/http.request/https.request/gotas the host — a caller value that only fills a path segment under a fixed or configured base URL is not reported — with no validation-looking identifier (allowlist,isPrivateIP,ssrf, ...) anywhere nearby. - Hardcoded secrets — Stripe/AWS/GitHub/Google key patterns, and a
generic
password/apiKey/token= "literal string" check (placeholders, env var names and values without a mix of letters and digits are ignored). - exec/Function injection —
child_process.exec()/execSync()ornew Function()called with an interpolated string, the shape a command-injection bug usually has.db.exec(),RegExp.exec()and values wrapped in a shell-escape helper are not reported.
Test, e2e, script, example and docs directories are skipped by default (run
with --include-tests to scan them too). On a 2026-10-19 scan of 36 public MCP
servers this cut 482 findings to 28.
- Tool poisoning — a tool description that talks to the model instead of
the user: hidden
<IMPORTANT>-style tags, "ignore previous instructions", "don't tell the user", requests for key files or credentials, and invisible or bidirectional Unicode characters. An AI client reads tool descriptions as trusted instructions, so these are reported as HIGH.
Pin tool descriptions (rug-pull detection)
A tool description can be changed after you reviewed and approved it, and the client will follow the new text. Pin the current ones, commit the file, and fail CI when they change:
npx @sms-florin/mcp-ssrf-scan ./src --pin # writes ./src/.mcp-tool-pins.json
npx @sms-florin/mcp-ssrf-scan ./src --check-pins # in CI--check-pins reports a changed description as HIGH (with the old and new
hash), a new tool as MEDIUM and a removed one as INFO; after reviewing
the change, re-run --pin to accept it. It runs fully locally: no account, no
upload, no network. Whitespace-only edits don't change the pin. Use
--pin-file path to keep the file elsewhere.
Limits: it reads string literals in JS/TS source (server.tool(...),
registerTool(...), { name, description } entries). A description built at
runtime, or defined in Python or another language, isn't seen.
What it is not
This is a regex-based heuristic scanner, not a real AST analyzer or a
dynamic test. It has false positives (a validated call it doesn't recognize
as validated) and false negatives (a vulnerable call it doesn't recognize as
vulnerable, especially across files or through an abstraction layer). Treat
a HIGH finding as "go read this function," not as a confirmed bug — and
don't assume a clean scan means a server is safe.
CI usage
Exit code is 1 if any HIGH finding is reported, 0 otherwise:
- run: npx @sms-florin/mcp-ssrf-scan ./src--json prints findings as JSON instead of the human-readable list.
--sarif prints SARIF 2.1.0 for code-scanning UIs. Secret findings are tagged
OWASP MCP01 (Token Mismanagement & Secret Exposure) and exec findings MCP05
(Command Injection & Execution). The OWASP MCP Top 10 has no SSRF entry, so
the SSRF rule carries no OWASP tag.
GitLab CI component
include:
- component: gitlab.com/florin-builds/mcp-ssrf-scan/scan@~latest
inputs:
path: ./srcInputs: stage (default test), path (default .), node_image (default
node:22-alpine). The job fails on any HIGH finding and keeps
mcp-ssrf-scan.sarif as an artifact.
mcp-ssrf-scan Cloud (optional, paid)
The CLI above is free and stays free. mcp-ssrf-scan Cloud ($15/month, up to 10 repos) adds what a CI gate alone can't: every run is uploaded and diffed against the previous scan of that repo, so the CLI prints exactly what's new since the last scan and what got fixed, and every scan stays browsable on a dashboard for 90 days.
# GitHub Actions
- run: npx @sms-florin/mcp-ssrf-scan@latest ./src --upload
env:
MCP_SSRF_SCAN_KEY: ${{ secrets.MCP_SSRF_SCAN_KEY }}# GitLab CI (MCP_SSRF_SCAN_KEY as a masked CI/CD variable)
mcp-ssrf-scan:
image: node:22
script:
- npx @sms-florin/mcp-ssrf-scan@latest ./src --uploadRepo, branch and commit come from the CI environment (or --repo owner/name).
Only findings are uploaded — path, line, rule, message and the flagged line
— never your source. A failed upload only prints a warning; the exit code is
always decided by the findings alone.
License
MIT
