mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-13 19:10:39 +00:00
* LINE: require webhook signatures in express handler * LINE: require webhook signatures in node handler * LINE: update express signature tests * LINE: update node signature tests * Changelog: note LINE webhook hardening * LINE: validate signatures before parsing webhook bodies * LINE: reject missing signatures before body reads
117 lines
3.6 KiB
TypeScript
117 lines
3.6 KiB
TypeScript
import type { WebhookRequestBody } from "@line/bot-sdk";
|
|
import type { Request, Response, NextFunction } from "express";
|
|
import { logVerbose, danger } from "../globals.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { validateLineSignature } from "./signature.js";
|
|
import { parseLineWebhookBody } from "./webhook-utils.js";
|
|
|
|
const LINE_WEBHOOK_MAX_RAW_BODY_BYTES = 64 * 1024;
|
|
|
|
export interface LineWebhookOptions {
|
|
channelSecret: string;
|
|
onEvents: (body: WebhookRequestBody) => Promise<void>;
|
|
runtime?: RuntimeEnv;
|
|
}
|
|
|
|
function readRawBody(req: Request): string | null {
|
|
const rawBody =
|
|
(req as { rawBody?: string | Buffer }).rawBody ??
|
|
(typeof req.body === "string" || Buffer.isBuffer(req.body) ? req.body : null);
|
|
if (!rawBody) {
|
|
return 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;
|
|
}
|
|
if (!rawBody) {
|
|
return null;
|
|
}
|
|
return parseLineWebhookBody(rawBody);
|
|
}
|
|
|
|
export function createLineWebhookMiddleware(
|
|
options: LineWebhookOptions,
|
|
): (req: Request, res: Response, _next: NextFunction) => Promise<void> {
|
|
const { channelSecret, onEvents, runtime } = options;
|
|
|
|
return async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
|
|
try {
|
|
const signature = req.headers["x-line-signature"];
|
|
|
|
if (!signature || typeof signature !== "string") {
|
|
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;
|
|
}
|
|
if (Buffer.byteLength(rawBody, "utf-8") > LINE_WEBHOOK_MAX_RAW_BODY_BYTES) {
|
|
res.status(413).json({ error: "Payload too large" });
|
|
return;
|
|
}
|
|
|
|
if (!validateLineSignature(rawBody, signature, channelSecret)) {
|
|
logVerbose("line: webhook signature validation failed");
|
|
res.status(401).json({ error: "Invalid signature" });
|
|
return;
|
|
}
|
|
|
|
const body = parseWebhookBody(req, rawBody);
|
|
|
|
if (!body) {
|
|
res.status(400).json({ error: "Invalid webhook payload" });
|
|
return;
|
|
}
|
|
|
|
if (body.events && body.events.length > 0) {
|
|
logVerbose(`line: received ${body.events.length} webhook events`);
|
|
await onEvents(body);
|
|
}
|
|
|
|
res.status(200).json({ status: "ok" });
|
|
} catch (err) {
|
|
runtime?.error?.(danger(`line webhook error: ${String(err)}`));
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export interface StartLineWebhookOptions {
|
|
channelSecret: string;
|
|
onEvents: (body: WebhookRequestBody) => Promise<void>;
|
|
runtime?: RuntimeEnv;
|
|
path?: string;
|
|
}
|
|
|
|
export function startLineWebhook(options: StartLineWebhookOptions): {
|
|
path: string;
|
|
handler: (req: Request, res: Response, _next: NextFunction) => Promise<void>;
|
|
} {
|
|
const channelSecret =
|
|
typeof options.channelSecret === "string" ? options.channelSecret.trim() : "";
|
|
if (!channelSecret) {
|
|
throw new Error(
|
|
"LINE webhook mode requires a non-empty channel secret. " +
|
|
"Set channels.line.channelSecret in your config.",
|
|
);
|
|
}
|
|
const path = options.path ?? "/line/webhook";
|
|
const middleware = createLineWebhookMiddleware({
|
|
channelSecret,
|
|
onEvents: options.onEvents,
|
|
runtime: options.runtime,
|
|
});
|
|
|
|
return { path, handler: middleware };
|
|
}
|