Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions server/src/components/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down
12 changes: 12 additions & 0 deletions server/src/plugins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2441,6 +2441,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
Expand Down
112 changes: 112 additions & 0 deletions server/tests/plugin-call-args-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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.",
});
}
});
});