no-credential-leak-in-tool
Disallow an MCP tool returning the server's environment or the caller's token, or forwarding that token upstream.
CWE: CWE-200, CWE-526, CWE-441 OWASP: A01:2021 ā Broken Access Control
Detects process.env returned in a tool result, the caller's bearer token (authInfo.token) returned to the model, and that token forwarded to another service. 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-200 (Exposure of Sensitive Information) |
| Severity | High (CVSS 7.5) |
| Auto-Fix | ā No auto-fix available |
| Category | Security |
Why this matters
A tool result is read by the model, written to the host's logs, and can be echoed into any later tool call the model is steered into making. Anything a handler returns should be treated as published to whoever can influence the model.
| Message | What it catches |
|---|---|
envInResult (CWE-526) | process.env returned whole ā JSON.stringify(process.env), { ...process.env }, Object.entries(process.env) ā or process.env[name] with name chosen by a tool argument. Every key the server was started with is one prompt away. |
tokenInResult (CWE-200) | authInfo.token ā the bearer token the SDK verified for this request ā returned to the model. |
tokenPassthrough (CWE-441) | The same token forwarded in an outbound HTTP request or to an upstream MCP client transport. The MCP authorization spec forbids it: the downstream API trusts a token not issued for it (a confused deputy), and audit trails attribute the call to the wrong party. |
Relationship to other plugins
None overlap. Neither the tool-result channel nor the SDK's AuthInfo exists
outside MCP, and node-security / secure-coding have no rule for either.
ā Incorrect
// ā every credential the server holds, handed to the model
server.registerTool('debug_env', cfg, async () => ({
content: [{ type: 'text', text: JSON.stringify(process.env, null, 2) }],
}));
// ā the model chooses which variable to read
server.registerTool('get_env', cfg, async ({ name }) => ({
content: [{ type: 'text', text: String(process.env[name]) }],
}));
// ā token passthrough
server.registerTool('list_repos', cfg, async (args, extra) =>
fetch('https://api.github.com/user/repos', {
headers: { Authorization: `Bearer ${extra.authInfo.token}` },
}),
);How results, tokens and requests are recognised
- Results are every value a tool handler (or
ResourceTemplatecallback) returns, followed through same-file bindings. process.envis the globalprocess(unless shadowed) ornode:process's export. Reading one named variable āprocess.env.NODE_ENVā is not reported: whether it is secret is not something a name can prove.authInfois read from the request context the SDK passes each handler: the second parameter when the tool declares an input schema, the first when it does not, and the second of asetRequestHandler(CallToolRequestSchema)handler. Itstokenis followed through destructuring, bindings, templates andJSON.stringify(authInfo).- Outbound requests are the clients
no-ssrf-in-toolresolves (fetch,axios,got,undici,node-fetch,http(s)), plus the SDK'sStreamableHTTPClientTransportandSSEClientTransport. Every argument is searched, including nested headers objects and arrays.
ā Correct
// ā
report what the token grants, not the token
server.registerTool('whoami', cfg, async (args, { authInfo }) => ({
content: [
{
type: 'text',
text: `${authInfo?.clientId}: ${authInfo?.scopes.join(', ')}`,
},
],
}));
// ā
call downstream with a credential issued to this server
server.registerTool('list_repos', cfg, async (args, extra) => {
const token = await exchangeForGitHubToken(extra.authInfo);
return fetch('https://api.github.com/user/repos', {
headers: { Authorization: `Bearer ${token}` },
});
});What this rule deliberately does not report
- One named environment variable in a result.
- The variable names ā
Object.keys(process.env)lists names, not values. - Test files (
*.test.ts,test/,e2e/,fixtures/, ā¦): a fixture server reporting its own environment ships nothing. - The server's own credentials sent downstream (
process.env.SERVICE_TOKEN). - Other
authInfofields āclientId,scopes,expiresAt. - A token exchanged by a call this rule cannot see into: the result of that call is a different token.
- A file that imports no MCP server package.
When Not To Use It
A debugging tool that deliberately reports configuration should return a fixed list of non-secret keys instead, so this rule has no options.
Further Reading
- MCP Security Best Practices: Token Passthrough
- CWE-526: Exposure of Sensitive Information Through Environmental Variables
- CWE-441: Unintended Proxy or Intermediary (Confused Deputy)
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.