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>
This commit is contained in:
Josh Avant 2026-03-20 15:32:55 -05:00 committed by GitHub
parent 11d71ca352
commit c7134e629c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 91 additions and 5 deletions

View File

@ -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

View File

@ -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

View File

@ -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");

View File

@ -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" });