Back to Blog
critical SEVERITY9 min read

How argument injection happens in Node.js Copilot tool bridge and how to fix it

A high-severity argument injection vulnerability was discovered in the Copilot tool bridge (`bridge.ts`) where user-controlled `request.args` were passed directly to `tool.execute()` without any validation or sanitization. The fix introduces Zod schema validation at line 108, ensuring that tool arguments are parsed against a declared `inputSchema` before execution. This prevents malformed or malicious payloads — including prototype pollution attempts — from reaching the underlying tool implement

O
By Orbis AppSec
Published July 4, 2026Reviewed July 4, 2026

Answer Summary

This is an argument injection vulnerability (CWE-20: Improper Input Validation) in the Node.js Copilot tool bridge (`bridge.ts`), where the `executeToolCall` function passed user-controlled `request.args` directly to `tool.execute()` without validation. The fix adds Zod schema validation: if a tool declares an `inputSchema` of type `z.ZodType`, arguments are now parsed through `tool.inputSchema.parse(request.args)` before execution, rejecting malformed or malicious payloads at the boundary.

Vulnerability at a Glance

cweCWE-20
fixValidate `request.args` against `tool.inputSchema` using Zod's `parse()` before passing to `tool.execute()`
riskAttackers can craft malicious tool arguments to inject payloads, pollute prototypes, or bypass tool-level input assumptions
languageTypeScript (Node.js)
root cause`executeToolCall` passed `request.args` directly to `tool.execute()` without schema validation or sanitization
vulnerabilityArgument Injection / Improper Input Validation

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 the executeToolCall function passed user-controlled request.args directly to tool.execute() without any validation. The fix adds Zod schema validation: if a tool declares an inputSchema of type z.ZodType, arguments are now parsed through tool.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:

  1. If the tool declares an inputSchema of type z.ZodType: the incoming request.args is passed through tool.inputSchema.parse(). Zod's parse() is strict — it throws a ZodError if 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 reach tool.execute().

  2. If no Zod schema is declared: the raw request.args is 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

  • executeToolCall in bridge.ts was 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 runtimerequest.args is 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.prototype survives adversarial payloads, which is the right way to encode this invariant.
  • Tools without a declared inputSchema silently 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 executeToolCall could 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.args field of a LlmToolCallbackRequest object arriving at the Copilot tool bridge
  • Sink: tool.execute(request.args, options) at line 106 of packages/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.args assignment and the tool.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.


References

Frequently Asked Questions

What is argument injection in a Node.js tool bridge?

Argument injection occurs when user-controlled data is passed directly to a function or tool without validation, allowing attackers to supply unexpected values that exploit assumptions in the receiving code.

How do you prevent argument injection in TypeScript/Node.js?

Declare an explicit input schema (e.g., using Zod) for every callable tool and validate all incoming arguments against that schema before execution. Reject or sanitize any input that fails validation.

What CWE is argument injection?

Argument injection is classified under CWE-20 (Improper Input Validation), and in some contexts CWE-88 (Argument Injection or Modification).

Is type checking in TypeScript enough to prevent argument injection?

No. TypeScript types are erased at runtime, so they provide no protection against malicious data arriving over the network or from an LLM response. Runtime validation with a library like Zod is required.

Can static analysis detect argument injection like this?

Yes. Tools like Semgrep can identify patterns where external data flows into sensitive function calls without an intervening validation step, which is exactly how Orbis AppSec detected this issue.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #15172

Related Articles

high

How CORS credential reflection happens in Hono middleware and how to fix it

A high-severity CORS misconfiguration in Hono's middleware (CVE-2026-54290) allowed any origin to be reflected with credentials when the `origin` option defaulted to wildcard. This vulnerability in the studio frontend could enable attackers to steal authenticated user data through cross-origin requests. The fix upgrades Hono from 4.12.21 to 4.12.25, which properly handles CORS origin validation.

high

How integer overflow in malloc happens in C libregexp and how to fix it

A high-severity integer overflow vulnerability was discovered in QuickJS's libregexp.c where multiplication to compute allocation size could wrap around, causing a heap overflow. The fix replaces the unsafe `malloc(sizeof(capture[0]) * lre_get_alloc_count(bc))` pattern with `calloc(lre_get_alloc_count(bc), sizeof(capture[0]))`, which safely handles the multiplication internally and prevents exploitation.

critical

How buffer overflow via sprintf() happens in C++ settings parsing and how to fix it

A critical buffer overflow vulnerability was discovered in `app/src/main/cpp/samp/settings.cpp` where `sprintf()` writes to a fixed 127-byte buffer (`char buff[0x7F]`) without bounds checking. If the `g_pszStorage` global variable contains a string longer than ~107 bytes, the formatted output exceeds the buffer, enabling stack corruption. The fix replaces `sprintf()` with `snprintf()` using `sizeof(buff)` to guarantee writes never exceed the declared buffer length.

medium

How integer overflow in bounds checking happens in C and how to fix it

A critical integer overflow vulnerability was discovered in the W_Read function of DOOM/w_file.c that allowed attackers to bypass bounds checking by crafting WAD files with malicious offset values near UINT_MAX. The fix implements a two-step validation approach that first checks if the offset exceeds the file length, then safely calculates the remaining bytes without risk of overflow.

critical

How buffer overflow in strcat() happens in C and how to fix it

A critical buffer overflow vulnerability was discovered in the `daemonize()` function of `tpl.c`, where command-line arguments are concatenated into a fixed-size 8192-byte buffer using `strcat()` without any bounds checking. An attacker who controls command-line arguments can overflow this buffer to corrupt adjacent memory and potentially achieve arbitrary code execution. The fix adds a buffer-length check before each concatenation to ensure writes never exceed the declared buffer size.

critical

How command injection happens in Node.js subprocess and how to fix it

A critical command injection vulnerability in `tools/dev/src/index.ts` allowed attackers to execute arbitrary shell commands through unsanitized subprocess arguments. The fix was simple but essential: explicitly setting `shell: false` in the `spawn()` call to prevent shell metacharacter interpretation. This vulnerability demonstrates why subprocess handling requires explicit security controls in Node.js.