Merge branch 'refactor/line-webhook-verification'
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
154
src/line/webhook-node.test.ts
Normal file
154
src/line/webhook-node.test.ts
Normal 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
129
src/line/webhook-node.ts
Normal 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
15
src/line/webhook-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user