VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.282026.3.28

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.25

Patches

1
0b4d07337467

synology-chat: throttle webhook token guesses (#55141)

https://github.com/openclaw/openclawJacob TomlinsonMar 26, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.