HTTP parameter pollution in MCP servers: duplicate keys, array coercion, and validation bypasses
HTTP parameter pollution (HPP) exploits the inconsistency between how different components in your stack parse duplicate HTTP parameters. If your MCP server's API gateway takes the first occurrence of a duplicate parameter but your Express application takes the last, an attacker can craft a request where the gateway sees a safe value and your handler receives a malicious one. In MCP servers with JSON body arguments, the same class of attack applies to unexpected array coercion and type confusion in argument parsing.
How parsing inconsistency creates a security boundary gap
Consider a request to an MCP HTTP transport endpoint:
GET /mcp/call?tool=get_report&reportId=123&reportId=999 HTTP/1.1
Host: api.example.com
The reportId parameter appears twice. What does each layer see?
| Parser | Result for reportId | Behavior |
|---|---|---|
| AWS API Gateway | "123" | Takes first occurrence — the safe value that passed rate limiting |
| nginx | "999" | Takes last occurrence — the attacker's injected value |
| Express (qs) | ["123", "999"] | Coerces to array — type confusion if handler expects string |
| Express (querystring) | "999" | Takes last occurrence |
| Python urllib | "123" | Takes first occurrence |
| Django QueryDict | ["123", "999"] | Provides .getlist() for all values; .get() returns last |
If your WAF validates reportId=123 (the safe value it sees) but your handler receives ["123", "999"] (the array), and your handler accesses args.reportId expecting a string, type coercion may produce "123,999" — which might SQL-inject into a query like WHERE id = '123,999'.
HPP in JSON body arguments
MCP tool calls are typically JSON-encoded, not query strings — but HPP-equivalent attacks still apply in JSON body parsing:
// Attacker sends:
POST /mcp HTTP/1.1
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "get_report",
"arguments": {
"reportId": "safe-value",
"reportId": "malicious-value"
}
}
}
JSON technically forbids duplicate keys, but many parsers are permissive. The result depends on the parser:
- Most JS JSON parsers: last value wins → handler sees
"malicious-value" - Python
json.loads: last value wins by default - Some Java parsers: first value wins → inconsistency if JS parser is used at another layer
- Custom streaming parsers: behavior varies unpredictably
If your schema validation happens before JSON parsing (e.g., at a gateway that inspects raw JSON), and your handler sees different values, validation is bypassed for the effective value.
Array coercion attacks
Express's default query string parser (qs) coerces repeated parameters to arrays. This creates type confusion when your handler expects a scalar:
// Request: ?orgId[]=org-abc&orgId[]=org-xyz
// Express receives: { orgId: ["org-abc", "org-xyz"] }
// Handler expecting a string:
const orgId = req.query.orgId as string;
// orgId = "org-abc,org-xyz" (Array.prototype.toString)
// Then used in a query:
const org = await db.orgs.findById(orgId);
// Effective lookup: WHERE id = 'org-abc,org-xyz'
// This might fail silently and return null (safe) or throw with DB details (leaks schema)
A more dangerous version: if the handler passes the array to a function expecting a string, and that function concatenates array elements into a SQL fragment without parameterization, the result is SQL injection.
Defenses
1. Explicit type assertion at the handler entry point
Use Zod (or equivalent) with explicit type coercion that rejects arrays where scalars are expected:
// src/tools/get-report.ts
import { z } from "zod";
const GetReportSchema = z.object({
// .string() rejects arrays — coercion to string is not permitted
reportId: z.string().uuid("reportId must be a valid UUID"),
orgId: z.string().uuid("orgId must be a valid UUID"),
});
server.tool("get_report", GetReportSchema, async (args) => {
// args.reportId is guaranteed to be a single valid UUID string
// An array input would have been caught by Zod before reaching here
});
Zod's z.string() does not accept arrays. If the parsed JSON delivers an array due to duplicate key handling, Zod will reject it with a type error before your handler sees it.
2. Normalize before validation
For query string arguments in HTTP MCP transports, normalize before parsing with your schema validator:
// src/middleware/normalize-query.ts
import type { Request, Response, NextFunction } from "express";
export function normalizeQueryParams(req: Request, _res: Response, next: NextFunction) {
// Convert any array values to their first element
// (if you expect scalars, "first wins" is the safe default)
for (const [key, value] of Object.entries(req.query)) {
if (Array.isArray(value)) {
req.query[key] = value[0]; // take first, log a warning
console.warn(`HPP detected: duplicate parameter '${key}' — using first value`);
}
}
next();
}
// Apply before all routes:
app.use(normalizeQueryParams);
Choosing "first wins" or "last wins" is less important than being consistent across all layers in your stack. If your gateway takes first and your application takes last, consistency at the application layer doesn't fix the gap. Document which behavior you choose and verify your gateway matches.
3. Reject duplicate JSON keys at parse time
// Use a JSON parser that rejects duplicate keys
import { parse } from "secure-json-parse"; // npm install secure-json-parse
app.use(express.json({
// Replace the default JSON.parse with one that throws on duplicate keys
reviver: undefined,
}));
// Or use a custom body parser:
app.use((req, _res, next) => {
let body = "";
req.on("data", chunk => { body += chunk; });
req.on("end", () => {
try {
req.body = parse(body, { protoAction: "error", constructorAction: "error" });
next();
} catch (e) {
next(new Error("Invalid JSON: duplicate keys or prototype pollution attempt"));
}
});
});
4. Consistent parameter parsing across stack layers
Document your chosen behavior and verify your API gateway, reverse proxy, and application layer all implement it identically. A simple integration test:
// scripts/test-hpp-consistency.ts
const testCases = [
{ url: "/mcp/call?reportId=safe&reportId=malicious", expected: "safe" },
{ url: "/mcp/call?reportId[]=arr1&reportId[]=arr2", expected: "error" },
];
for (const { url, expected } of testCases) {
const res = await fetch(`https://gateway.example.com${url}`);
const data = await res.json();
const actual = data.args?.reportId ?? "error";
if (actual !== expected) {
console.error(`HPP inconsistency: ${url} — expected ${expected}, got ${actual}`);
process.exit(1);
}
}
SkillAudit findings for HTTP parameter pollution
args.field as string but does not reject array coercion from query string parserHPP is a subtle finding that often only surfaces during manual code review of how arguments flow from the transport layer to the handler. Run a free SkillAudit — our static analysis traces argument paths from HTTP parsing through to handler logic and flags type coercion gaps.