From 1b18742e8ed18b0cf115084a7cc2800727501250 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 20:04:25 +0000 Subject: [PATCH 1/6] test: peel more slow unit files out of unit-fast --- scripts/test-parallel.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 5bbd4c94ac6..f3c03970080 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -297,7 +297,7 @@ const defaultHeavyUnitFileLimit = : isMacMiniProfile ? 90 : testProfile === "low" - ? 20 + ? 32 : highMemLocalHost ? 80 : 60; @@ -307,7 +307,7 @@ const defaultHeavyUnitLaneCount = : isMacMiniProfile ? 6 : testProfile === "low" - ? 2 + ? 3 : highMemLocalHost ? 5 : 4; From 23fef04c4ebce85cab7641290001f20fd536124f Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Fri, 20 Mar 2026 13:07:22 -0700 Subject: [PATCH 2/6] test: fix setup finalize web search mocks (#51253) --- src/wizard/setup.finalize.test.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 2c7e0e85470..7ceeab37c23 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -154,6 +154,21 @@ function createRuntime(): RuntimeEnv { }; } +function createWebSearchProviderEntry( + provider: Pick< + PluginWebSearchProviderEntry, + "id" | "label" | "hint" | "envVars" | "placeholder" | "signupUrl" | "credentialPath" + >, +): PluginWebSearchProviderEntry { + return { + pluginId: `plugin-${provider.id}`, + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + createTool: () => null, + ...provider, + }; +} + function expectFirstOnboardingInstallPlanCallOmitsToken() { const [firstArg] = (buildGatewayInstallPlan.mock.calls.at(0) as [Record] | undefined) ?? []; @@ -414,7 +429,7 @@ describe("finalizeSetupWizard", () => { it("only reports legacy auto-detect for runtime-visible providers", async () => { listConfiguredWebSearchProviders.mockReturnValue([ - { + createWebSearchProviderEntry({ id: "perplexity", label: "Perplexity Search", hint: "Fast web answers", @@ -422,7 +437,7 @@ describe("finalizeSetupWizard", () => { placeholder: "pplx-...", signupUrl: "https://www.perplexity.ai/", credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", - }, + }), ]); hasExistingKey.mockImplementation((_config, provider) => provider === "perplexity"); @@ -463,7 +478,7 @@ describe("finalizeSetupWizard", () => { it("uses configured provider resolution instead of the active runtime registry", async () => { listConfiguredWebSearchProviders.mockReturnValue([ - { + createWebSearchProviderEntry({ id: "firecrawl", label: "Firecrawl Search", hint: "Structured results", @@ -471,7 +486,7 @@ describe("finalizeSetupWizard", () => { placeholder: "fc-...", signupUrl: "https://www.firecrawl.dev/", credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", - }, + }), ]); hasExistingKey.mockImplementation((_config, provider) => provider === "firecrawl"); From fa71ad7c5d0688d5443e298ebb8b4644c9235493 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 20:16:44 +0000 Subject: [PATCH 3/6] test: repair latest-main web search regressions --- .../check-plugin-extension-import-boundary.mjs | 1 + src/wizard/setup.finalize.test.ts | 17 +++++++++++++++++ test/plugin-extension-import-boundary.test.ts | 5 ++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/scripts/check-plugin-extension-import-boundary.mjs b/scripts/check-plugin-extension-import-boundary.mjs index bbe9f9702f5..ac9c5e178a4 100644 --- a/scripts/check-plugin-extension-import-boundary.mjs +++ b/scripts/check-plugin-extension-import-boundary.mjs @@ -195,6 +195,7 @@ function scanWebSearchRegistrySmells(sourceFile, filePath) { function shouldSkipFile(filePath) { const relativeFile = normalizePath(filePath); return ( + relativeFile === "src/plugins/bundled-web-search-registry.ts" || relativeFile.startsWith("src/plugins/contracts/") || /^src\/plugins\/runtime\/runtime-[^/]+-contract\.[cm]?[jt]s$/u.test(relativeFile) ); diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 7ceeab37c23..a701eec35fb 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -49,6 +49,23 @@ const listConfiguredWebSearchProviders = vi.hoisted(() => vi.fn<(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]>(() => []), ); +function createWebSearchProviderEntry( + entry: Omit< + PluginWebSearchProviderEntry, + "pluginId" | "getCredentialValue" | "setCredentialValue" | "createTool" + > & { + pluginId?: string; + }, +): PluginWebSearchProviderEntry { + return { + ...entry, + pluginId: entry.pluginId ?? entry.id, + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + createTool: () => null, + }; +} + vi.mock("../commands/onboard-helpers.js", () => ({ detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })), formatControlUiSshHint: vi.fn(() => "ssh hint"), diff --git a/test/plugin-extension-import-boundary.test.ts b/test/plugin-extension-import-boundary.test.ts index 94fd9ee7f83..bef7bb57838 100644 --- a/test/plugin-extension-import-boundary.test.ts +++ b/test/plugin-extension-import-boundary.test.ts @@ -21,12 +21,15 @@ function readBaseline() { } describe("plugin extension import boundary inventory", () => { - it("keeps web-search-providers out of the remaining inventory", async () => { + it("keeps dedicated web-search registry shims out of the remaining inventory", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); expect(inventory.some((entry) => entry.file === "src/plugins/web-search-providers.ts")).toBe( false, ); + expect( + inventory.some((entry) => entry.file === "src/plugins/bundled-web-search-registry.ts"), + ).toBe(false); }); it("ignores boundary shims by scope", async () => { From 5a5e84ca1d6459973b2e4b1ff8bcba5245a2fae6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 20:25:24 +0000 Subject: [PATCH 4/6] test: drop duplicate web search helper --- src/wizard/setup.finalize.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index a701eec35fb..7ceeab37c23 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -49,23 +49,6 @@ const listConfiguredWebSearchProviders = vi.hoisted(() => vi.fn<(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]>(() => []), ); -function createWebSearchProviderEntry( - entry: Omit< - PluginWebSearchProviderEntry, - "pluginId" | "getCredentialValue" | "setCredentialValue" | "createTool" - > & { - pluginId?: string; - }, -): PluginWebSearchProviderEntry { - return { - ...entry, - pluginId: entry.pluginId ?? entry.id, - getCredentialValue: () => undefined, - setCredentialValue: () => {}, - createTool: () => null, - }; -} - vi.mock("../commands/onboard-helpers.js", () => ({ detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })), formatControlUiSshHint: vi.fn(() => "ssh hint"), From 11d71ca35219b27d3604f99b181006d19129461e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 13:27:39 -0700 Subject: [PATCH 5/6] pairing: keep setup codes bootstrap-token only (#51259) --- src/cli/qr-cli.test.ts | 29 +++++---------- src/cli/qr-dashboard.integration.test.ts | 7 +--- src/pairing/setup-code.test.ts | 21 ++++------- src/pairing/setup-code.ts | 46 ------------------------ 4 files changed, 17 insertions(+), 86 deletions(-) diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 3a0490d996f..1bc8a645719 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -135,24 +135,16 @@ describe("registerQrCli", () => { }; } - function expectLoggedSetupCode( - url: string, - auth?: { - token?: string; - password?: string; - }, - ) { + function expectLoggedSetupCode(url: string) { const expected = encodePairingSetupCode({ url, bootstrapToken: "bootstrap-123", - ...(auth?.token ? { token: auth.token } : {}), - ...(auth?.password ? { password: auth.password } : {}), }); expect(runtime.log).toHaveBeenCalledWith(expected); } - function expectLoggedLocalSetupCode(auth?: { token?: string; password?: string }) { - expectLoggedSetupCode("ws://gateway.local:18789", auth); + function expectLoggedLocalSetupCode() { + expectLoggedSetupCode("ws://gateway.local:18789"); } function mockTailscaleStatusLookup() { @@ -189,7 +181,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", bootstrapToken: "bootstrap-123", - token: "tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(qrGenerate).not.toHaveBeenCalled(); @@ -225,7 +216,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only", "--token", "override-token"]); - expectLoggedLocalSetupCode({ token: "override-token" }); + expectLoggedLocalSetupCode(); }); it("skips local password SecretRef resolution when --token override is provided", async () => { @@ -237,7 +228,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only", "--token", "override-token"]); - expectLoggedLocalSetupCode({ token: "override-token" }); + expectLoggedLocalSetupCode(); }); it("resolves local gateway auth password SecretRefs before setup code generation", async () => { @@ -250,7 +241,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "local-password-secret" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -264,7 +255,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "password-from-env" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -279,7 +270,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ token: "token-123" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -293,7 +284,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only"]); - expectLoggedLocalSetupCode({ password: "inferred-password" }); + expectLoggedLocalSetupCode(); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -342,7 +333,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "remote-tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( @@ -386,7 +376,6 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "remote-tok", }); expect(runtime.log).toHaveBeenCalledWith(expected); }); diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index 559b9a8fc15..81550c5922a 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -69,8 +69,6 @@ function createGatewayTokenRefFixture() { function decodeSetupCode(setupCode: string): { url?: string; bootstrapToken?: string; - token?: string; - password?: string; } { const padded = setupCode.replace(/-/g, "+").replace(/_/g, "/"); const padLength = (4 - (padded.length % 4)) % 4; @@ -79,8 +77,6 @@ function decodeSetupCode(setupCode: string): { return JSON.parse(json) as { url?: string; bootstrapToken?: string; - token?: string; - password?: string; }; } @@ -119,7 +115,7 @@ describe("cli integration: qr + dashboard token SecretRef", () => { delete process.env.SHARED_GATEWAY_TOKEN; }); - it("uses the same resolved token SecretRef for both qr and dashboard commands", async () => { + it("uses the same resolved token SecretRef for qr auth validation and dashboard commands", async () => { const fixture = createGatewayTokenRefFixture(); process.env.SHARED_GATEWAY_TOKEN = "shared-token-123"; loadConfigMock.mockReturnValue(fixture); @@ -137,7 +133,6 @@ describe("cli integration: qr + dashboard token SecretRef", () => { const payload = decodeSetupCode(setupCode ?? ""); expect(payload.url).toBe("ws://gateway.local:18789"); expect(payload.bootstrapToken).toBeTruthy(); - expect(payload.token).toBe("shared-token-123"); expect(runtimeErrors).toEqual([]); runtimeLogs.length = 0; diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index b1d80a5e50d..6622f6c010f 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -45,8 +45,6 @@ describe("pairing setup code", () => { authLabel: string; url?: string; urlSource?: string; - token?: string; - password?: string; }, ) { expect(resolved.ok).toBe(true); @@ -55,8 +53,6 @@ describe("pairing setup code", () => { } expect(resolved.authLabel).toBe(params.authLabel); expect(resolved.payload.bootstrapToken).toBe("bootstrap-123"); - expect(resolved.payload.token).toBe(params.token); - expect(resolved.payload.password).toBe(params.password); if (params.url) { expect(resolved.payload.url).toBe(params.url); } @@ -117,7 +113,6 @@ describe("pairing setup code", () => { payload: { url: "ws://gateway.local:19001", bootstrapToken: "bootstrap-123", - token: "tok_123", }, authLabel: "token", urlSource: "gateway.bind=custom", @@ -144,7 +139,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "resolved-password" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef", async () => { @@ -167,7 +162,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("does not resolve gateway.auth.password SecretRef in token mode", async () => { @@ -189,7 +184,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "tok_123" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("resolves gateway.auth.token SecretRef for pairing payload", async () => { @@ -212,7 +207,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "resolved-token" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => { @@ -261,13 +256,13 @@ describe("pairing setup code", () => { id: "MISSING_GW_TOKEN", }); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("does not treat env-template token as plaintext in inferred mode", async () => { const resolved = await resolveInferredModeWithPasswordEnv("${MISSING_GW_TOKEN}"); - expectResolvedSetupOk(resolved, { authLabel: "password", password: "password-from-env" }); + expectResolvedSetupOk(resolved, { authLabel: "password" }); }); it("requires explicit auth mode when token and password are both configured", async () => { @@ -333,7 +328,7 @@ describe("pairing setup code", () => { }, ); - expectResolvedSetupOk(resolved, { authLabel: "token", token: "new-token" }); + expectResolvedSetupOk(resolved, { authLabel: "token" }); }); it("errors when gateway is loopback only", async () => { @@ -367,7 +362,6 @@ describe("pairing setup code", () => { payload: { url: "wss://mb-server.tailnet.ts.net", bootstrapToken: "bootstrap-123", - password: "secret", }, authLabel: "password", urlSource: "gateway.tailscale.mode=serve", @@ -396,7 +390,6 @@ describe("pairing setup code", () => { payload: { url: "wss://remote.example.com:444", bootstrapToken: "bootstrap-123", - token: "tok_123", }, authLabel: "token", urlSource: "gateway.remote.url", diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index c64ae36077e..6a2c5dd0b39 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -16,8 +16,6 @@ import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type PairingSetupPayload = { url: string; bootstrapToken: string; - token?: string; - password?: string; }; export type PairingSetupCommandResult = { @@ -64,11 +62,6 @@ type ResolveAuthLabelResult = { error?: string; }; -type ResolveSharedAuthResult = { - token?: string; - password?: string; -}; - function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -213,41 +206,6 @@ function resolvePairingSetupAuthLabel( return { error: "Gateway auth is not configured (no token or password)." }; } -function resolvePairingSetupSharedAuth( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv, -): ResolveSharedAuthResult { - const defaults = cfg.secrets?.defaults; - const tokenRef = resolveSecretInputRef({ - value: cfg.gateway?.auth?.token, - defaults, - }).ref; - const passwordRef = resolveSecretInputRef({ - value: cfg.gateway?.auth?.password, - defaults, - }).ref; - const token = - resolveGatewayTokenFromEnv(env) || - (tokenRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.token)); - const password = - resolveGatewayPasswordFromEnv(env) || - (passwordRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.password)); - const mode = cfg.gateway?.auth?.mode; - if (mode === "token") { - return { token }; - } - if (mode === "password") { - return { password }; - } - if (token) { - return { token }; - } - if (password) { - return { password }; - } - return {}; -} - async function resolveGatewayTokenSecretRef( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, @@ -417,8 +375,6 @@ export async function resolvePairingSetupFromConfig( if (authLabel.error) { return { ok: false, error: authLabel.error }; } - const sharedAuth = resolvePairingSetupSharedAuth(cfgForAuth, env); - const urlResult = await resolveGatewayUrl(cfgForAuth, { env, publicUrl: options.publicUrl, @@ -445,8 +401,6 @@ export async function resolvePairingSetupFromConfig( baseDir: options.pairingBaseDir, }) ).token, - ...(sharedAuth.token ? { token: sharedAuth.token } : {}), - ...(sharedAuth.password ? { password: sharedAuth.password } : {}), }, authLabel: authLabel.label, urlSource: urlResult.source ?? "unknown", From c7134e629c917c120c79f6988b78069fda244318 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:32:55 -0500 Subject: [PATCH 6/6] LINE: harden Express webhook parsing to verified raw body (#51202) * LINE: enforce signed-raw webhook parsing * LINE: narrow scope and add buffer regression * changelog Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --------- Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/line.md | 1 + src/line/webhook.test.ts | 86 ++++++++++++++++++++++++++++++++++++++++ src/line/webhook.ts | 8 ++-- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13939729cd9..15fe8b08613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,7 @@ Docs: https://docs.openclaw.ai - Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant. - Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant. - Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant. +- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant. - xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek ### Fixes diff --git a/docs/channels/line.md b/docs/channels/line.md index a965dc6e991..079025e10ac 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -51,6 +51,7 @@ If you need a custom path, set `channels.line.webhookPath` or Security note: - LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification. +- OpenClaw processes webhook events from the verified raw request bytes. Upstream middleware-transformed `req.body` values are ignored for signature-integrity safety. ## Configure diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts index 9b3b9c0539a..5c38c58f3ce 100644 --- a/src/line/webhook.test.ts +++ b/src/line/webhook.test.ts @@ -138,6 +138,92 @@ describe("createLineWebhookMiddleware", () => { expect(onEvents).not.toHaveBeenCalled(); }); + it("uses the signed raw body instead of a pre-parsed req.body object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBody = JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-user" } }], + }); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBody, SECRET) }, + rawBody, + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-user"); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); + }); + + it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBodyText = JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-buffer-user" } }], + }); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBodyText, SECRET) }, + rawBody: Buffer.from(rawBodyText, "utf-8"), + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-buffer-user"); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); + }); + + it("rejects invalid signed raw JSON even when req.body is a valid object", async () => { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const rawBody = "not-json"; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + + const req = { + headers: { "x-line-signature": sign(rawBody, SECRET) }, + rawBody, + body: { events: [{ type: "message" }] }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" }); + expect(onEvents).not.toHaveBeenCalled(); + }); + it("returns 500 when event processing fails and does not acknowledge with 200", async () => { const onEvents = vi.fn(async () => { throw new Error("boom"); diff --git a/src/line/webhook.ts b/src/line/webhook.ts index 99c338db2f9..879972d0490 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -23,10 +23,7 @@ function readRawBody(req: Request): string | 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; - } +function parseWebhookBody(rawBody?: string | null): WebhookRequestBody | null { if (!rawBody) { return null; } @@ -64,7 +61,8 @@ export function createLineWebhookMiddleware( return; } - const body = parseWebhookBody(req, rawBody); + // Keep processing tied to the exact bytes that passed signature verification. + const body = parseWebhookBody(rawBody); if (!body) { res.status(400).json({ error: "Invalid webhook payload" });