line: centralize webhook signature validation
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import crypto from "node:crypto";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { danger, logVerbose } from "../globals.js";
|
import { danger, logVerbose } from "../globals.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { createLineBot } from "./bot.js";
|
import { createLineBot } from "./bot.js";
|
||||||
|
import { validateLineSignature } from "./signature.js";
|
||||||
import { normalizePluginHttpPath } from "../plugins/http-path.js";
|
import { normalizePluginHttpPath } from "../plugins/http-path.js";
|
||||||
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
||||||
import {
|
import {
|
||||||
@@ -85,11 +85,6 @@ export function getLineRuntimeState(accountId: string) {
|
|||||||
return runtimeState.get(`line:${accountId}`);
|
return runtimeState.get(`line:${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateLineSignature(body: string, signature: string, channelSecret: string): boolean {
|
|
||||||
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
|
|
||||||
return hash === signature;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readRequestBody(req: IncomingMessage): Promise<string> {
|
async function readRequestBody(req: IncomingMessage): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
|||||||
27
src/line/signature.test.ts
Normal file
27
src/line/signature.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateLineSignature } from "./signature.js";
|
||||||
|
|
||||||
|
const sign = (body: string, secret: string) =>
|
||||||
|
crypto.createHmac("SHA256", secret).update(body).digest("base64");
|
||||||
|
|
||||||
|
describe("validateLineSignature", () => {
|
||||||
|
it("accepts valid signatures", () => {
|
||||||
|
const secret = "secret";
|
||||||
|
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||||
|
|
||||||
|
expect(validateLineSignature(rawBody, sign(rawBody, secret), secret)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects signatures computed with the wrong secret", () => {
|
||||||
|
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||||
|
|
||||||
|
expect(validateLineSignature(rawBody, sign(rawBody, "wrong-secret"), "secret")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects signatures with a different length", () => {
|
||||||
|
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||||
|
|
||||||
|
expect(validateLineSignature(rawBody, "short", "secret")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
src/line/signature.ts
Normal file
18
src/line/signature.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export function validateLineSignature(
|
||||||
|
body: string,
|
||||||
|
signature: string,
|
||||||
|
channelSecret: string,
|
||||||
|
): boolean {
|
||||||
|
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
|
||||||
|
const hashBuffer = Buffer.from(hash);
|
||||||
|
const signatureBuffer = Buffer.from(signature);
|
||||||
|
|
||||||
|
// Use constant-time comparison to prevent timing attacks.
|
||||||
|
if (hashBuffer.length !== signatureBuffer.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.timingSafeEqual(hashBuffer, signatureBuffer);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import crypto from "node:crypto";
|
|
||||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||||
import { logVerbose, danger } from "../globals.js";
|
import { logVerbose, danger } from "../globals.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { validateLineSignature } from "./signature.js";
|
||||||
|
|
||||||
export interface LineWebhookOptions {
|
export interface LineWebhookOptions {
|
||||||
channelSecret: string;
|
channelSecret: string;
|
||||||
@@ -10,20 +10,6 @@ export interface LineWebhookOptions {
|
|||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSignature(body: string, signature: string, channelSecret: string): boolean {
|
|
||||||
const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
|
|
||||||
const hashBuffer = Buffer.from(hash);
|
|
||||||
const signatureBuffer = Buffer.from(signature);
|
|
||||||
|
|
||||||
// Use constant-time comparison to prevent timing attacks
|
|
||||||
// Ensure buffers are same length before comparison to prevent timing leak
|
|
||||||
if (hashBuffer.length !== signatureBuffer.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return crypto.timingSafeEqual(hashBuffer, signatureBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRawBody(req: Request): string | null {
|
function readRawBody(req: Request): string | null {
|
||||||
const rawBody =
|
const rawBody =
|
||||||
(req as { rawBody?: string | Buffer }).rawBody ??
|
(req as { rawBody?: string | Buffer }).rawBody ??
|
||||||
@@ -61,7 +47,7 @@ export function createLineWebhookMiddleware(options: LineWebhookOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateSignature(rawBody, signature, channelSecret)) {
|
if (!validateLineSignature(rawBody, signature, channelSecret)) {
|
||||||
logVerbose("line: webhook signature validation failed");
|
logVerbose("line: webhook signature validation failed");
|
||||||
res.status(401).json({ error: "Invalid signature" });
|
res.status(401).json({ error: "Invalid signature" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user