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:
parent
11d71ca352
commit
c7134e629c
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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" });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user