Merge branch 'refactor/line-webhook-verification'

This commit is contained in:
Peter Steinberger
2026-02-15 01:00:23 +01:00
6 changed files with 310 additions and 124 deletions

View File

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

View File

@@ -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<string> {
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}`);

View File

@@ -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<string, string> = {};
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();
});
});

129
src/line/webhook-node.ts Normal file
View File

@@ -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<string> {
return await readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS,
});
}
type ReadBodyFn = (req: IncomingMessage, maxBytes: number) => Promise<string>;
export function createLineNodeWebhookHandler(params: {
channelSecret: string;
bot: { handleWebhook: (body: WebhookRequestBody) => Promise<void> };
runtime: RuntimeEnv;
readBody?: ReadBodyFn;
maxBodyBytes?: number;
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
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" }));
}
}
};
}

15
src/line/webhook-utils.ts Normal file
View File

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

View File

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