VYPR
Medium severity5.3NVD Advisory· Published Apr 23, 2026· Updated Apr 29, 2026

CVE-2026-41346

CVE-2026-41346

Description

OpenClaw 2026.2.26 before 2026.3.31 enforces pending pairing-request caps per channel file instead of per account, allowing attackers to exhaust the shared pending window. Remote attackers can submit pairing requests from other accounts to block new pairing challenges on unaffected accounts, causing denial of service.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.2.26, < 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: >=2026.2.26,<2026.3.31

Patches

1
9bc1f896c8cd

fix(pairing): scope pending request caps per account (#58239)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via ghsa
3 files changed · +121 12
  • CHANGELOG.md+1 0 modified
    @@ -147,6 +147,7 @@ Docs: https://docs.openclaw.ai
     - Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant.
     - Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.
     - Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.
    +- Pairing: enforce pending request limits per account instead of per shared channel queue, so one account's outstanding pairing challenges no longer block new pairing on other accounts. Thanks @smaeljaish771 and @vincentkoc.
     - Exec approvals: unwrap `caffeinate` and `sandbox-exec` before persisting allow-always trust so later shell payload changes still require a fresh approval. Thanks @tdjackey and @vincentkoc.
     
     ## 2026.3.28
    
  • src/pairing/pairing-store.test.ts+77 0 modified
    @@ -359,6 +359,51 @@ describe("pairing store", () => {
             });
           },
         },
    +    {
    +      name: "counts legacy default-account pending requests before admitting a new one",
    +      run: async () => {
    +        await withTempStateDir(async (stateDir) => {
    +          const createdAt = new Date().toISOString();
    +          await writeJsonFixture(resolvePairingFilePath(stateDir, "demo-pairing-c"), {
    +            version: 1,
    +            requests: [
    +              {
    +                id: "+15550000001",
    +                code: "AAAAAAAB",
    +                createdAt,
    +                lastSeenAt: createdAt,
    +              },
    +              {
    +                id: "+15550000002",
    +                code: "AAAAAAAC",
    +                createdAt,
    +                lastSeenAt: createdAt,
    +              },
    +              {
    +                id: "+15550000003",
    +                code: "AAAAAAAD",
    +                createdAt,
    +                lastSeenAt: createdAt,
    +              },
    +            ],
    +          });
    +
    +          const blocked = await upsertChannelPairingRequest({
    +            channel: "demo-pairing-c",
    +            id: "+15550000004",
    +            accountId: DEFAULT_ACCOUNT_ID,
    +          });
    +          expect(blocked.created).toBe(false);
    +
    +          const list = await listChannelPairingRequests("demo-pairing-c");
    +          expect(list.map((entry) => entry.id)).toEqual([
    +            "+15550000001",
    +            "+15550000002",
    +            "+15550000003",
    +          ]);
    +        });
    +      },
    +    },
       ] as const)("$name", async ({ run }) => {
         await expectPairingRequestStateCase({ run });
       });
    @@ -573,6 +618,38 @@ describe("pairing store", () => {
             });
           },
         },
    +    {
    +      name: "does not block a new account when other accounts already filled their own pending slots",
    +      run: async () => {
    +        await withTempStateDir(async () => {
    +          for (const accountId of ["alpha", "beta", "gamma"]) {
    +            const created = await upsertChannelPairingRequest({
    +              channel: "telegram",
    +              accountId,
    +              id: `pending-${accountId}`,
    +            });
    +            expect(created.created).toBe(true);
    +          }
    +
    +          const delta = await upsertChannelPairingRequest({
    +            channel: "telegram",
    +            accountId: "delta",
    +            id: "pending-delta",
    +          });
    +          expect(delta.created).toBe(true);
    +
    +          const deltaList = await listChannelPairingRequests("telegram", process.env, "delta");
    +          const allPending = await listChannelPairingRequests("telegram");
    +          expect(deltaList.map((entry) => entry.id)).toEqual(["pending-delta"]);
    +          expect(allPending.map((entry) => entry.id)).toEqual([
    +            "pending-alpha",
    +            "pending-beta",
    +            "pending-gamma",
    +            "pending-delta",
    +          ]);
    +        });
    +      },
    +    },
       ] as const)("$name", async ({ run }) => {
         await expectPairingRequestStateCase({ run });
       });
    
  • src/pairing/pairing-store.ts+43 12 modified
    @@ -193,12 +193,44 @@ function resolveLastSeenAt(entry: PairingRequest): number {
       return parseTimestamp(entry.lastSeenAt) ?? parseTimestamp(entry.createdAt) ?? 0;
     }
     
    -function pruneExcessRequests(reqs: PairingRequest[], maxPending: number) {
    +function resolvePairingRequestAccountId(entry: PairingRequest): string {
    +  return normalizePairingAccountId(String(entry.meta?.accountId ?? "")) || DEFAULT_ACCOUNT_ID;
    +}
    +
    +function pruneExcessRequestsByAccount(reqs: PairingRequest[], maxPending: number) {
       if (maxPending <= 0 || reqs.length <= maxPending) {
         return { requests: reqs, removed: false };
       }
    -  const sorted = reqs.slice().toSorted((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b));
    -  return { requests: sorted.slice(-maxPending), removed: true };
    +  const grouped = new Map<string, number[]>();
    +  for (const [index, entry] of reqs.entries()) {
    +    const accountId = resolvePairingRequestAccountId(entry);
    +    const current = grouped.get(accountId);
    +    if (current) {
    +      current.push(index);
    +      continue;
    +    }
    +    grouped.set(accountId, [index]);
    +  }
    +
    +  const droppedIndexes = new Set<number>();
    +  for (const indexes of grouped.values()) {
    +    if (indexes.length <= maxPending) {
    +      continue;
    +    }
    +    const sortedIndexes = indexes
    +      .slice()
    +      .toSorted((left, right) => resolveLastSeenAt(reqs[left]) - resolveLastSeenAt(reqs[right]));
    +    for (const index of sortedIndexes.slice(0, sortedIndexes.length - maxPending)) {
    +      droppedIndexes.add(index);
    +    }
    +  }
    +  if (droppedIndexes.size === 0) {
    +    return { requests: reqs, removed: false };
    +  }
    +  return {
    +    requests: reqs.filter((_, index) => !droppedIndexes.has(index)),
    +    removed: true,
    +  };
     }
     
     function randomCode(): string {
    @@ -229,11 +261,7 @@ function requestMatchesAccountId(entry: PairingRequest, normalizedAccountId: str
       if (!normalizedAccountId) {
         return true;
       }
    -  return (
    -    String(entry.meta?.accountId ?? "")
    -      .trim()
    -      .toLowerCase() === normalizedAccountId
    -  );
    +  return resolvePairingRequestAccountId(entry) === normalizedAccountId;
     }
     
     function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean {
    @@ -666,7 +694,7 @@ export async function listChannelPairingRequests(
         async () => {
           const { requests: prunedExpired, removed: expiredRemoved } =
             await readPrunedPairingRequests(filePath);
    -      const { requests: pruned, removed: cappedRemoved } = pruneExcessRequests(
    +      const { requests: pruned, removed: cappedRemoved } = pruneExcessRequestsByAccount(
             prunedExpired,
             PAIRING_PENDING_MAX,
           );
    @@ -757,20 +785,23 @@ export async function upsertChannelPairingRequest(params: {
               meta: meta ?? existing?.meta,
             };
             reqs[existingIdx] = next;
    -        const { requests: capped } = pruneExcessRequests(reqs, PAIRING_PENDING_MAX);
    +        const { requests: capped } = pruneExcessRequestsByAccount(reqs, PAIRING_PENDING_MAX);
             await writeJsonFile(filePath, {
               version: 1,
               requests: capped,
             } satisfies PairingStore);
             return { code, created: false };
           }
     
    -      const { requests: capped, removed: cappedRemoved } = pruneExcessRequests(
    +      const { requests: capped, removed: cappedRemoved } = pruneExcessRequestsByAccount(
             reqs,
             PAIRING_PENDING_MAX,
           );
           reqs = capped;
    -      if (PAIRING_PENDING_MAX > 0 && reqs.length >= PAIRING_PENDING_MAX) {
    +      const accountRequestCount = reqs.filter((r) =>
    +        requestMatchesAccountId(r, normalizedMatchingAccountId),
    +      ).length;
    +      if (PAIRING_PENDING_MAX > 0 && accountRequestCount >= PAIRING_PENDING_MAX) {
             if (expiredRemoved || cappedRemoved) {
               await writeJsonFile(filePath, {
                 version: 1,
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

5

News mentions

0

No linked articles in our index yet.