From c7134e629c917c120c79f6988b78069fda244318 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:32:55 -0500 Subject: [PATCH] LINE: harden Express webhook parsing to verified raw body (#51202) * LINE: enforce signed-raw webhook parsing * LINE: narrow scope and add buffer regression * changelog Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --------- Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/line.md | 1 + src/line/webhook.test.ts | 86 ++++++++++++++++++++++++++++++++++++++++ src/line/webhook.ts | 8 ++-- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13939729cd9..15fe8b08613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,7 @@ Docs: https://docs.openclaw.ai - Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant. - Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant. - Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant. +- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant. - xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek ### Fixes diff --git a/docs/channels/line.md b/docs/channels/line.md index a965dc6e991..079025e10ac 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -51,6 +51,7 @@ If you need a custom path, set `channels.line.webhookPath` or Security note: - LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification. +- OpenClaw processes webhook events from the verified raw request bytes. Upstream middleware-transformed `req.body` values are ignored for signature-integrity safety. ## Configure diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts index 9b3b9c0539a..5c38c58f3ce 100644 --- a/src/line/webhook.test.ts +++ b/src/line/webhook.test.ts @@ -138,6 +138,92 @@ describe("createLineWebhookMiddleware", () => { expect(onEvents).not.toHaveBeenCalled(); }); + it("uses the signed raw body instead of a pre-parsed req.body object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBody = JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-user" } }], + }); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBody, SECRET) }, + rawBody, + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-user"); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); + }); + + it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBodyText = JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-buffer-user" } }], + }); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBodyText, SECRET) }, + rawBody: Buffer.from(rawBodyText, "utf-8"), + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-buffer-user"); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); + }); + + it("rejects invalid signed raw JSON even when req.body is a valid object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBody = "not-json"; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBody, SECRET) }, + rawBody, + body: { events: [{ type: "message" }] }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" }); + expect(onEvents).not.toHaveBeenCalled(); + }); + it("returns 500 when event processing fails and does not acknowledge with 200", async () => { const onEvents = vi.fn(async () => { throw new Error("boom"); diff --git a/src/line/webhook.ts b/src/line/webhook.ts index 99c338db2f9..879972d0490 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -23,10 +23,7 @@ function readRawBody(req: Request): string | null { return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody; } -function parseWebhookBody(req: Request, rawBody?: string | null): WebhookRequestBody | null { - if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) { - return req.body as WebhookRequestBody; - } +function parseWebhookBody(rawBody?: string | null): WebhookRequestBody | null { if (!rawBody) { return null; } @@ -64,7 +61,8 @@ export function createLineWebhookMiddleware( return; } - const body = parseWebhookBody(req, rawBody); + // Keep processing tied to the exact bytes that passed signature verification. + const body = parseWebhookBody(rawBody); if (!body) { res.status(400).json({ error: "Invalid webhook payload" });