Medium severity4.8NVD Advisory· Published Apr 9, 2026· Updated Apr 15, 2026
CVE-2026-35646
CVE-2026-35646
Description
OpenClaw before 2026.3.25 contains a pre-authentication rate-limit bypass vulnerability in webhook token validation that allows attackers to brute-force weak webhook secrets. The vulnerability exists because invalid webhook tokens are rejected without throttling repeated authentication attempts, enabling attackers to guess weak tokens through rapid successive requests.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
10b4d07337467synology-chat: throttle webhook token guesses (#55141)
2 files changed · +225 −1
extensions/synology-chat/src/webhook-handler.test.ts+119 −0 modified@@ -147,6 +147,125 @@ describe("createWebhookHandler", () => { expect(res._status).toBe(401); }); + it("rate limits repeated invalid token guesses before the correct token can succeed", async () => { + const weakToken = "00000129"; + const deliver = vi.fn().mockResolvedValue(null); + const handler = createWebhookHandler({ + account: makeAccount({ + accountId: "weak-token-bruteforce-" + Date.now(), + token: weakToken, + rateLimitPerMinute: 5, + }), + deliver, + log, + }); + + let guessedToken: string | null = null; + let saw429 = false; + + for (let i = 0; i < 130; i += 1) { + const candidate = String(i).padStart(8, "0"); + const req = makeReq( + "POST", + makeFormBody({ + token: candidate, + user_id: "123", + username: "testuser", + text: "Hello bot", + }), + ); + (req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10"; + const res = makeRes(); + await handler(req, res); + + if (res._status === 429) { + saw429 = true; + break; + } + + if (res._status === 204) { + guessedToken = candidate; + break; + } + + expect(res._status).toBe(401); + } + + expect(saw429).toBe(true); + expect(guessedToken).toBeNull(); + const lockedReq = makeReq( + "POST", + makeFormBody({ + token: weakToken, + user_id: "123", + username: "testuser", + text: "Hello bot", + }), + ); + (lockedReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10"; + const lockedRes = makeRes(); + await handler(lockedReq, lockedRes); + + expect(lockedRes._status).toBe(429); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("keeps pre-auth throttling scoped to the remote IP", async () => { + const deliver = vi.fn().mockResolvedValue(null); + const handler = createWebhookHandler({ + account: makeAccount({ + accountId: "preauth-ip-scope-" + Date.now(), + rateLimitPerMinute: 1, + }), + deliver, + log, + }); + + const invalidReq = makeReq( + "POST", + makeFormBody({ + token: "wrong-token", + user_id: "123", + username: "testuser", + text: "Hello", + }), + ); + (invalidReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10"; + const invalidRes = makeRes(); + await handler(invalidReq, invalidRes); + expect(invalidRes._status).toBe(401); + + const validReq = makeReq("POST", validBody); + (validReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.11"; + const validRes = makeRes(); + await handler(validReq, validRes); + + expect(validRes._status).toBe(204); + expect(deliver).toHaveBeenCalledTimes(1); + }); + + it("does not spend invalid-token budget on successful requests", async () => { + const deliver = vi.fn().mockResolvedValue(null); + const handler = createWebhookHandler({ + account: makeAccount({ + accountId: "invalid-token-budget-" + Date.now(), + rateLimitPerMinute: 30, + }), + deliver, + log, + }); + + for (let i = 0; i < 11; i += 1) { + const req = makeReq("POST", validBody); + (req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.20"; + const res = makeRes(); + await handler(req, res); + expect(res._status).toBe(204); + } + + expect(deliver).toHaveBeenCalledTimes(11); + }); + it("accepts application/json with alias fields", async () => { const deliver = vi.fn().mockResolvedValue(null); const handler = createWebhookHandler({
extensions/synology-chat/src/webhook-handler.ts+106 −1 modified@@ -16,8 +16,77 @@ import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./type // One rate limiter per account, created lazily const rateLimiters = new Map<string, RateLimiter>(); +const invalidTokenRateLimiters = new Map<string, InvalidTokenRateLimiter>(); const PREAUTH_MAX_BODY_BYTES = 64 * 1024; const PREAUTH_BODY_TIMEOUT_MS = 5_000; +const PREAUTH_MAX_REQUESTS_PER_MINUTE = 10; +const INVALID_TOKEN_WINDOW_MS = 60_000; +const INVALID_TOKEN_MAX_TRACKED_KEYS = 5_000; + +type InvalidTokenRateLimitState = { + count: number; + windowStartMs: number; +}; + +class InvalidTokenRateLimiter { + private readonly limit: number; + private readonly state = new Map<string, InvalidTokenRateLimitState>(); + + constructor(limit: number) { + this.limit = limit; + } + + private normalizeState(key: string, nowMs: number): InvalidTokenRateLimitState | undefined { + const existing = this.state.get(key); + if (!existing) { + return undefined; + } + if (nowMs - existing.windowStartMs >= INVALID_TOKEN_WINDOW_MS) { + this.state.delete(key); + return undefined; + } + return existing; + } + + private touch(key: string, value: InvalidTokenRateLimitState): void { + this.state.delete(key); + this.state.set(key, value); + while (this.state.size > INVALID_TOKEN_MAX_TRACKED_KEYS) { + const oldestKey = this.state.keys().next().value; + if (!oldestKey) { + break; + } + this.state.delete(oldestKey); + } + } + + isLocked(key: string, nowMs = Date.now()): boolean { + if (!key) { + return false; + } + const existing = this.normalizeState(key, nowMs); + return (existing?.count ?? 0) > this.limit; + } + + recordFailure(key: string, nowMs = Date.now()): boolean { + if (!key) { + return false; + } + const existing = this.normalizeState(key, nowMs); + const nextCount = (existing?.count ?? 0) + 1; + const windowStartMs = existing?.windowStartMs ?? nowMs; + this.touch(key, { count: nextCount, windowStartMs }); + return nextCount > this.limit; + } + + clear(): void { + this.state.clear(); + } + + maxRequests(): number { + return this.limit; + } +} function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter { let rl = rateLimiters.get(account.accountId); @@ -29,15 +98,34 @@ function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter { return rl; } +function getInvalidTokenRateLimiter(account: ResolvedSynologyChatAccount): InvalidTokenRateLimiter { + const limit = Math.min(account.rateLimitPerMinute, PREAUTH_MAX_REQUESTS_PER_MINUTE); + let rl = invalidTokenRateLimiters.get(account.accountId); + if (!rl || rl.maxRequests() !== limit) { + rl?.clear(); + rl = new InvalidTokenRateLimiter(limit); + invalidTokenRateLimiters.set(account.accountId, rl); + } + return rl; +} + export function clearSynologyWebhookRateLimiterStateForTest(): void { for (const limiter of rateLimiters.values()) { limiter.clear(); } rateLimiters.clear(); + for (const limiter of invalidTokenRateLimiters.values()) { + limiter.clear(); + } + invalidTokenRateLimiters.clear(); } export function getSynologyWebhookRateLimiterCountForTest(): number { - return rateLimiters.size; + return rateLimiters.size + invalidTokenRateLimiters.size; +} + +function getSynologyWebhookInvalidTokenRateLimitKey(req: IncomingMessage): string { + return req.socket?.remoteAddress ?? "unknown"; } /** Read the full request body as a string. */ @@ -281,10 +369,22 @@ function authorizeSynologyWebhook(params: { req: IncomingMessage; account: ResolvedSynologyChatAccount; payload: SynologyWebhookPayload; + invalidTokenRateLimiter: InvalidTokenRateLimiter; rateLimiter: RateLimiter; log?: WebhookHandlerDeps["log"]; }): SynologyWebhookAuthorization { + const invalidTokenRateLimitKey = getSynologyWebhookInvalidTokenRateLimitKey(params.req); + // Once a source has exhausted its invalid-token budget, reject all requests in the window. + if (params.invalidTokenRateLimiter.isLocked(invalidTokenRateLimitKey)) { + params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`); + return { ok: false, statusCode: 429, error: "Rate limit exceeded" }; + } + if (!validateToken(params.payload.token, params.account.token)) { + if (params.invalidTokenRateLimiter.recordFailure(invalidTokenRateLimitKey)) { + params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`); + return { ok: false, statusCode: 429, error: "Rate limit exceeded" }; + } params.log?.warn(`Invalid token from ${params.req.socket?.remoteAddress}`); return { ok: false, statusCode: 401, error: "Invalid token" }; } @@ -313,6 +413,7 @@ function authorizeSynologyWebhook(params: { } if (!params.rateLimiter.check(params.payload.user_id)) { + // Keep a separate post-auth budget so authenticated users are still throttled per sender. params.log?.warn(`Rate limit exceeded for user: ${params.payload.user_id}`); return { ok: false, statusCode: 429, error: "Rate limit exceeded" }; } @@ -332,6 +433,7 @@ async function parseAndAuthorizeSynologyWebhook(params: { req: IncomingMessage; res: ServerResponse; account: ResolvedSynologyChatAccount; + invalidTokenRateLimiter: InvalidTokenRateLimiter; rateLimiter: RateLimiter; log?: WebhookHandlerDeps["log"]; }): Promise<{ ok: false } | { ok: true; message: AuthorizedSynologyWebhook }> { @@ -344,6 +446,7 @@ async function parseAndAuthorizeSynologyWebhook(params: { req: params.req, account: params.account, payload: parsed.payload, + invalidTokenRateLimiter: params.invalidTokenRateLimiter, rateLimiter: params.rateLimiter, log: params.log, }); @@ -453,6 +556,7 @@ async function processAuthorizedSynologyWebhook(params: { export function createWebhookHandler(deps: WebhookHandlerDeps) { const { account, deliver, log } = deps; const rateLimiter = getRateLimiter(account); + const invalidTokenRateLimiter = getInvalidTokenRateLimiter(account); return async (req: IncomingMessage, res: ServerResponse) => { // Only accept POST @@ -464,6 +568,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { req, res, account, + invalidTokenRateLimiter, rateLimiter, log, });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/0b4d07337467f4d40a0cc1ced83d45ceaec0863cnvdPatchWEB
- github.com/advisories/GHSA-mf5g-6r6f-ghhmghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-mf5g-6r6f-ghhmnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-35646ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-pre-authentication-rate-limit-bypass-in-webhook-token-validationnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.