From 5d6110cb2b50a4d89edde63010f692b5bb89878f Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Tue, 15 Sep 2026 14:27:23 +0530 Subject: [PATCH] Refuse non-object tool args on POST /api/plugins/call and /components/:name/call with 400 args flowed into Object.entries inside the store, where a string fans out into indexed entries, a number yields none, and an array passes as an object, surfacing as a vendor 502 or data error instead of a malformed-call 400. Only plain JSON objects are admissible now, before any grant check, vendor call, or audit row. --- server/src/components/routes.ts | 14 +++ server/src/plugins/routes.ts | 12 ++ .../tests/plugin-call-args-validation.test.ts | 112 ++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 server/tests/plugin-call-args-validation.test.ts diff --git a/server/src/components/routes.ts b/server/src/components/routes.ts index 404e1bf9f..66e5da270 100644 --- a/server/src/components/routes.ts +++ b/server/src/components/routes.ts @@ -284,6 +284,20 @@ export function createComponentRoutes( 400, ); } + // A string `args` would reach `fn.run` and fail as a 502 data error instead of a malformed + // call. Arrays and prototype-polluted objects are refused for the same reason. + if ( + body?.args !== undefined && + (typeof body.args !== "object" || + body.args === null || + Array.isArray(body.args) || + Object.getPrototypeOf(body.args) !== Object.prototype) + ) { + return context.json( + { error: "Function arguments must be an object." }, + 400, + ); + } // Before the grant, and before anything runs. This is the route that executes, so borrowing a // Bot here borrows whatever its components were granted. if (!(await canUseBot(context.var.actor, agentId))) { diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index f02f52279..04fa3533f 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -895,6 +895,18 @@ export function createPluginRoutes( ) { return context.json({ error: "A tool and a Bot are required." }, 400); } + // `args` reaches `Object.entries` inside the store, where a string fans out into indexed + // entries, a number becomes no entries, and an array passes as an object — all escaping as + // a vendor 502 instead of a 400 for a malformed call. + if ( + body.args !== undefined && + (typeof body.args !== "object" || + body.args === null || + Array.isArray(body.args) || + Object.getPrototypeOf(body.args) !== Object.prototype) + ) { + return context.json({ error: "Tool arguments must be an object." }, 400); + } // Asked before the grant is looked up, and before anything reaches a vendor. The grant says this // Bot may use the tool; it says nothing about whether this person may act as this Bot, and the diff --git a/server/tests/plugin-call-args-validation.test.ts b/server/tests/plugin-call-args-validation.test.ts new file mode 100644 index 000000000..607d59257 --- /dev/null +++ b/server/tests/plugin-call-args-validation.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import type { BotAccessCheck } from "../src/plugins/routes"; +import { createPluginRoutes } from "../src/plugins/routes"; +import type { PluginStore } from "../src/plugins/store"; + +const requireUser: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, +) => { + context.set("actor", { + id: "user-1", + email: "user@openbot.test", + role: "admin", + }); + await next(); +}; +const canUseBot: BotAccessCheck = async () => true; + +/** + * `args` used to flow into `Object.entries` inside the store, where a string fans out into + * indexed entries, a number yields none, and an array passes as an object — surfacing as a + * vendor 502 instead of a malformed-call 400. Only a plain JSON object is admissible now. + */ +describe("POST /api/plugins/call args", () => { + function appWith(calls: unknown[]) { + const store = { + callTool: async (input: unknown) => { + calls.push(input); + return { ok: true }; + }, + } as unknown as PluginStore; + return createPluginRoutes(store, requireUser, canUseBot); + } + + test.each([ + ["a string", "oops"], + ["a number", 42], + ["an array", [1, 2]], + ["null", null], + ])("refuses %s with 400 and never reaches the store", async (_n, args) => { + const calls: unknown[] = []; + const body = { ref: "s/t", agentId: "bot-1", args }; + const response = await appWith(calls).request("http://openbot.test/call", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "Tool arguments must be an object.", + }); + expect(calls).toEqual([]); + }); + + test("accepts a plain object and omits args when absent", async () => { + const calls: unknown[] = []; + const app = appWith(calls); + for (const body of [ + { ref: "s/t", agentId: "bot-1", args: { q: "hi" } }, + { ref: "s/t", agentId: "bot-1" }, + ]) { + const response = await app.request("http://openbot.test/call", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(200); + } + expect(calls).toHaveLength(2); + }); +}); + +describe("POST /api/components/:name/call args", () => { + test("refuses non-object args with 400 before any grant check", async () => { + // Import the route module lazily so this test stays decoupled from the component store. + const { createComponentRoutes } = await import("../src/components/routes"); + const store = { + decide: async () => { + throw new Error("must not reach the store"); + }, + mayCall: async () => true, + callFunction: async () => ({}), + }; + const app = new Hono<{ Variables: AppVariables }>(); + app.use(requireUser); + app.route( + "/", + createComponentRoutes( + store as never, + requireUser, + undefined, + async () => true, + ), + ); + + for (const args of ["oops", 42, [1]]) { + const response = await app.request("http://openbot.test/widget/call", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ function: "f", agentId: "bot-1", args }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "Function arguments must be an object.", + }); + } + }); +});