require-http-transport-hardening
Require an MCP HTTP server transport to validate the Host header and a network-reachable MCP server to authenticate its callers.
CWE: CWE-346 (and CWE-306 for missingAuth)
OWASP: A05:2021 β Security Misconfiguration
Detects SDK HTTP transports built with DNS-rebinding protection off, createMcpExpressApp bound to every interface with no allowedHosts, and an MCP HTTP server bound to every interface with no authentication in the file. This rule is part of eslint-plugin-mcp-sdk-security.
πΌ This rule is set to error in the strict config.
Quick Summary
| Aspect | Details |
|---|---|
| CWE Reference | CWE-346 (Origin Validation Error) |
| Severity | High (CVSS 8.1) |
| Auto-Fix | β No auto-fix available |
| Category | Security |
| messageId | What it reports |
|---|---|
missingRebindingProtection | An HTTP transport without enableDnsRebindingProtection: true + allowedHosts |
allInterfacesWithoutAllowedHosts | createMcpExpressApp({ host: '0.0.0.0' | '::' }) with no allowedHosts |
missingAuth (CWE-306) | An all-interfaces MCP HTTP server with no authentication in the file |
Why this matters
The SDK's HTTP transports ship with enableDnsRebindingProtection: false.
A server on localhost with that default is reachable from any web page the
user opens: the page's own hostname is rebound to 127.0.0.1, and the
browser then treats the MCP server as same-origin. The page can list and call
every tool, with whatever access the server has to files, shells and tokens.
The Host header is the one thing such a request cannot fake, so validating it
against an allowlist closes the attack.
A server bound to 0.0.0.0 or :: is reachable by anyone who can reach the
port. With no authentication in front of it, every tool is public.
SDK v1 and v2 put the protection in different places
| SDK | Where Host validation lives | What this rule reports |
|---|---|---|
v1 β @modelcontextprotocol/sdk, and v2's @modelcontextprotocol/server-legacy SSE | The transport: enableDnsRebindingProtection + allowedHosts (or an app-level validator) | The transport (missingRebindingProtection) |
v2 β @modelcontextprotocol/server, /node, /express, /hono, /fastify | The app: createMcpExpressApp / createMcpHonoApp / createMcpFastifyApp validate localhost by default, and allowedHosts installs hostHeaderValidation(). The transports have no such option. | A hand-built express() / new Hono() / fastify() app that serves the transport with no validator (appWithoutHostValidation), or an app factory on every interface with no allowedHosts |
β Incorrect
// β protection off (the default) β any page can rebind to it
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
// β SSE: the options are the 3rd argument, and there are none
const sse = new SSEServerTransport('/messages', res);
// β protection on, but no allowedHosts β nothing is compared
new StreamableHTTPServerTransport({ enableDnsRebindingProtection: true });
// β every interface, no allowedHosts β the SDK only logs a warning
const app = createMcpExpressApp({ host: '0.0.0.0' });
// β (missingAuth) a transport, an explicit all-interfaces bind, no auth
app.post('/mcp', (req, res) => transport.handleRequest(req, res, req.body));
app.listen(3000, '0.0.0.0');
// β (appWithoutHostValidation) SDK v2 on a hand-built app with no validator
const raw = express();
raw.post('/mcp', (req, res) =>
new NodeStreamableHTTPServerTransport().handleRequest(req, res, req.body),
);How it recognises things
- Transports are resolved to the SDK, never matched by name:
StreamableHTTPServerTransportandSSEServerTransport(v1, and v2'sserver-legacy) have their options checked;NodeStreamableHTTPServerTransport(@modelcontextprotocol/node) andWebStandardStreamableHTTPServerTransport(@modelcontextprotocol/server) never do β they mark the file as serving SDK v2, as does a call tocreateMcpHandler. A local class with the same name is not reported. - Apps are judged only when this file builds them β
express(),new Hono(),fastify(), resolved to their packages β and registers a route on them (get/post/put/patch/delete/all/route/on). An app passed in as a parameter (function routes(app: Express)) is not judged. - Options are read inline or through a
const(here or in a relative module).allowedHostsmay be an inline array or aconstarray. A missing options argument counts as unprotected. - App-level validation suppresses
missingRebindingProtectionandappWithoutHostValidationfor the whole file: a call tohostHeaderValidation(...)orlocalhostHostValidation(), or acreateMcpExpressApp/createMcpHonoApp/createMcpFastifyAppthat installs one β no options (default127.0.0.1), a loopback host (127.0.0.1,localhost,::1), or anyallowedHosts. missingAuthreports the bind call when the file constructs an MCP HTTP transport and binds every interface β explicitly (.listen(port, '0.0.0.0'),.listen(port, '::'),.listen({ host: '0.0.0.0' }), or thecreateMcpExpressAppcall above), or implicitly:.listen(port)with no host on anexpress()app, an app fromcreateMcpExpressApp()(itshostoption picks the Host validation, not the interface β and a network caller can sendHost: 127.0.0.1itself), or a Nodehttp(s).createServer(β¦)server binds::β and none of these is present:- a call to an SDK auth export:
requireBearerAuth,mcpAuthRouter,mcpAuthMetadataRouter; - a
.use(...)passed custom middleware: an identifier or an inline function (app.use(express.json())is a factory call and does not count); - a route whose handler calls
.handleRequest(registered with more than a path and a handler (app.post('/mcp', checkAuth, handler)). - an inline credential check: every call that serves the transport
(
transport.handleRequest(β¦), or a handler built by an SDK adapter such astoNodeHandler(β¦)) comes after anifthat reads a request header (req.headersβ¦,req.get(β¦),c.req.header(β¦), directly or through the bindings it is computed from) and returns or throws.
- a call to an SDK auth export:
- A credential check also answers DNS rebinding. A browser does not send a
bearer token or an API key header on its own, so a rebound page cannot get
past the inline check above:
missingRebindingProtection,appWithoutHostValidationandallInterfacesWithoutAllowedHostsare not reported for a file whose transport is served only behind one.
β Correct
// β
the transport validates Host itself (Host header values, port included)
new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableDnsRebindingProtection: true,
allowedHosts: ['127.0.0.1:3000', 'localhost:3000'],
});
// β
the app validates Host (default host is 127.0.0.1)
const app = createMcpExpressApp();
// β
public server: allowedHosts plus bearer auth on the route
const app = createMcpExpressApp({
host: '0.0.0.0',
allowedHosts: ['mcp.example.com'],
});
app.post('/mcp', requireBearerAuth({ verifier }), handler);
app.listen(3000, '0.0.0.0');The transport compares allowedHosts with the raw Host header, port
included. hostHeaderValidation and createMcpExpressApp compare the hostname
only. The rule checks that a list is present and non-empty, not that its
entries match how clients connect.
What this rule deliberately does not report
- Options it cannot read: a parameter, a factory call, an object with a
spread or a computed key, or a flag decided at runtime
(
enableDnsRebindingProtection: process.env.X === '1'). It abstains rather than guess. An explicitfalseis still reported, whateverallowedHostsholds. allowedOriginsalone. It is not accepted as a substitute forallowedHosts: the transport skips the Origin check when the header is absent, and a same-originGETfrom a rebound page sends none.- A bind whose host it cannot place: a host from a variable it cannot
read, a Fastify app (Fastify listens on
localhostunless told otherwise), or an app this file does not build. - An app handed to code this file cannot see. When the app (or the Node
server wrapping it) is passed to a function from another module β
mountAuth(app)β that function may mount the auth middleware, somissingAuthabstains. A same-file function is read instead: its.use(β¦)calls count. (Before 1.0 this was a documented false positive.) - Test files β
*.test.ts,*.spec.ts, and files undertest/,tests/,e2e/,fixtures/,__tests__/and similar (the devkit's shared test-file predicate). A server bound for one assertion is not a deployment. - A file that imports no MCP server package.
Overlap with node-security / secure-coding
None. Neither plugin knows an MCP transport's options, the SDK's host-validation middlewares, or which HTTP server is an MCP endpoint.
When Not To Use It
When every MCP HTTP server in the codebase sits behind a reverse proxy that
validates Host and authenticates, and the files cannot show it. Prefer an
inline eslint-disable that names the proxy over turning the rule off.
Further Reading
- CWE-346: Origin Validation Error
- CWE-306: Missing Authentication for Critical Function
- MCP: Transports, security warning
- MCP: Authorization
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.