From 4f2f641950c23a345673bb3bd7c9919cdc5dc3ae Mon Sep 17 00:00:00 2001 From: artale Date: Sat, 14 Feb 2026 23:10:13 +0100 Subject: [PATCH] fix(line): return 200 for webhook verification requests without signature LINE Platform sends POST {"events":[]} without an X-Line-Signature header when the user clicks 'Verify' in the LINE Developers Console. Both webhook.ts and monitor.ts rejected this with 400 'Missing X-Line-Signature header', causing verification to fail. Now detect the verification pattern (no signature + empty events array) and return 200 OK immediately, while still requiring valid signatures for all real webhook deliveries with non-empty events. Fixes #16425 --- src/line/monitor.ts | 18 ++++++++++++++++- src/line/webhook.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++ src/line/webhook.ts | 13 ++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/line/monitor.ts b/src/line/monitor.ts index 821cb7b37..6da220fbe 100644 --- a/src/line/monitor.ts +++ b/src/line/monitor.ts @@ -322,8 +322,24 @@ export async function monitorLineProvider( const rawBody = await readLineWebhookRequestBody(req, LINE_WEBHOOK_MAX_BODY_BYTES); const signature = req.headers["x-line-signature"]; - // Validate signature + // LINE webhook verification sends POST {"events":[]} without a + // signature header. Return 200 so the LINE Developers Console + // "Verify" button succeeds. if (!signature || typeof signature !== "string") { + try { + const verifyBody = JSON.parse(rawBody) as WebhookRequestBody; + if (Array.isArray(verifyBody.events) && verifyBody.events.length === 0) { + logVerbose( + "line: webhook verification request (empty events, no signature) — 200 OK", + ); + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ status: "ok" })); + return; + } + } catch { + // Not valid JSON — fall through to the error below. + } logVerbose("line: webhook missing X-Line-Signature header"); res.statusCode = 400; res.setHeader("Content-Type", "application/json"); diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts index 61628d423..870761754 100644 --- a/src/line/webhook.test.ts +++ b/src/line/webhook.test.ts @@ -98,6 +98,48 @@ describe("createLineWebhookMiddleware", () => { expect(onEvents).not.toHaveBeenCalled(); }); + it("returns 200 for verification request (empty events, no signature)", async () => { + const onEvents = vi.fn(async () => {}); + const secret = "secret"; + const rawBody = JSON.stringify({ events: [] }); + const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); + + const req = { + headers: {}, + body: rawBody, + // 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(res.json).toHaveBeenCalledWith({ status: "ok" }); + expect(onEvents).not.toHaveBeenCalled(); + }); + + it("rejects missing signature when events are non-empty", async () => { + const onEvents = vi.fn(async () => {}); + const secret = "secret"; + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); + + const req = { + headers: {}, + body: rawBody, + // 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: "Missing X-Line-Signature header" }); + expect(onEvents).not.toHaveBeenCalled(); + }); + it("rejects webhooks with signatures computed using wrong secret", async () => { const onEvents = vi.fn(async () => {}); const correctSecret = "correct-secret"; diff --git a/src/line/webhook.ts b/src/line/webhook.ts index b2e9806fa..880786df5 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -39,13 +39,24 @@ export function createLineWebhookMiddleware( return async (req: Request, res: Response, _next: NextFunction): Promise => { try { const signature = req.headers["x-line-signature"]; + const rawBody = readRawBody(req); + // LINE webhook verification sends POST {"events":[]} without a + // signature header. Return 200 immediately so the LINE Developers + // Console "Verify" button succeeds. if (!signature || typeof signature !== "string") { + if (rawBody) { + const body = parseWebhookBody(req, rawBody); + if (body && Array.isArray(body.events) && body.events.length === 0) { + logVerbose("line: webhook verification request (empty events, no signature) — 200 OK"); + res.status(200).json({ status: "ok" }); + return; + } + } res.status(400).json({ error: "Missing X-Line-Signature header" }); return; } - const rawBody = readRawBody(req); if (!rawBody) { res.status(400).json({ error: "Missing raw request body for signature verification" }); return;