Skip to content

Commit 8c42b04

Browse files
committed
fix(webapp): strip client-supplied webhook actionSource on session input
Only the hosted webhook ingress may mark an action as webhook-sourced, which skips action-schema validation in the run loop. The session .in append route now strips a client-supplied actionSource: "webhook" from incoming records, so a caller with session write access cannot claim webhook trust for an unvalidated action.
1 parent bcaaf30 commit 8c42b04

3 files changed

Lines changed: 66 additions & 1 deletion

File tree

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resolveSessionWithWriterFallback,
1111
} from "~/services/realtime/sessions.server";
1212
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
13+
import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server";
1314
import {
1415
claimSessionStreamPart,
1516
drainSessionStreamWaitpoints,
@@ -137,7 +138,11 @@ const { action, loader } = createActionApiRoute(
137138

138139
const addressingKey = canonicalSessionAddressingKey(session, params.session);
139140

140-
const part = await request.text();
141+
let part = await request.text();
142+
if (params.io === "in") {
143+
part = stripClientWebhookActionSource(part);
144+
}
145+
141146
const clientPartId = request.headers.get("X-Part-Id");
142147
const partId = clientPartId ?? nanoid(7);
143148

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Strip a client-forged `actionSource: "webhook"` from a session `.in` append part.
3+
*
4+
* Only the hosted webhook ingress may claim webhook trust, and it appends server-side rather than
5+
* through the client append route. A client with session write access could otherwise send a record
6+
* carrying `actionSource: "webhook"`, which the run loop uses to skip action-schema validation. We
7+
* downgrade it here (delete the field) so the record is validated as a normal client action.
8+
*/
9+
export function stripClientWebhookActionSource(part: string): string {
10+
if (!part.includes('"actionSource"')) return part;
11+
12+
let record: { payload?: { actionSource?: string } } | undefined;
13+
try {
14+
record = JSON.parse(part) as { payload?: { actionSource?: string } };
15+
} catch {
16+
return part;
17+
}
18+
19+
if (record?.payload?.actionSource === "webhook") {
20+
delete record.payload.actionSource;
21+
return JSON.stringify(record);
22+
}
23+
24+
return part;
25+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vitest";
2+
import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server";
3+
4+
const record = (payload: Record<string, unknown>) => JSON.stringify({ kind: "message", payload });
5+
6+
describe("stripClientWebhookActionSource", () => {
7+
it("removes a client-forged webhook actionSource so the action is validated normally", () => {
8+
const forged = record({
9+
chatId: "c1",
10+
trigger: "action",
11+
actionSource: "webhook",
12+
action: { type: "refund", amount: 9999 },
13+
});
14+
15+
const cleaned = JSON.parse(stripClientWebhookActionSource(forged));
16+
expect(cleaned.payload.actionSource).toBeUndefined();
17+
expect(cleaned.payload.action).toEqual({ type: "refund", amount: 9999 });
18+
expect(cleaned.payload.trigger).toBe("action");
19+
});
20+
21+
it("leaves a non-webhook actionSource untouched", () => {
22+
const part = record({ trigger: "action", actionSource: "client", action: { type: "ping" } });
23+
expect(stripClientWebhookActionSource(part)).toBe(part);
24+
});
25+
26+
it("leaves a normal message part untouched (fast path, no parse)", () => {
27+
const part = record({ trigger: "submit-message", message: { role: "user", parts: [] } });
28+
expect(stripClientWebhookActionSource(part)).toBe(part);
29+
});
30+
31+
it("leaves a malformed part untouched", () => {
32+
const part = '{"kind":"message","payload":{"actionSource":"webhook"';
33+
expect(stripClientWebhookActionSource(part)).toBe(part);
34+
});
35+
});

0 commit comments

Comments
 (0)