Files
openclaw/src/line/webhook.ts
Tak Hoffman dbccc73d7a security(line): synthesize strict LINE auth boundary hardening
LINE auth boundary hardening synthesis for inbound webhook authn/z/authz:
- account-scoped pairing-store access
- strict DM/group allowlist boundary separation
- fail-closed webhook auth/runtime behavior
- replay and duplicate handling with in-flight continuity for concurrent redeliveries

Source PRs: #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777
Related continuity context: #21955

Co-authored-by: bmendonca3 <208517100+bmendonca3@users.noreply.github.com>
Co-authored-by: davidahmann <46606159+davidahmann@users.noreply.github.com>
Co-authored-by: harshang03 <58983401+harshang03@users.noreply.github.com>
Co-authored-by: haosenwang1018 <167664334+haosenwang1018@users.noreply.github.com>
Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
Co-authored-by: coygeek <65363919+coygeek@users.noreply.github.com>
Co-authored-by: lailoo <20536249+lailoo@users.noreply.github.com>
2026-03-03 00:21:15 -06:00

117 lines
3.8 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 { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js";
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"];
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 (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;
}
if (!rawBody) {
res.status(400).json({ error: "Missing raw request body for signature verification" });
return;
}
if (!validateLineSignature(rawBody, signature, channelSecret)) {
logVerbose("line: webhook signature validation failed");
res.status(401).json({ error: "Invalid signature" });
return;
}
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 };
}