diff --git a/CHANGELOG.md b/CHANGELOG.md index 778ab081919..1029a315489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - CLI/status: resolve read-only channel setup runtime fallback from the packaged OpenClaw dist root, so `status --all`, `status --deep`, channel, and doctor paths do not crash when an external channel plugin needs setup metadata. Fixes #74693. Thanks @giangthb. - CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash. +- Channels/Voice call: keep pre-auth webhook in-flight limiting active when socket remote address metadata is missing, so slow-body requests from stripped-IP proxy paths still share the fallback bucket. (#74453) Thanks @davidangularme. - Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc. - Channels/Microsoft Teams: treat configured `19:...@thread.tacv2` and legacy `19:...@thread.skype` team/channel IDs as already resolved during startup, avoiding false `channels unresolved` warnings while preserving Graph name lookup for display-name entries. Fixes #74683. Thanks @dseravalli. - CLI/browser: preserve parent flags while lazy-loading browser subcommands, so `openclaw browser --json open` and `openclaw browser --json tabs` keep machine-readable output after reparsing. Fixes #74574. Thanks @devintegeritsm. diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 8d5e321a303..14efd6d1f7f 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -1,4 +1,4 @@ -import { request } from "node:http"; +import { request, type IncomingMessage } from "node:http"; import type { RealtimeTranscriptionProviderPlugin } from "openclaw/plugin-sdk/realtime-transcription"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { VoiceCallConfigSchema, type VoiceCallConfig } from "./config.js"; @@ -941,7 +941,9 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { if (enteredReads === 8) { releaseReads(); } - await unblockReads; + if (enteredReads <= 8) { + await unblockReads; + } return "CallSid=CA123&SpeechResult=hello"; }); @@ -967,6 +969,80 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { await server.stop(); } }); + + it("limits missing remote addresses with a shared fallback bucket", async () => { + const twilioProvider: VoiceCallProvider = { + ...provider, + name: "twilio", + verifyWebhook: () => ({ ok: true, verifiedRequestKey: "twilio:req:test" }), + }; + const { manager } = createManager([]); + const config = createConfig({ provider: "twilio" }); + const server = new VoiceCallWebhookServer(config, manager, twilioProvider); + const runWebhookPipeline = ( + server as unknown as { + runWebhookPipeline: ( + req: IncomingMessage, + webhookPath: string, + ) => Promise<{ statusCode: number; body: string }>; + } + ).runWebhookPipeline.bind(server); + + let enteredReads = 0; + let releaseReads!: () => void; + let unblockReadBodies!: () => void; + const enteredEightReads = new Promise((resolve) => { + releaseReads = resolve; + }); + const unblockReads = new Promise((resolve) => { + unblockReadBodies = resolve; + }); + const readBodySpy = vi.spyOn( + server as unknown as { + readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise; + }, + "readBody", + ); + readBodySpy.mockImplementation(async () => { + enteredReads += 1; + if (enteredReads === 8) { + releaseReads(); + } + await unblockReads; + return "CallSid=CA123&SpeechResult=hello"; + }); + + const makeRequestWithoutRemoteAddress = () => + ({ + method: "POST", + url: "/voice/webhook", + headers: { "x-twilio-signature": "sig" }, + socket: { remoteAddress: undefined }, + }) as unknown as IncomingMessage; + + try { + const inFlightRequests = Array.from({ length: 8 }, () => + runWebhookPipeline(makeRequestWithoutRemoteAddress(), "/voice/webhook"), + ); + await enteredEightReads; + + const rejected = await runWebhookPipeline( + makeRequestWithoutRemoteAddress(), + "/voice/webhook", + ); + expect(rejected.statusCode).toBe(429); + expect(rejected.body).toBe("Too Many Requests"); + expect(readBodySpy).toHaveBeenCalledTimes(8); + + unblockReadBodies(); + + const settled = await Promise.all(inFlightRequests); + expect(settled.every((response) => response.statusCode === 200)).toBe(true); + } finally { + unblockReadBodies(); + readBodySpy.mockRestore(); + } + }); }); describe("VoiceCallWebhookServer response normalization", () => { diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 0d04dc75da0..77c9c60cdc5 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -29,6 +29,7 @@ import { startStaleCallReaper } from "./webhook/stale-call-reaper.js"; const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes; const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs; +const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__"; const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2000; const TRANSCRIPT_LOG_MAX_CHARS = 200; @@ -616,7 +617,16 @@ export class VoiceCallWebhookServer { return { statusCode: 401, body: "Unauthorized" }; } - const inFlightKey = req.socket.remoteAddress ?? ""; + // createWebhookInFlightLimiter intentionally treats an empty key as fail-open. + // Missing socket metadata must still share one bucket instead of bypassing + // the pre-auth limiter entirely. + const remoteAddress = req.socket.remoteAddress; + if (!remoteAddress) { + console.warn( + `[voice-call] Webhook accepted with no remote address; using shared fallback in-flight key`, + ); + } + const inFlightKey = remoteAddress || MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY; if (!this.webhookInFlightLimiter.tryAcquire(inFlightKey)) { console.warn(`[voice-call] Webhook rejected before body read: too many in-flight requests`); return { statusCode: 429, body: "Too Many Requests" }; diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 985db7a79af..a2704e35d52 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -489,8 +489,8 @@ describe("scripts/test-projects changed-target routing", () => { expect(plans).toEqual([ { config: "test/vitest/vitest.unit.config.ts", - forwardedArgs: [], - includePatterns: ["packages/sdk/src/**/*.test.ts"], + forwardedArgs: ["packages/sdk/src/index.test.ts"], + includePatterns: null, watchMode: false, }, ]);