Back to Blog
critical SEVERITY6 min read

How Insufficient Input Validation happens in TypeScript and how to fix it

A critical input validation vulnerability was discovered in `src/mcp/presets/commerce/inputs.ts` where the `normalizeCommerceAccountInput` function accepted loosely-typed `Record<string, any>` arguments without verifying field types or object structure. This allowed attackers to inject malicious payloads through MCP tool invocations. The fix adds explicit type guards and structural validation to ensure only properly-typed string values reach downstream consumers.

O
By Orbis AppSec
Published August 15, 2026Reviewed August 15, 2026

Answer Summary

This is an Insufficient Input Validation vulnerability (CWE-20) in a TypeScript Node.js library where the `normalizeCommerceAccountInput` function accepted `Record<string, any>` without type checking individual fields. Attackers could pass non-string values (objects, arrays, or prototype-polluting payloads) through MCP tool endpoints. The fix adds explicit `typeof` checks for each field and validates the `platform` argument is a plain object before accessing its properties.

Vulnerability at a Glance

cweCWE-20 (Improper Input Validation)
fixAdded structural type guard (`typeof p === 'object' && !Array.isArray(p)`) and per-field `typeof` string checks
riskAttackers can inject malicious objects, trigger prototype pollution, or pass unexpected types through MCP commerce endpoints
languageTypeScript / Node.js
root cause`normalizeCommerceAccountInput` trusts `args.platform` without verifying it is a plain object or that its properties are strings
vulnerabilityInsufficient Input Validation / Improper Input Neutralization

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:

  1. 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 pass platform: "__proto__" or platform: [malicious_array] and the code would attempt to read properties from it.

  2. 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.

  3. Prototype pollution vector: Without checking !Array.isArray(p) and typeof 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. The normalizeCommerceAccountInput function 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 typeof checks 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 to normalizeCommerceAccountInput
  • Sink: Direct property access on args.platform.url, args.platform.username, etc. at src/mcp/presets/commerce/inputs.ts:16 without type validation
  • Missing control: No runtime type guards on the platform object 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-field typeof === '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.

References

Frequently Asked Questions

What is Insufficient Input Validation?

Insufficient Input Validation occurs when a function accepts user-controlled data without verifying its type, structure, or content, allowing attackers to supply unexpected values that alter program behavior or exploit downstream logic.

How do you prevent Insufficient Input Validation in TypeScript?

Use runtime type guards (typeof checks), schema validation libraries like Zod or io-ts, and avoid relying on TypeScript's compile-time types alone since they are erased at runtime and cannot protect against malicious input.

What CWE is Insufficient Input Validation?

CWE-20 (Improper Input Validation) covers cases where software does not validate or incorrectly validates input that can affect control flow or data flow.

Is TypeScript's type system enough to prevent input validation vulnerabilities?

No. TypeScript types are erased at compile time and provide no runtime protection. Any data crossing a trust boundary (API calls, MCP tool invocations, user input) must be validated at runtime with explicit checks or schema validators.

Can static analysis detect Insufficient Input Validation?

Yes. Tools like Semgrep, ESLint security plugins, and specialized AI scanners can flag functions accepting `Record<string, any>` or `any` types at trust boundaries where user-controlled data enters the system.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7

Related Articles

high

How Command Injection happens in Node.js child_process calls and how to fix it

A high-severity command injection vulnerability was discovered in `src/collectors/git.ts`, where `execSync` was used to build a shell command by interpolating unsanitized arguments into a template string. By replacing `execSync` with `spawnSync`, the fix eliminates shell interpretation entirely, ensuring that git arguments are passed directly to the process without ever touching a shell. This change is especially important for a Node.js library, where downstream consumers may pass user-controlle

critical

How Path Traversal Vulnerabilities Happen in Node.js Development Servers and How to Fix Them

A critical path traversal vulnerability was discovered in the development file server script `serve.mjs`, where arbitrary directory paths from command-line arguments were accepted without validation. This flaw could allow attackers to serve any directory on the filesystem over HTTP, potentially exposing sensitive system files like `/etc/passwd` or application secrets. The fix adds a simple but effective validation check ensuring the serve root stays within the current working directory.

critical

How Plaintext Credential Storage happens in JSON Configuration Files and how to fix it

A critical security issue was discovered in `assets/settings/global.json` where a real phone number (PII) was stored in plaintext alongside placeholder patterns for API keys and payment credentials. This design encouraged developers to substitute real credentials directly into a version-controlled file, creating a high risk of credential exposure via repository access or filesystem reads. The fix replaces the hardcoded phone number with a placeholder and reinforces safe configuration patterns.

high

How Quadratic CPU Consumption Vulnerabilities Happen in JavaScript YAML Parsers and How to Fix Them

A high-severity denial-of-service vulnerability in js-yaml versions 3.x and 4.x allowed attackers to trigger quadratic CPU consumption through specially crafted YAML documents using the !!omap tag. This fix upgrades js-yaml from 4.1.1 to 4.3.1 and from 3.14.2 to 3.15.1, eliminating the algorithmic complexity attack vector that could freeze Node.js applications processing untrusted YAML input.

high

How javascript.lang.security.detect-child-process.detect-child-process happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `scripts/build.js` where `execSync` was called with string-interpolated arguments (`sourceDir` and `outputPath`) inside a shell command. By replacing `execSync` with `spawnSync` using an argument array (no shell), the fix eliminates the possibility of shell metacharacter injection while preserving identical build behavior.

critical

How Supply Chain Timing Attacks happen in pnpm Workspaces and how to fix it

The apple-mail-mcp repository was vulnerable to supply chain timing attacks because its pnpm workspace configuration only enforced a 1-day (1440 minute) minimum release age for newly published packages. This allowed a 5-day-old transitive dependency (ip-address@10.5.0) to be installed despite Dependabot's 7-day cooldown, creating a window where malicious or unstable packages could enter the dependency tree. The fix raises minimumReleaseAge to 10080 minutes (7 days) to ensure all packages—includi