From 994f4f99fe25fd7258dbcd2ba81f69f9687fafe6 Mon Sep 17 00:00:00 2001 From: "openclaw-clownfish[bot]" <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:26:16 +0900 Subject: [PATCH] fix(line): canonicalize trailing-slash webhook paths (#91649) * fix(line): canonicalize trailing-slash webhook paths * fix(clownfish): address review for clawsweeper-commit-openclaw-openclaw-4cf228466770 (1) --------- Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> --- extensions/line/src/monitor.lifecycle.test.ts | 39 ++++++++++++++++++- extensions/line/src/monitor.ts | 5 ++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/extensions/line/src/monitor.lifecycle.test.ts b/extensions/line/src/monitor.lifecycle.test.ts index 0030caed23c5..1bb5efc1dd30 100644 --- a/extensions/line/src/monitor.lifecycle.test.ts +++ b/extensions/line/src/monitor.lifecycle.test.ts @@ -173,9 +173,13 @@ describe("monitorLineProvider lifecycle", () => { .mockImplementation(() => innerLineWebhookHandlerMock); unregisterHttpMock.mockReset(); registerWebhookTargetWithPluginRouteMock.mockReset().mockImplementation((params) => { - const key = params.target.path.startsWith("/") + const withLeadingSlash = params.target.path.startsWith("/") ? params.target.path : `/${params.target.path}`; + const key = + withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/") + ? withLeadingSlash.slice(0, -1) + : withLeadingSlash; const normalizedTarget = { ...params.target, path: key }; const existing = params.targetsByPath.get(key) ?? []; params.targetsByPath.set(key, [...existing, normalizedTarget]); @@ -353,6 +357,39 @@ describe("monitorLineProvider lifecycle", () => { secondMonitor.stop(); }); + it("dispatches a signed POST to a configured trailing-slash webhook path", async () => { + const monitor = await monitorLineProvider({ + channelAccessToken: "token", + channelSecret: "secret", // pragma: allowlist secret + webhookPath: "/line/webhook/", + accountId: "default", + config: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + }); + + const registration = requireWebhookRegistration(); + expect(registration.target.path).toBe("/line/webhook"); + + const route = requireRegisteredRoute(); + const payload = JSON.stringify({ events: [{ type: "message" }] }); + const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64"); + const req = Object.assign(createMockIncomingRequest([payload]), { + method: "POST", + headers: { "x-line-signature": signature }, + }) as unknown as IncomingMessage; + const res = createRouteResponse(); + + await route.handler(req, res); + + const bot = createLineBotMock.mock.results[0]?.value as { + handleWebhook: ReturnType; + }; + expect(res.statusCode).toBe(200); + expect(bot.handleWebhook).toHaveBeenCalledTimes(1); + + monitor.stop(); + }); + it("acknowledges shared-path POST requests before matched event processing completes", async () => { const monitor = await monitorLineProvider({ channelAccessToken: "token", diff --git a/extensions/line/src/monitor.ts b/extensions/line/src/monitor.ts index 536c4192bb4f..13d382e96a6e 100644 --- a/extensions/line/src/monitor.ts +++ b/extensions/line/src/monitor.ts @@ -12,6 +12,7 @@ import { import { isRequestBodyLimitError, normalizePluginHttpPath, + normalizeWebhookPath, registerWebhookTargetWithPluginRoute, requestBodyErrorToText, resolveSingleWebhookTarget, @@ -337,7 +338,9 @@ export async function monitorLineProvider( }, }); - const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook"; + const normalizedPath = normalizeWebhookPath( + normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook", + ); const createScopedLineWebhookHandler = (target: LineWebhookTarget) => createLineNodeWebhookHandler({ channelSecret: target.channelSecret,