How Argument Injection Happens in Node.js Copilot Tool Bridge and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Argument Injection / Improper Input Validation |
| CWE | CWE-20 |
| Language | TypeScript (Node.js) |
| Risk | Malicious tool arguments bypass tool-level assumptions |
| Root Cause | request.args passed to tool.execute() without schema validation |
| Fix | Zod schema validation before execution |
Quick Answer
What is this vulnerability and how do you fix it?
This is an argument injection vulnerability (CWE-20: Improper Input Validation) in the Node.js Copilot tool bridge (
bridge.ts), where theexecuteToolCallfunction passed user-controlledrequest.argsdirectly totool.execute()without any validation. The fix adds Zod schema validation: if a tool declares aninputSchemaof typez.ZodType, arguments are now parsed throughtool.inputSchema.parse(request.args)before execution, rejecting malformed or malicious payloads at the security boundary.
Introduction
The file packages/backend/server/src/plugins/copilot/runtime/tool/bridge.ts acts as the execution layer between an LLM's tool-call responses and the actual tool implementations in the Copilot runtime. It receives a structured request from the language model — including a tool name and a set of arguments — and routes that call to the appropriate handler. This is a critical trust boundary: data arriving here originated from an LLM response, which can be influenced by user prompts.
A flaw in the executeToolCall function at line 106 meant that request.args — the argument payload constructed from an LLM tool-call response — was handed directly to tool.execute() with no validation, sanitization, or type checking whatsoever. Any attacker who could influence the arguments passed through the Copilot interface could exploit this gap.
The Vulnerability Explained
What the Code Was Doing
Before the fix, the core execution logic in executeToolCall looked like this:
// VULNERABLE — bridge.ts (before fix), around line 106
const output = await tool.execute(request.args, options);
That's it. request.args — a raw object parsed from the LLM's tool-call response — goes straight into tool.execute(). There is no check that the arguments match the tool's expected schema, no sanitization, and no rejection of unexpected keys.
Why This Is Dangerous
Tools in the Copilot runtime are designed to accept specific, well-shaped arguments. When executeToolCall acts as the bridge between the LLM and those tools, it becomes the natural place to enforce that contract. Without that enforcement, the bridge becomes a pass-through for arbitrary data.
Consider what an attacker can do if they can influence the LLM's tool-call arguments — for example, through a prompt injection attack or by directly accessing the Copilot tool interface:
Prototype Pollution: An attacker could craft an argument payload like:
{ "__proto__": { "polluted": "yes" } }
If JSON.parse is used upstream and the resulting object is passed through without sanitization, properties can be injected onto Object.prototype, affecting every object in the Node.js process. The regression test in the PR explicitly guards against this:
{
description: 'prototype pollution payload',
request: {
callId: '1',
name: 'safeTool',
args: JSON.parse('{"__proto__":{"polluted":"yes"}}'),
rawArgumentsText: '{"__proto__":{"polluted":"yes"}}',
argumentParseError: null,
},
}
Type Confusion: A tool expecting { userId: string } might receive { userId: { toString: "injected" } }. If the tool doesn't defensively validate its inputs, this can cause unexpected behavior, crashes, or logic bypasses.
Deep Nesting / Denial of Service: Excessively nested objects (also tested in the regression suite) can cause stack overflows or extreme memory consumption in tools that recursively process their arguments.
Real-World Impact for This Application
The Copilot tool bridge is a production component that executes real operations — file access, API calls, data lookups — based on LLM-generated tool calls. A vulnerability here means that a carefully crafted prompt could cause the system to execute tools with arguments that the tool authors never anticipated, potentially leading to:
- Data exfiltration through tools that access user data
- Server-side logic bypass in tools that enforce authorization through argument values
- Process-wide prototype pollution affecting unrelated application logic
The Fix
What Changed
The fix introduces Zod schema validation as a gate between request.args and tool.execute(). Here is the exact before/after from the diff:
Before:
// bridge.ts — VULNERABLE
const output = await tool.execute(request.args, options);
After:
// bridge.ts — FIXED (lines 108-112)
import { z } from 'zod';
// ...
const args =
tool.inputSchema instanceof z.ZodType
? tool.inputSchema.parse(request.args)
: request.args;
const output = await tool.execute(args, options);
How This Solves the Problem
The fix introduces a conditional validation step:
-
If the tool declares an
inputSchemaof typez.ZodType: the incomingrequest.argsis passed throughtool.inputSchema.parse(). Zod'sparse()is strict — it throws aZodErrorif the data doesn't conform to the schema, and it strips unrecognized keys by default (depending on schema configuration). This means prototype pollution payloads and unexpected fields are rejected or removed before they ever reachtool.execute(). -
If no Zod schema is declared: the raw
request.argsis passed through unchanged. This maintains backward compatibility with tools that haven't yet adopted Zod schemas, while providing a clear migration path.
The check tool.inputSchema instanceof z.ZodType is a clean runtime guard — it works because Zod schemas are instances of the ZodType class at runtime, even though TypeScript types are erased.
Additional Changes: Exporting for Testability
The PR also exports executeToolCall (previously unexported) and re-exports the relevant types:
// Previously private — now exported for test consumers
export async function executeToolCall(...)
// Re-exports added at bottom of bridge.ts
export type { LlmToolCallbackRequest } from '../../../../native';
export type { CopilotToolSet, CopilotToolExecuteOptions } from '../../tools';
This is a meaningful change beyond testability: making executeToolCall a named export means it can be unit-tested in isolation, which is exactly what the new regression test (tests/invariant_bridge.test.ts) does. Security-critical functions should always be independently testable.
The Regression Test
The PR adds a comprehensive test file (tests/invariant_bridge.test.ts) that covers three adversarial scenarios:
const payloads = [
{
description: 'prototype pollution payload',
args: JSON.parse('{"__proto__":{"polluted":"yes"}}'),
},
{
description: 'excessive nested object',
args: { a: { b: { c: { d: { e: { f: { g: 'deep' } } } } } } },
},
{
description: 'valid minimal input',
args: {},
},
];
Each test verifies that after executeToolCall runs, Object.prototype has not been corrupted — a direct check for prototype pollution. This test is valuable independent of the code change: it encodes the security invariant that the execution context must survive adversarial input uncorrupted.
Prevention & Best Practices
1. Validate at Every Trust Boundary
The LLM tool-call interface is a trust boundary. Data from an LLM response should be treated with the same skepticism as data from an HTTP request body. Always validate before acting.
2. Use Schema Validation Libraries at Runtime
TypeScript's type system is compile-time only. For runtime safety, use a schema validation library:
- Zod (used here) — excellent TypeScript integration, strict by default
- io-ts — functional-style runtime types
- Ajv — JSON Schema validation, very fast
3. Declare inputSchema on Every Tool
Now that executeToolCall will validate against tool.inputSchema when present, tool authors should treat declaring an inputSchema as mandatory, not optional. A tool without a schema silently bypasses validation.
4. Protect Against Prototype Pollution Explicitly
When dealing with user-controlled JSON, consider using Object.create(null) for intermediate objects, or use a library like safe-flat when flattening nested structures. Zod's parse() helps here because it constructs a new object from the schema definition rather than copying properties from the input.
5. Apply Defense in Depth
The fix at the bridge layer is the right place to add validation, but individual tools should also validate their own inputs. Defense in depth means a bug in one layer doesn't immediately compromise the system.
Relevant Standards
- OWASP Input Validation Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
- CWE-20: Improper Input Validation — https://cwe.mitre.org/data/definitions/20.html
- CWE-88: Argument Injection or Modification — https://cwe.mitre.org/data/definitions/88.html
Key Takeaways
executeToolCallinbridge.tswas the right place to add validation — it is the single chokepoint between LLM-generated arguments and all tool implementations. One fix protects every tool.- TypeScript types don't protect you at runtime —
request.argsis typed, but that type is meaningless when the data arrives from a network-originated LLM response. Always validate with a runtime library like Zod. - Prototype pollution is a real risk when passing parsed JSON to functions — the regression test explicitly checks that
Object.prototypesurvives adversarial payloads, which is the right way to encode this invariant. - Tools without a declared
inputSchemasilently bypass the new validation — teams using this codebase should audit all tool definitions and add Zod schemas to ensure they benefit from the fix. - Making security-critical functions exportable and testable is itself a security improvement — the previous private
executeToolCallcould not be unit-tested in isolation, which is why this class of bug persisted.
How Orbis AppSec Detected This
- Source: User-controlled LLM tool-call response, specifically the
request.argsfield of aLlmToolCallbackRequestobject arriving at the Copilot tool bridge - Sink:
tool.execute(request.args, options)at line 106 ofpackages/backend/server/src/plugins/copilot/runtime/tool/bridge.ts— a direct pass-through of unvalidated external data to a tool execution function - Missing control: No schema validation, type checking, or sanitization between the
request.argsassignment and thetool.execute()call - CWE: CWE-20 (Improper Input Validation), with elements of CWE-88 (Argument Injection or Modification)
- Fix: Added a Zod schema validation gate —
tool.inputSchema instanceof z.ZodType ? tool.inputSchema.parse(request.args) : request.args— so that arguments are validated against the tool's declared schema before execution
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
The argument injection vulnerability in bridge.ts is a textbook example of a trust boundary failure: data from an external, potentially adversarial source (an LLM tool-call response) was treated as safe and passed directly to production tool implementations. The fix is elegant — a single conditional Zod validation step in executeToolCall that validates arguments against each tool's declared schema before execution.
The broader lesson is that LLM-integrated systems introduce new trust boundaries that developers may not immediately recognize. An LLM's tool-call arguments are as untrusted as a form submission or an API request body. Apply the same rigor: validate at the boundary, use runtime schema libraries, and encode your security invariants as tests that will catch regressions.