diff --git a/extensions/line/src/monitor.lifecycle.test.ts b/extensions/line/src/monitor.lifecycle.test.ts index f4561ead08a3..f13e6ee54fea 100644 --- a/extensions/line/src/monitor.lifecycle.test.ts +++ b/extensions/line/src/monitor.lifecycle.test.ts @@ -1,7 +1,7 @@ // Line tests cover monitor.lifecycle plugin behavior. import crypto from "node:crypto"; import { EventEmitter } from "node:events"; -import type { IncomingMessage, ServerResponse } from "node:http"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env"; @@ -378,6 +378,102 @@ describe("monitorLineProvider lifecycle", () => { await secondMonitor.stop(); }); + it("redacts structured admission failures from signed HTTP webhook requests", async () => { + const runtimeError = vi.fn<(...args: unknown[]) => void>(); + const runtime: RuntimeEnv = { + log: vi.fn<(...args: unknown[]) => void>(), + error: runtimeError, + exit: vi.fn<(code: number) => void>(), + }; + const monitor = await monitorLineProvider({ + channelAccessToken: "token", + channelSecret: "secret", // pragma: allowlist secret + accountId: "default", + config: {} as OpenClawConfig, + runtime, + }); + const route = requireRegisteredRoute(); + const server = createServer((req, res) => { + void route.handler(req, res); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected LINE webhook test server to have a TCP address"); + } + + const webhookUrl = `http://127.0.0.1:${address.port}/line/webhook`; + const payload = JSON.stringify({ events: [{ type: "message" }] }); + const rejected = await fetch(webhookUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-line-signature": "invalid-signature", + }, + body: payload, + }); + expect(rejected.status).toBe(401); + expect(await rejected.json()).toEqual({ error: "Invalid signature" }); + + const bot = createLineBotMock.mock.results[0]?.value; + if (!bot) { + throw new Error("expected registered LINE webhook bot"); + } + expect(bot.handleWebhook).not.toHaveBeenCalled(); + + const bearerToken = "test_line_access_token_1234567890"; + bot.handleWebhook.mockRejectedValueOnce({ + code: "LINE_ADMISSION_REJECTED", + retryAfterMs: 250, + authorization: `Bearer ${bearerToken}`, + }); + const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64"); + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-line-signature": signature, + }, + body: payload, + }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "Internal server error" }); + expect(bot.handleWebhook).toHaveBeenCalledTimes(1); + expect(runtimeError).toHaveBeenCalledTimes(1); + const message = String(runtimeError.mock.calls[0]?.[0]); + expect(message).toContain("line webhook error:"); + expect(message).toContain("LINE_ADMISSION_REJECTED"); + expect(message).toContain("retryAfterMs"); + expect(message).not.toContain("[object Object]"); + expect(message).not.toContain(bearerToken); + } finally { + try { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + } finally { + await monitor.stop(); + } + } + }); + it("dispatches a signed POST to a configured trailing-slash webhook path", async () => { const monitor = await monitorLineProvider({ channelAccessToken: "token", diff --git a/extensions/line/src/monitor.ts b/extensions/line/src/monitor.ts index 693a7a0b7fe1..5077ca2cde2c 100644 --- a/extensions/line/src/monitor.ts +++ b/extensions/line/src/monitor.ts @@ -2,6 +2,7 @@ import type { webhook } from "@line/bot-sdk"; import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime"; import { danger, @@ -392,7 +393,7 @@ export async function monitorLineProvider( res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); return; } - runtime.error?.(danger(`line webhook error: ${String(err)}`)); + runtime.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`)); if (!res.headersSent) { res.statusCode = 500; res.setHeader("Content-Type", "application/json"); diff --git a/extensions/line/src/webhook-node.test.ts b/extensions/line/src/webhook-node.test.ts index 24832007ed71..42d1e3ac345f 100644 --- a/extensions/line/src/webhook-node.test.ts +++ b/extensions/line/src/webhook-node.test.ts @@ -183,18 +183,19 @@ type WebhookPostResult = { }; type WebhookPostInvoker = (params: { - failWith?: Error; + failWith?: unknown; rawBody: string; signed: boolean; }) => Promise; async function invokeNodePostContract(params: { - failWith?: Error; + failWith?: unknown; rawBody: string; signed: boolean; }) { const dispatched = vi.fn(async () => { if (params.failWith) { + // oxlint-disable-next-line typescript/only-throw-error -- Webhook boundaries must report non-Error throws from downstream dispatchers. throw params.failWith; } }); @@ -223,13 +224,14 @@ async function invokeNodePostContract(params: { } async function invokeMiddlewarePostContract(params: { - failWith?: Error; + failWith?: unknown; rawBody: string; signed: boolean; }) { const runtime = createRuntimeMock(); const onEvents = vi.fn(async () => { if (params.failWith) { + // oxlint-disable-next-line typescript/only-throw-error -- Webhook boundaries must report non-Error throws from downstream dispatchers. throw params.failWith; } }); @@ -354,6 +356,26 @@ describe("LINE webhook shared POST contract", () => { expect(result.runtimeError).toHaveBeenCalledTimes(1); }, ); + + it.each(sharedWebhookPostContractCases)( + "$name reports non-Error admission failures without object Object", + async ({ invoke }) => { + const result = await invoke({ + failWith: { code: "LINE_ADMISSION_REJECTED", retryAfterMs: 250 }, + rawBody: JSON.stringify({ events: [{ type: "message" }] }), + signed: true, + }); + + expect(result.status).toBe(500); + expect(result.body).toEqual({ error: "Internal server error" }); + expect(result.runtimeError).toHaveBeenCalledTimes(1); + const runtimeMessage = String(firstMockCall(result.runtimeError, "runtime error")[0]); + expect(runtimeMessage).toContain("line webhook error:"); + expect(runtimeMessage).toContain("LINE_ADMISSION_REJECTED"); + expect(runtimeMessage).toContain("retryAfterMs"); + expect(runtimeMessage).not.toContain("[object Object]"); + }, + ); }); describe("createLineNodeWebhookHandler", () => { diff --git a/extensions/line/src/webhook-node.ts b/extensions/line/src/webhook-node.ts index 35881291371d..936e8428404d 100644 --- a/extensions/line/src/webhook-node.ts +++ b/extensions/line/src/webhook-node.ts @@ -1,6 +1,7 @@ // Line plugin module implements webhook node behavior. import type { IncomingMessage, ServerResponse } from "node:http"; import type { webhook } from "@line/bot-sdk"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { isRequestBodyLimitError, @@ -119,7 +120,7 @@ export function createLineNodeWebhookHandler(params: { res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); return; } - params.runtime.error?.(danger(`line webhook error: ${String(err)}`)); + params.runtime.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`)); if (!res.headersSent) { res.statusCode = 500; res.setHeader("Content-Type", "application/json"); diff --git a/extensions/line/src/webhook.ts b/extensions/line/src/webhook.ts index cc78348d1365..55ec00c4ebc8 100644 --- a/extensions/line/src/webhook.ts +++ b/extensions/line/src/webhook.ts @@ -1,6 +1,7 @@ // Line plugin module implements webhook behavior. import type { webhook } from "@line/bot-sdk"; import type { NextFunction, Request, Response } from "express"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js"; @@ -73,7 +74,7 @@ export function createLineWebhookMiddleware( } res.status(200).json({ status: "ok" }); } catch (err) { - runtime?.error?.(danger(`line webhook error: ${String(err)}`)); + runtime?.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`)); if (!res.headersSent) { res.status(500).json({ error: "Internal server error" }); }