| Version | Security fixes |
|---|---|
0.1.x beta (current) |
Yes |
| Earlier pre-releases | No — upgrade to the latest 0.1.x beta |
Pre-1.0 the supported surface is whatever the latest published beta carries. There is no LTS branch below 0.1.x. When 1.0.0 ships, this table will grow a 1.x (current) row and the previous beta line will age out per the disclosure policy below.
Do not open a public GitHub issue for security vulnerabilities.
Use GitHub's private security advisory: Security → Report a vulnerability (on the repository page)
Include:
- Description of the vulnerability and potential impact
- Steps to reproduce (PoC config, test case, or script)
- Affected component (
vault,bridge,chain,transport,loaders, etc.)
You will receive an acknowledgment within 72 hours and a status update within 7 days.
We follow coordinated disclosure:
- Reporter receives credit in the advisory and CHANGELOG (unless they prefer anonymity)
- We aim to ship a fix within 90 days of confirmation (shorter for critical issues)
- Public disclosure is coordinated with the reporter before release
- CVEs are requested for confirmed vulnerabilities with real-world impact
In scope:
- SSRF via URL validation —
assertSafeUrl,loadOpenApiSpec,loadGraphqlSchema, bridgebaseUrl - Vault encryption (
src/security/vault.js) — AES-256-GCM envelope, key derivation, JWT issuance - Vault-client fail-closed behavior (
src/security/vault-client.js) — auth hooks must throw on daemon failure, not silently drop credentials - Auth credential handling (
src/core/client.js) — credential logging, header injection, OAuth2 flow - Policy bypass (
src/security/policy.js) — approval gate circumvention, deny bypass - Tenant escalation (
src/tenant/scope.js) — cross-tenant data access via_tenantoverride - Prototype pollution (
src/core/object.js) —__proto__,constructor,prototypekey injection - Prompt injection via configs — malicious
descriptionfields or tool names in community configs (configs/)
Out of scope (documented non-goals):
- DoS via oversized configs or large upstream responses — not a rate-limiting product
- Downstream API security — 40mcp forwards requests; it cannot harden what it wraps
- MCP client authentication — protocol limitation, not a 40mcp vulnerability
- Secrets management at scale — the vault is single-node by design
- DNS rebinding at dispatch time — URLs are validated at load/creation time, not per-request (see Known Limitations)
40mcp assumes a trusted operator who controls both the config and the environment. Treat a config file like executable code.
| Control | What it protects |
|---|---|
| Cloud metadata unconditional block | AWS IMDS (169.254.169.254), GCP metadata, Azure IMDS, ECS task-role endpoint — blocked on every code path regardless of allowPrivate / strictSsrf |
| SSRF blocklist at the loader layer (default: strict) | loadOpenApiSpec, loadGraphqlSchema, and assertSafeUrl default to allowPrivate: false, refusing loopback (127.x, ::1, localhost), RFC-1918, link-local, CGNAT (100.64.0.0/10), and IPv6 ULA/multicast |
| SSRF blocklist at the bridge layer (default: permissive for local dev) | createRestBridge defaults to allowPrivate: true so local development against 127.0.0.1 works out of the box. Cloud metadata is still blocked unconditionally. Set config.strictSsrf: true (or config.allowPrivate: false) in any production deployment that should refuse private/loopback targets. doctor flags this when baseUrl is public but strictSsrf is unset on a non-localhost host. |
| IPv4-compatible IPv6 decode | ::7f00:1 (= ::127.0.0.1) is decoded and re-validated against IPv4 range rules |
| Loopback hostname denylist | localhost, ip6-localhost, localhost.localdomain blocked when allowPrivate: false |
| Prototype pollution guards | setByPath/getByPath refuse __proto__, constructor, prototype keys |
| Secret-named env var block | Env vars matching TOKEN, PASSWORD, API_KEY, etc. refused in URL template substitution |
| Embedded URL credential block | user:pass@host form rejected |
| Input validation | Tool args validated against inputSchema before dispatch |
| Vault fail-closed | Auth hooks throw (not silently no-op) when vault daemon is unreachable |
| SSE idle eviction | Connections closed after 5 min idle; mitigates Slow Loris |
| Schema sanitization | connectStdio/connectSse strip prototype-poisoning keys from upstream schemas |
| Egress chokepoint (audited) | Every outbound fetch in src/ passes through assertSafeUrl before the network call. Enforced programmatically by src/security/invariants/egress-chokepoint.test.js — new PRs that add an outbound fetch without the gate fail CI. |
The two-layer default. Loader-layer SSRF (
assertSafeUrl,loadOpenApiSpec,loadGraphqlSchema) blocks private ranges by default; bridge-layer dispatch (createRestBridge) permits them by default sobaseUrl: "http://127.0.0.1"during development does not require a flag. Cloud-metadata hosts are blocked on both layers unconditionally. For production: setstrictSsrf: trueon the bridge config, or pin a non-permissiveallowPrivate: falseexplicitly.
All result-exit paths from 40mcp apply at minimum stripInternalEnvelopes. Paths that expose results to an LLM also apply sanitizeResultObject (prompt-injection redaction over all string leaves).
| Exit path | stripInternalEnvelopes |
sanitizeResultObject |
Notes |
|---|---|---|---|
MCP tools/list (bridge) |
— | ✓ | sanitizeMcpToolDescription on each description |
MCP tools/list (mixer) |
— | ✓ | sanitizeMcpToolDescription; mixer result-egress symmetry documented in the rows below |
MCP tools/call (bridge CallToolRequestSchema) |
✓ | ✓ | Both passes in bridge.js:978–1004 |
MCP tools/call (mixer CallToolRequestSchema) |
✓ | ✓ | sanitizeTransportEgress |
dispatch() exported (bridge) |
✓ | ✓ | sanitizeTransportEgress at bridge.js:565 |
dispatch() exported (mixer) |
— | — | Consumed by the CallToolRequestSchema handler above; no separate LLM path |
| Reverse bridge REST egress | ✓ | ✓ | Inherits from dispatch() (bridge already applied sanitizeTransportEgress); stripInternalEnvelopes applied again (idempotent) |
| Webhook sync response | ✓ | ✓ | sanitizeTransportEgress in webhook/listener.js:577 |
| SSE transport | N/A | N/A | Serializes MCP protocol objects, not raw dispatch results; sanitization upstream |
| Chain egress (intermediate steps) | — | — | Steps aggregate into a final result; dispatch() or CallTool handler sanitizes before external exit |
This audit is enforced programmatically by src/security/invariants/egress-sanitize.test.js — new exit paths must register there or fail CI.
Test coverage: See sanitization invariant tests.
The reverse bridge (createReverseBridge) validates incoming auth headers using HMAC-SHA256 digest comparison:
- Both the expected secret (from
process.env[auth.envVar]) and the received header value are HMAC-SHA256'd with a cryptographically-random per-process key generated at startup viarandomBytes(32). - The resulting 32-byte digests are compared with
timingSafeEqual.
Why HMAC instead of direct timingSafeEqual on the raw strings? Direct timingSafeEqual requires equal-length buffers. Padding to a fixed maximum length leaks the secret's byte-length as a timing oracle — Buffer.alloc(maxLen) allocation branches are distinguishable under timing analysis even when the comparison itself is constant-time. HMAC normalizes both inputs to exactly 32 bytes regardless of secret or header length, eliminating the length oracle entirely.
What this protects: Timing side-channel attacks that would otherwise reveal the secret's length and byte values through differential response-time measurements.
What it does NOT protect: A compromised environment where the env var or HMAC key is readable from the process; brute-force attacks against short secrets; forward-secrecy (the HMAC key is per-process, regenerated on restart).
Test coverage: See webhook and reverse bridge invariant tests.
- DNS rebinding (
connectSsehttp://):connectSsenow resolves the upstream hostname to an IPv4 address at connection time, validates it against the SSRF blocklist, and pinshttp://transports to that IP (reconnects go to the pinned IP, not whatever DNS returns later). HTTPS upstreams are protected by TLS certificate validation. BridgebaseUrlandloadOpenApiSpec/loadGraphqlSchemaURLs are still validated at load time only — DNS rebinding via those paths remains a known limitation unless network-level controls are present (e.g. restricted outbound DNS). - Operator trust: The operator controls the config. 40mcp does not protect against a malicious config operator.
- MCP client auth: stdio transport is unauthenticated by protocol design. SSE transport inherits whatever the HTTP server provides.
ListToolsis unauthenticated: MCP protocol design — all clients can enumerate available tools.connectStdioexecutes commands: The command in the config is exec'd directly — treat.mcp.jsonfiles like code./healthsession count: Not exposed by default. SetexposeSessionCount: trueincreateSseTransportfor internal/trusted networks only.
- 40mcp is not a WAF, API gateway, or network firewall.
- 40mcp does not guarantee protection against a compromised operating environment.
- 40mcp does not prevent prompt injection by upstream APIs into LLM responses — it controls what reaches the LLM tool call boundary, not what the LLM does with the response.
- 40mcp's security invariants cover documented code paths. Operator-provided middleware, custom plugins, or extensions are outside this scope.
See SPEC.md §7 for the full security model, docs/SAFE-DEFAULTS.md for the deployment checklist, docs/trust-model.md for the three-tier trust topology, and docs/security-evolution.md for the design history behind these controls.
The only supported channel for security reports is the GitHub private security advisory flow described above under Reporting a Vulnerability (Security → Report a vulnerability on the repository page). Do not email or DM individual maintainers — those channels are not monitored for security reports and your report may be lost.
For non-sensitive questions about the security model, open a regular GitHub Discussion or issue. For coordinated disclosure of a confirmed vulnerability, the repository advisory thread is the canonical record.