Introduction
The file src/mcp/presets/commerce/inputs.ts is responsible for normalizing raw arguments from MCP (Model Context Protocol) tool invocations into typed commerce account structures. At line 16, the normalizeCommerceAccountInput function accepted a Record<string, any> argument and immediately accessed nested properties of args.platform without any structural or type validation. This meant any caller—including untrusted external input from MCP endpoints—could pass arbitrary objects, arrays, or prototype-polluting payloads directly into the platform account fields.
Because this is a Node.js library consumed by downstream packages, the vulnerability extends beyond the immediate codebase. Every application that invokes normalizeCommerceAccountInput with user-controlled data inherits this weakness.
The Vulnerability Explained
The Dangerous Pattern
Here's the vulnerable code before the fix:
export function normalizeCommerceAccountInput(args: Record<string, any>): CommerceAccountInput {
const platformAccounts = args.platform
? [
{
platformUrl: args.platform.url,
username: args.platform.username,
password: args.platform.password,
twoFactorKey: args.platform.twoFactorKey,
remarks: args.platform.remarks,
},
]
: undefined;
The problems are layered:
-
No structural check on
args.platform: The code uses a simple truthy check (args.platform). Any truthy value—an array, a function, a string—passes this guard. An attacker could passplatform: "__proto__"orplatform: [malicious_array]and the code would attempt to read properties from it. -
No type validation on nested fields:
args.platform.url,args.platform.username, etc. are assigned directly without verifying they are strings. An attacker could supply{ url: { toString: () => "http://evil.com" } }or inject objects that exploit downstream serialization. -
Prototype pollution vector: Without checking
!Array.isArray(p)andtypeof p === 'object', an attacker could craft payloads that exploit JavaScript's prototype chain when the resulting object is spread or merged downstream.
Exploitation Scenario
Consider an attacker invoking an MCP tool that calls normalizeCommerceAccountInput:
{
"platform": {
"url": {"__proto__": {"isAdmin": true}},
"username": ["injected", "array"],
"password": {"toString": "() => process.env.SECRET"},
"remarks": 12345
}
}
Because the original code performs no type checking:
- platformUrl receives an object instead of a string, potentially causing prototype pollution when merged with other objects
- username receives an array, which could break downstream logic expecting .trim() or .toLowerCase()
- password receives an object with a crafted toString, which could leak secrets if coerced to string in logging
- remarks receives a number, bypassing any string-length or pattern validation downstream
In a production environment where MCP tools are exposed over HTTP or WebSocket, this is directly exploitable by any client that can invoke the commerce account tool.
The Fix
The fix introduces two layers of defense:
1. Structural Validation of args.platform
const p = args.platform;
const platformAccounts = p != null && typeof p === 'object' && !Array.isArray(p)
This ensures p is:
- Not null or undefined (p != null)
- Actually an object (typeof p === 'object')
- Not an array (!Array.isArray(p))
This eliminates the entire class of attacks where platform is a string, number, array, or null.
2. Per-Field Type Guards
{
platformUrl: typeof p.url === 'string' ? p.url : undefined,
username: typeof p.username === 'string' ? p.username : undefined,
password: typeof p.password === 'string' ? p.password : undefined,
twoFactorKey: typeof p.twoFactorKey === 'string' ? p.twoFactorKey : undefined,
remarks: typeof p.remarks === 'string' ? p.remarks : undefined,
}
Each field is individually validated. If an attacker supplies a non-string value for any field, it falls back to undefined rather than propagating the malicious value. This is a defense-in-depth approach: even if the structural check were somehow bypassed, individual fields are still protected.
Before vs. After
| Aspect | Before | After |
|---|---|---|
| Platform check | Truthy check only | typeof === 'object' + !Array.isArray |
| Field validation | None | typeof === 'string' per field |
| Array input | Passes through | Rejected (returns undefined) |
| Object injection | Passes through | Rejected (returns undefined) |
| Valid input behavior | Unchanged | Unchanged |
Prevention & Best Practices
1. Never Trust Record<string, any> at Trust Boundaries
When a function accepts data from external sources (API endpoints, MCP tools, WebSocket messages), always validate at runtime:
// Use a schema validator like Zod
import { z } from 'zod';
const PlatformSchema = z.object({
url: z.string().url().optional(),
username: z.string().optional(),
password: z.string().optional(),
twoFactorKey: z.string().optional(),
remarks: z.string().optional(),
});
2. Apply Structural Guards Before Property Access
Always verify an object is actually an object before accessing its properties:
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
// Safe to access properties
}
3. Use structuredClone() for Deep Copies
The E2E test script already uses structuredClone() to prevent prototype pollution—apply this pattern in production code as well when handling untrusted input.
4. Lint Rules and Static Analysis
Configure ESLint with @typescript-eslint/no-unsafe-member-access and @typescript-eslint/no-unsafe-assignment to flag unvalidated property access on any-typed values.
Key Takeaways
Record<string, any>is not a validation mechanism—it's a type annotation that vanishes at runtime. ThenormalizeCommerceAccountInputfunction needed runtime guards, not just TypeScript types.- Truthy checks (
if (args.platform)) are insufficient structural validation—strings, numbers, and arrays are all truthy and would pass through to property access. - Per-field
typeofchecks provide defense-in-depth—even when the outer structural guard is correct, validating each field independently prevents partial injection attacks. - MCP tool inputs are untrusted by default—any function exposed through MCP endpoints must treat its arguments as potentially malicious, regardless of what TypeScript interfaces suggest.
- The fix preserves valid behavior—legitimate callers passing proper string fields see no change, demonstrating that security hardening doesn't require breaking changes.
How Orbis AppSec Detected This
- Source: Untrusted input entering via MCP tool invocations as
Record<string, any>arguments tonormalizeCommerceAccountInput - Sink: Direct property access on
args.platform.url,args.platform.username, etc. atsrc/mcp/presets/commerce/inputs.ts:16without type validation - Missing control: No runtime type guards on the
platformobject structure or its individual string fields; no schema validation library applied - CWE: CWE-20 (Improper Input Validation)
- Fix: Added structural type guard (
typeof p === 'object' && !Array.isArray(p)) and per-fieldtypeof === 'string'checks to reject non-string values
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability demonstrates a common but dangerous pattern in TypeScript applications: relying on compile-time types to protect runtime behavior. The normalizeCommerceAccountInput function's Record<string, any> parameter type provided zero runtime protection against malicious payloads. By adding explicit structural validation and per-field type guards, the fix ensures that only properly-typed data reaches downstream consumers—without breaking any existing valid usage.
For any function that sits at a trust boundary (API handlers, MCP tools, webhook processors), always validate at runtime. TypeScript types are documentation for developers; they are not security controls.