From 2493455f08d0dabd570f69ac1a460fc3f85a0b17 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 00:59:32 +0100 Subject: [PATCH] refactor(line): extract node webhook handler + shared verification --- src/line/monitor.read-body.test.ts | 2 +- src/line/monitor.ts | 113 +-------------------- src/line/webhook-node.test.ts | 154 +++++++++++++++++++++++++++++ src/line/webhook-node.ts | 129 ++++++++++++++++++++++++ src/line/webhook-utils.ts | 15 +++ src/line/webhook.ts | 21 ++-- 6 files changed, 310 insertions(+), 124 deletions(-) create mode 100644 src/line/webhook-node.test.ts create mode 100644 src/line/webhook-node.ts create mode 100644 src/line/webhook-utils.ts diff --git a/src/line/monitor.read-body.test.ts b/src/line/monitor.read-body.test.ts index 1c2e53544..49057cf2a 100644 --- a/src/line/monitor.read-body.test.ts +++ b/src/line/monitor.read-body.test.ts @@ -1,7 +1,7 @@ import type { IncomingMessage } from "node:http"; import { EventEmitter } from "node:events"; import { describe, expect, it } from "vitest"; -import { readLineWebhookRequestBody } from "./monitor.js"; +import { readLineWebhookRequestBody } from "./webhook-node.js"; function createMockRequest(chunks: string[]): IncomingMessage { const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void }; diff --git a/src/line/monitor.ts b/src/line/monitor.ts index 0f436c142..c09b47195 100644 --- a/src/line/monitor.ts +++ b/src/line/monitor.ts @@ -1,5 +1,4 @@ import type { WebhookRequestBody } from "@line/bot-sdk"; -import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import type { LineChannelData, ResolvedLineAccount } from "./types.js"; @@ -7,11 +6,6 @@ import { chunkMarkdownText } from "../auto-reply/chunk.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import { danger, logVerbose } from "../globals.js"; -import { - isRequestBodyLimitError, - readRequestBodyWithLimit, - requestBodyErrorToText, -} from "../infra/http-body.js"; import { normalizePluginHttpPath } from "../plugins/http-path.js"; import { registerPluginHttpRoute } from "../plugins/http-registry.js"; import { deliverLineAutoReply } from "./auto-reply-delivery.js"; @@ -31,8 +25,8 @@ import { createImageMessage, createLocationMessage, } from "./send.js"; -import { validateLineSignature } from "./signature.js"; import { buildTemplateMessageFromPayload } from "./template-messages.js"; +import { createLineNodeWebhookHandler } from "./webhook-node.js"; export interface MonitorLineProviderOptions { channelAccessToken: string; @@ -51,9 +45,6 @@ export interface LineProviderMonitor { stop: () => void; } -const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; -const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000; - // Track runtime state in memory (simplified version) const runtimeState = new Map< string, @@ -93,16 +84,6 @@ export function getLineRuntimeState(accountId: string) { return runtimeState.get(`line:${accountId}`); } -export async function readLineWebhookRequestBody( - req: IncomingMessage, - maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES, -): Promise { - return await readRequestBodyWithLimit(req, { - maxBytes, - timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS, - }); -} - function startLineLoadingKeepalive(params: { userId: string; accountId?: string; @@ -300,97 +281,7 @@ export async function monitorLineProvider( pluginId: "line", accountId: resolvedAccountId, log: (msg) => logVerbose(msg), - handler: async (req: IncomingMessage, res: ServerResponse) => { - // Handle GET requests for webhook verification - if (req.method === "GET") { - res.statusCode = 200; - res.setHeader("Content-Type", "text/plain"); - res.end("OK"); - return; - } - - // Only accept POST requests - if (req.method !== "POST") { - res.statusCode = 405; - res.setHeader("Allow", "GET, POST"); - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ error: "Method Not Allowed" })); - return; - } - - try { - const rawBody = await readLineWebhookRequestBody(req, LINE_WEBHOOK_MAX_BODY_BYTES); - const signature = req.headers["x-line-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"); - res.end(JSON.stringify({ error: "Missing X-Line-Signature header" })); - return; - } - - if (!validateLineSignature(rawBody, signature, channelSecret)) { - logVerbose("line: webhook signature validation failed"); - res.statusCode = 401; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ error: "Invalid signature" })); - return; - } - - // Parse and process the webhook body - const body = JSON.parse(rawBody) as WebhookRequestBody; - - // Respond immediately with 200 to avoid LINE timeout - res.statusCode = 200; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ status: "ok" })); - - // Process events asynchronously - if (body.events && body.events.length > 0) { - logVerbose(`line: received ${body.events.length} webhook events`); - await bot.handleWebhook(body).catch((err) => { - runtime.error?.(danger(`line webhook handler failed: ${String(err)}`)); - }); - } - } catch (err) { - if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { - res.statusCode = 413; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ error: "Payload too large" })); - return; - } - if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { - res.statusCode = 408; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); - return; - } - runtime.error?.(danger(`line webhook error: ${String(err)}`)); - if (!res.headersSent) { - res.statusCode = 500; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ error: "Internal server error" })); - } - } - }, + handler: createLineNodeWebhookHandler({ channelSecret, bot, runtime }), }); logVerbose(`line: registered webhook handler at ${normalizedPath}`); diff --git a/src/line/webhook-node.test.ts b/src/line/webhook-node.test.ts new file mode 100644 index 000000000..aded9c76d --- /dev/null +++ b/src/line/webhook-node.test.ts @@ -0,0 +1,154 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import crypto from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { createLineNodeWebhookHandler } from "./webhook-node.js"; + +const sign = (body: string, secret: string) => + crypto.createHmac("SHA256", secret).update(body).digest("base64"); + +function createRes() { + const headers: Record = {}; + const res = { + statusCode: 0, + headersSent: false, + setHeader: (k: string, v: string) => { + headers[k.toLowerCase()] = v; + }, + end: vi.fn((data?: unknown) => { + res.headersSent = true; + // Keep payload available for assertions + (res as { body?: unknown }).body = data; + }), + } as unknown as ServerResponse & { body?: unknown }; + return { res, headers }; +} + +describe("createLineNodeWebhookHandler", () => { + it("returns 200 for GET", async () => { + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { error: vi.fn() }; + const handler = createLineNodeWebhookHandler({ + channelSecret: "secret", + bot, + runtime, + readBody: async () => "", + }); + + const { res } = createRes(); + await handler({ method: "GET", headers: {} } as unknown as IncomingMessage, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe("OK"); + }); + + it("returns 200 for verification request (empty events, no signature)", async () => { + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { error: vi.fn() }; + const rawBody = JSON.stringify({ events: [] }); + const handler = createLineNodeWebhookHandler({ + channelSecret: "secret", + bot, + runtime, + readBody: async () => rawBody, + }); + + const { res, headers } = createRes(); + await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res); + + expect(res.statusCode).toBe(200); + expect(headers["content-type"]).toBe("application/json"); + expect(res.body).toBe(JSON.stringify({ status: "ok" })); + expect(bot.handleWebhook).not.toHaveBeenCalled(); + }); + + it("rejects missing signature when events are non-empty", async () => { + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { error: vi.fn() }; + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + const handler = createLineNodeWebhookHandler({ + channelSecret: "secret", + bot, + runtime, + readBody: async () => rawBody, + }); + + const { res } = createRes(); + await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res); + + expect(res.statusCode).toBe(400); + expect(bot.handleWebhook).not.toHaveBeenCalled(); + }); + + it("rejects invalid signature", async () => { + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { error: vi.fn() }; + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + const handler = createLineNodeWebhookHandler({ + channelSecret: "secret", + bot, + runtime, + readBody: async () => rawBody, + }); + + const { res } = createRes(); + await handler( + { method: "POST", headers: { "x-line-signature": "bad" } } as unknown as IncomingMessage, + res, + ); + + expect(res.statusCode).toBe(401); + expect(bot.handleWebhook).not.toHaveBeenCalled(); + }); + + it("accepts valid signature and dispatches events", async () => { + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { error: vi.fn() }; + const secret = "secret"; + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + const handler = createLineNodeWebhookHandler({ + channelSecret: secret, + bot, + runtime, + readBody: async () => rawBody, + }); + + const { res } = createRes(); + await handler( + { + method: "POST", + headers: { "x-line-signature": sign(rawBody, secret) }, + } as unknown as IncomingMessage, + res, + ); + + expect(res.statusCode).toBe(200); + expect(bot.handleWebhook).toHaveBeenCalledWith( + expect.objectContaining({ events: expect.any(Array) }), + ); + }); + + it("returns 400 for invalid JSON payload even when signature is valid", async () => { + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { error: vi.fn() }; + const secret = "secret"; + const rawBody = "not json"; + const handler = createLineNodeWebhookHandler({ + channelSecret: secret, + bot, + runtime, + readBody: async () => rawBody, + }); + + const { res } = createRes(); + await handler( + { + method: "POST", + headers: { "x-line-signature": sign(rawBody, secret) }, + } as unknown as IncomingMessage, + res, + ); + + expect(res.statusCode).toBe(400); + expect(bot.handleWebhook).not.toHaveBeenCalled(); + }); +}); diff --git a/src/line/webhook-node.ts b/src/line/webhook-node.ts new file mode 100644 index 000000000..47b36bfaf --- /dev/null +++ b/src/line/webhook-node.ts @@ -0,0 +1,129 @@ +import type { WebhookRequestBody } from "@line/bot-sdk"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { RuntimeEnv } from "../runtime.js"; +import { danger, logVerbose } from "../globals.js"; +import { + isRequestBodyLimitError, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "../infra/http-body.js"; +import { validateLineSignature } from "./signature.js"; +import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js"; + +const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000; + +export async function readLineWebhookRequestBody( + req: IncomingMessage, + maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES, +): Promise { + return await readRequestBodyWithLimit(req, { + maxBytes, + timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS, + }); +} + +type ReadBodyFn = (req: IncomingMessage, maxBytes: number) => Promise; + +export function createLineNodeWebhookHandler(params: { + channelSecret: string; + bot: { handleWebhook: (body: WebhookRequestBody) => Promise }; + runtime: RuntimeEnv; + readBody?: ReadBodyFn; + maxBodyBytes?: number; +}): (req: IncomingMessage, res: ServerResponse) => Promise { + const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES; + const readBody = params.readBody ?? readLineWebhookRequestBody; + + return async (req: IncomingMessage, res: ServerResponse) => { + // Handle GET requests for webhook verification + if (req.method === "GET") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain"); + res.end("OK"); + return; + } + + // Only accept POST requests + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "GET, POST"); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Method Not Allowed" })); + return; + } + + try { + const rawBody = await readBody(req, maxBodyBytes); + const signature = req.headers["x-line-signature"]; + + // Parse once; we may need it for verification requests and for event processing. + const body = parseLineWebhookBody(rawBody); + + // 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") { + if (isLineWebhookVerificationRequest(body)) { + 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; + } + logVerbose("line: webhook missing X-Line-Signature header"); + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Missing X-Line-Signature header" })); + return; + } + + if (!validateLineSignature(rawBody, signature, params.channelSecret)) { + logVerbose("line: webhook signature validation failed"); + res.statusCode = 401; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Invalid signature" })); + return; + } + + if (!body) { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Invalid webhook payload" })); + return; + } + + // Respond immediately with 200 to avoid LINE timeout + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ status: "ok" })); + + // Process events asynchronously + if (body.events && body.events.length > 0) { + logVerbose(`line: received ${body.events.length} webhook events`); + await params.bot.handleWebhook(body).catch((err) => { + params.runtime.error?.(danger(`line webhook handler failed: ${String(err)}`)); + }); + } + } catch (err) { + if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { + res.statusCode = 413; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Payload too large" })); + return; + } + if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { + res.statusCode = 408; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); + return; + } + params.runtime.error?.(danger(`line webhook error: ${String(err)}`)); + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Internal server error" })); + } + } + }; +} diff --git a/src/line/webhook-utils.ts b/src/line/webhook-utils.ts new file mode 100644 index 000000000..a0ea410fe --- /dev/null +++ b/src/line/webhook-utils.ts @@ -0,0 +1,15 @@ +import type { WebhookRequestBody } from "@line/bot-sdk"; + +export function parseLineWebhookBody(rawBody: string): WebhookRequestBody | null { + try { + return JSON.parse(rawBody) as WebhookRequestBody; + } catch { + return null; + } +} + +export function isLineWebhookVerificationRequest( + body: WebhookRequestBody | null | undefined, +): boolean { + return !!body && Array.isArray(body.events) && body.events.length === 0; +} diff --git a/src/line/webhook.ts b/src/line/webhook.ts index 9bccc2902..25c99970f 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from "express"; import type { RuntimeEnv } from "../runtime.js"; import { logVerbose, danger } from "../globals.js"; import { validateLineSignature } from "./signature.js"; +import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js"; export interface LineWebhookOptions { channelSecret: string; @@ -20,15 +21,14 @@ function readRawBody(req: Request): string | null { return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody; } -function parseWebhookBody(req: Request, rawBody: string): WebhookRequestBody | null { +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; } - try { - return JSON.parse(rawBody) as WebhookRequestBody; - } catch { + if (!rawBody) { return null; } + return parseLineWebhookBody(rawBody); } export function createLineWebhookMiddleware( @@ -40,18 +40,16 @@ export function createLineWebhookMiddleware( try { const signature = req.headers["x-line-signature"]; const rawBody = readRawBody(req); + const body = parseWebhookBody(req, rawBody); // 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; - } + if (isLineWebhookVerificationRequest(body)) { + 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; @@ -68,7 +66,6 @@ export function createLineWebhookMiddleware( return; } - const body = parseWebhookBody(req, rawBody); if (!body) { res.status(400).json({ error: "Invalid webhook payload" }); return;