fix: protect SMS webhook callback quota [AI] (#103620)

* fix: scope SMS webhook rate limiting after validation

* fix: separate SMS webhook validation throttles

* fix: loosen SMS pre-validation throttle

* docs: clarify SMS webhook quota stages

* fix: preserve signed sms callback capacity

* fix: name sms invalid request limiter

* test: cover sms validation disabled quota

* fix: skip signed limiter without sms validation

* docs: clarify sms webhook request budget

* fix: keep sms disabled validation dispatch cap
This commit is contained in:
Pavan Kumar Gondhi
2026-07-13 10:29:59 +05:30
committed by GitHub
parent 7c99322e3f
commit ef9383d88a
3 changed files with 222 additions and 15 deletions
+3 -1
View File
@@ -348,7 +348,9 @@ By default, OpenClaw validates `X-Twilio-Signature` using `publicWebhookUrl` and
The webhook route also enforces, independent of signature validation:
- `POST` only.
- Rate limit of 30 requests per minute per source IP (HTTP 429 above that).
- Failed-request budget of 300 requests per minute per SMS account, webhook route, and resolved client address. All requests count toward this budget, but HTTP 429 is applied only after a request fails body parsing, Twilio validation, or AccountSid matching.
- Dispatchable callback rate limit of 30 accepted callbacks per minute per SMS account, webhook route, and resolved client address after those checks pass (HTTP 429 above that). If signature validation is disabled, this 30/min limit is the unauthenticated dispatch cap.
- Client addresses are resolved through the shared Gateway trusted-proxy rules. If `gateway.trustedProxies` contains the reverse proxy that forwards Twilio callbacks, OpenClaw keys these limits from the forwarded client address; otherwise it falls back to the direct socket address.
- The payload `AccountSid` must match the configured `accountSid` (HTTP 403 otherwise).
- Replayed `MessageSid` values are deduplicated for 10 minutes.
- Each SMS account's replay cache retains up to 10,000 live message SIDs. When every slot is live, new webhooks for that account fail closed with HTTP 429 and a `Retry-After` header until the oldest slot expires.
+150 -3
View File
@@ -7,6 +7,7 @@ import { computeTwilioSignature, parseTwilioFormBody } from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
import {
createSmsWebhookHandler,
resetSmsWebhookRateLimiterForTest,
createSmsWebhookReplayGuard,
resetSmsWebhookReplayGuardsForTest,
} from "./webhook.js";
@@ -36,16 +37,35 @@ function createAccount(overrides: Partial<ResolvedSmsAccount> = {}): ResolvedSms
};
}
function createSignedBody(params?: {
account?: ResolvedSmsAccount;
body?: string;
messageSid?: string;
}): { body: string; signature: string } {
const account = params?.account ?? createAccount();
const body =
params?.body ??
`AccountSid=${encodeURIComponent(account.accountSid)}&From=%2B15551234567&To=%2B15557654321&Body=hello&MessageSid=${encodeURIComponent(params?.messageSid ?? "SM123")}`;
return {
body,
signature: computeTwilioSignature({
url: account.publicWebhookUrl,
authToken: account.authToken,
form: parseTwilioFormBody(body),
}),
};
}
function createRequest(
body: string,
signature: string,
remoteAddress = "127.0.0.1",
options?: { headers?: Record<string, string>; remoteAddress?: string },
): IncomingMessage {
const req = Readable.from([body]) as IncomingMessage;
req.method = "POST";
req.headers = { "x-twilio-signature": signature };
req.headers = { "x-twilio-signature": signature, ...options?.headers };
Object.defineProperty(req, "socket", {
value: { remoteAddress },
value: { remoteAddress: options?.remoteAddress ?? "127.0.0.1" },
});
return req;
}
@@ -96,6 +116,7 @@ function createMessageSid(index: number): string {
describe("createSmsWebhookHandler", () => {
beforeEach(() => {
dispatchSmsInboundEvent.mockClear();
resetSmsWebhookRateLimiterForTest();
resetSmsWebhookReplayGuardsForTest();
});
@@ -241,4 +262,130 @@ describe("createSmsWebhookHandler", () => {
expect(res.statusCode).toBe(403);
expect(dispatchSmsInboundEvent).not.toHaveBeenCalled();
});
it("does not let unsigned proxy traffic consume the same client's signed webhook rate limit", async () => {
const account = createAccount();
const handler = createSmsWebhookHandler({
cfg: { gateway: { trustedProxies: ["127.0.0.1"] } },
account,
channelRuntime: {} as SmsChannelRuntime,
});
const unsignedBody =
"AccountSid=AC123&From=%2B15550000000&To=%2B15557654321&Body=bad&MessageSid=SM-bad";
for (let i = 0; i < 300; i += 1) {
const rejected = createResponse();
await handler(
createRequest(unsignedBody, "not-a-valid-signature", {
headers: { "x-forwarded-for": "203.0.113.10" },
}),
rejected,
);
expect(rejected.statusCode).toBe(403);
}
const throttled = createResponse();
await handler(
createRequest(unsignedBody, "not-a-valid-signature", {
headers: { "x-forwarded-for": "203.0.113.10" },
}),
throttled,
);
expect(throttled.statusCode).toBe(429);
const valid = createSignedBody({ account, messageSid: "SM-valid-after-invalid-burst" });
const accepted = createResponse();
await handler(
createRequest(valid.body, valid.signature, {
headers: { "x-forwarded-for": "203.0.113.10" },
}),
accepted,
);
expect(accepted.statusCode).toBe(200);
expect(dispatchSmsInboundEvent).toHaveBeenCalledTimes(1);
});
it("scopes signed webhook rate limits to one SMS account and route", async () => {
const supportAccount = createAccount({
accountId: "support",
accountSid: "AC-support",
webhookPath: "/webhooks/sms/support",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms/support",
});
const defaultAccount = createAccount();
const supportHandler = createSmsWebhookHandler({
cfg: {},
account: supportAccount,
channelRuntime: {} as SmsChannelRuntime,
});
const defaultHandler = createSmsWebhookHandler({
cfg: {},
account: defaultAccount,
channelRuntime: {} as SmsChannelRuntime,
});
for (let i = 0; i < 30; i += 1) {
const valid = createSignedBody({
account: supportAccount,
messageSid: `SM-support-${i}`,
});
const res = createResponse();
await supportHandler(createRequest(valid.body, valid.signature), res);
expect(res.statusCode).toBe(200);
}
const rateLimited = createSignedBody({
account: supportAccount,
messageSid: "SM-support-rate-limited",
});
const rateLimitedRes = createResponse();
await supportHandler(createRequest(rateLimited.body, rateLimited.signature), rateLimitedRes);
expect(rateLimitedRes.statusCode).toBe(429);
const defaultValid = createSignedBody({
account: defaultAccount,
messageSid: "SM-default-after-support-limit",
});
const defaultRes = createResponse();
await defaultHandler(createRequest(defaultValid.body, defaultValid.signature), defaultRes);
expect(defaultRes.statusCode).toBe(200);
});
it("keeps validation-disabled webhook dispatches on the stricter callback budget", async () => {
const account = createAccount({ dangerouslyDisableSignatureValidation: true });
const handler = createSmsWebhookHandler({
cfg: { gateway: { trustedProxies: ["127.0.0.1"] } },
account,
channelRuntime: {} as SmsChannelRuntime,
});
for (let i = 0; i < 30; i += 1) {
const valid = createSignedBody({
account,
messageSid: `SM-disabled-${i}`,
});
const res = createResponse();
await handler(
createRequest(valid.body, "unused-signature", {
headers: { "x-forwarded-for": "203.0.113.20" },
}),
res,
);
expect(res.statusCode).toBe(200);
}
const overBudget = createSignedBody({
account,
messageSid: "SM-disabled-over-budget",
});
const overBudgetRes = createResponse();
await handler(
createRequest(overBudget.body, "unused-signature", {
headers: { "x-forwarded-for": "203.0.113.20" },
}),
overBudgetRes,
);
expect(overBudgetRes.statusCode).toBe(429);
expect(dispatchSmsInboundEvent).toHaveBeenCalledTimes(30);
});
});
+69 -11
View File
@@ -2,7 +2,10 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { performance } from "node:perf_hooks";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createFixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress";
import {
createFixedWindowRateLimiter,
resolveRequestClientIp,
} from "openclaw/plugin-sdk/webhook-ingress";
import { dispatchSmsInboundEvent, type SmsChannelRuntime } from "./inbound.js";
import {
buildTwilioInboundMessage,
@@ -13,8 +16,19 @@ import {
} from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
const rateLimiter = createFixedWindowRateLimiter({
maxRequests: 30,
const INVALID_REQUEST_MAX_REQUESTS = 300;
const CALLBACK_DISPATCH_MAX_REQUESTS = 30;
// Count failed-auth traffic separately from the stricter dispatchable callback quota.
// The over-budget decision is applied only after validation fails, so a same-key
// invalid burst cannot block a later valid Twilio callback before authentication.
const invalidRequestRateLimiter = createFixedWindowRateLimiter({
maxRequests: INVALID_REQUEST_MAX_REQUESTS,
windowMs: 60_000,
maxTrackedKeys: 5_000,
});
const callbackDispatchRateLimiter = createFixedWindowRateLimiter({
maxRequests: CALLBACK_DISPATCH_MAX_REQUESTS,
windowMs: 60_000,
maxTrackedKeys: 5_000,
});
@@ -112,8 +126,35 @@ function headerValue(value: string | string[] | undefined): string | undefined {
return value;
}
function rateLimitKey(req: IncomingMessage): string {
return req.socket?.remoteAddress ?? "unknown";
function resolvedClientAddress(params: { cfg: OpenClawConfig; req: IncomingMessage }): string {
return (
resolveRequestClientIp(
params.req,
params.cfg.gateway?.trustedProxies,
params.cfg.gateway?.allowRealIpFallback === true,
) ??
params.req.socket?.remoteAddress ??
"unknown"
);
}
function rateLimitKey(params: { account: ResolvedSmsAccount; clientAddress: string }): string {
return `${params.account.accountId}:${params.account.webhookPath}:${params.clientAddress}`;
}
function rejectInvalidRequestRateLimit(params: {
key: string;
log?: SmsWebhookLog;
res: ServerResponse;
}): true {
params.log?.warn?.(`SMS webhook invalid-request rate limit exceeded for ${params.key}`);
respondTwiml(params.res, 429, "Rate limit exceeded");
return true;
}
export function resetSmsWebhookRateLimiterForTest(): void {
invalidRequestRateLimiter.clear();
callbackDispatchRateLimiter.clear();
}
// Each account route owns its guard so one saturated account cannot block sibling accounts.
@@ -127,17 +168,17 @@ export function createSmsWebhookHandler(
return true;
}
const key = rateLimitKey(req);
if (rateLimiter.isRateLimited(key)) {
params.log?.warn?.(`SMS webhook rate limit exceeded for ${key}`);
respondTwiml(res, 429, "Rate limit exceeded");
return true;
}
const clientAddress = resolvedClientAddress({ cfg: params.cfg, req });
const key = rateLimitKey({ account: params.account, clientAddress });
const invalidRequestRateLimited = invalidRequestRateLimiter.isRateLimited(key);
let form: Record<string, string>;
try {
form = await readTwilioWebhookForm(req);
} catch {
if (invalidRequestRateLimited) {
return rejectInvalidRequestRateLimit({ key, log: params.log, res });
}
respondTwiml(res, 400, "Invalid request body");
return true;
}
@@ -153,6 +194,9 @@ export function createSmsWebhookHandler(
form,
});
if (!ok) {
if (invalidRequestRateLimited) {
return rejectInvalidRequestRateLimit({ key, log: params.log, res });
}
params.log?.warn?.("SMS webhook rejected invalid Twilio signature");
respondTwiml(res, 403, "Invalid signature");
return true;
@@ -161,14 +205,28 @@ export function createSmsWebhookHandler(
const msg = buildTwilioInboundMessage(form);
if (!msg) {
if (invalidRequestRateLimited) {
return rejectInvalidRequestRateLimit({ key, log: params.log, res });
}
respondTwiml(res, 400, "Missing SMS payload");
return true;
}
if (msg.accountSid && msg.accountSid !== params.account.accountSid) {
if (invalidRequestRateLimited) {
return rejectInvalidRequestRateLimit({ key, log: params.log, res });
}
params.log?.warn?.("SMS webhook rejected mismatched Twilio AccountSid");
respondTwiml(res, 403, "Invalid account");
return true;
}
if (invalidRequestRateLimited && params.account.dangerouslyDisableSignatureValidation) {
return rejectInvalidRequestRateLimit({ key, log: params.log, res });
}
if (callbackDispatchRateLimiter.isRateLimited(key)) {
params.log?.warn?.(`SMS webhook rate limit exceeded for ${key}`);
respondTwiml(res, 429, "Rate limit exceeded");
return true;
}
const replayDecision = webhookReplayGuard.remember(msg.messageSid);
if (replayDecision.kind === "replayed") {
params.log?.warn?.(`SMS webhook ignored replayed message ${msg.messageSid}`);