From fb37811b656e24f6cacbfac0c28c55130cf9d5f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 10:58:36 -0400 Subject: [PATCH] fix(discord): reject unsafe retry-after delays --- extensions/discord/src/api.test.ts | 29 ++++++++++++++++++++ extensions/discord/src/api.ts | 9 ++---- extensions/discord/src/internal/rest.test.ts | 18 ++++++++++++ extensions/discord/src/retry-after.ts | 17 +++++++++--- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts index 04a9ac5fa1de..536bdd8787b7 100644 --- a/extensions/discord/src/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -103,6 +103,8 @@ describe("fetchDiscord", () => { it.each([ ["hex", "0x10"], ["fractional", "1.5"], + ["unsafe-ms", "9007199254741"], + ["unsafe-integer", "9007199254740993"], ["overflow", `1${"0".repeat(309)}`], ])("rejects invalid Retry-After header values: %s", async (_label, header) => { const fetcher = withFetchPreconnect( @@ -126,6 +128,33 @@ describe("fetchDiscord", () => { expect((error as DiscordApiError).retryAfter).toBe(60); }); + it("ignores unsafe retry_after body values and falls back to Retry-After", async () => { + const fetcher = withFetchPreconnect( + async () => + new Response( + JSON.stringify({ + message: "You are being rate limited.", + retry_after: 9_007_199_254_741, + global: false, + }), + { status: 429, headers: { "retry-after": "7" } }, + ), + ); + + let error: unknown; + try { + await fetchDiscord("/users/@me/guilds", "test", fetcher, { + retry: { attempts: 1 }, + }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(DiscordApiError); + expect((error as DiscordApiError).retryAfter).toBe(7); + expect(String(error)).not.toContain("retry after"); + }); + it("retries rate limits before succeeding", async () => { let calls = 0; const fetcher = withFetchPreconnect(async () => { diff --git a/extensions/discord/src/api.ts b/extensions/discord/src/api.ts index be9fe7ad8326..fcc311011653 100644 --- a/extensions/discord/src/api.ts +++ b/extensions/discord/src/api.ts @@ -5,7 +5,7 @@ import { type RetryConfig, } from "openclaw/plugin-sdk/retry-runtime"; import { isDiscordHtmlResponseBody, summarizeDiscordResponseBody } from "./error-body.js"; -import { parseRetryAfterHeaderSeconds } from "./retry-after.js"; +import { parseDiscordRetryAfterBodySeconds, parseRetryAfterHeaderSeconds } from "./retry-after.js"; const DISCORD_API_BASE = "https://discord.com/api/v10"; const DISCORD_API_RETRY_DEFAULTS = { @@ -41,10 +41,7 @@ function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | nul function parseRetryAfterSeconds(text: string, response: Response): number | undefined { const payload = parseDiscordApiErrorPayload(text); - const retryAfter = - payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after) - ? payload.retry_after - : undefined; + const retryAfter = parseDiscordRetryAfterBodySeconds(payload?.retry_after); if (retryAfter !== undefined) { return retryAfter; } @@ -88,7 +85,7 @@ function formatDiscordApiErrorText(text: string, response: Response): string | u ? payload.message.trim() : "unknown error"; const retryAfter = formatRetryAfterSeconds( - typeof payload.retry_after === "number" ? payload.retry_after : undefined, + parseDiscordRetryAfterBodySeconds(payload.retry_after), ); return retryAfter ? `${message} (retry after ${retryAfter})` : message; } diff --git a/extensions/discord/src/internal/rest.test.ts b/extensions/discord/src/internal/rest.test.ts index 946b1e352c16..db491c3f1415 100644 --- a/extensions/discord/src/internal/rest.test.ts +++ b/extensions/discord/src/internal/rest.test.ts @@ -620,9 +620,27 @@ describe("RequestClient", () => { await expectRateLimitError(client.get("/channels/c1/messages"), { retryAfter: 7 }); }); + it("falls back to Retry-After when the rate limit body value is unsafe", async () => { + const client = new RequestClient("test-token", { + queueRequests: false, + fetch: async () => + new Response( + JSON.stringify({ message: "Slow down", retry_after: "9007199254741", global: false }), + { + status: 429, + headers: { "Retry-After": "7" }, + }, + ), + }); + + await expectRateLimitError(client.get("/channels/c1/messages"), { retryAfter: 7 }); + }); + it.each([ ["hex", "0x10"], ["fractional", "1.5"], + ["unsafe-ms", "9007199254741"], + ["unsafe-integer", "9007199254740993"], ["overflow", `1${"0".repeat(309)}`], ])("rejects invalid Retry-After numeric strings: %s", async (_label, header) => { const client = new RequestClient("test-token", { diff --git a/extensions/discord/src/retry-after.ts b/extensions/discord/src/retry-after.ts index 92ea42019ce3..018cde01da08 100644 --- a/extensions/discord/src/retry-after.ts +++ b/extensions/discord/src/retry-after.ts @@ -1,7 +1,14 @@ +import { + asFiniteNumberInRange, + parseStrictFiniteNumber, + parseStrictNonNegativeInteger, +} from "openclaw/plugin-sdk/number-runtime"; + const RETRY_AFTER_HEADER_DELAY_RE = /^\d+$/; const RETRY_AFTER_BODY_SECONDS_RE = /^(?:\d+\.?\d*|\.\d+)$/; const RETRY_AFTER_HTTP_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/; +const MAX_SAFE_RETRY_AFTER_SECONDS = Number.MAX_SAFE_INTEGER / 1000; export function parseRetryAfterHeaderSeconds( value: string | null | undefined, @@ -12,8 +19,10 @@ export function parseRetryAfterHeaderSeconds( } const trimmed = value.trim(); if (RETRY_AFTER_HEADER_DELAY_RE.test(trimmed)) { - const delaySeconds = Number(trimmed); - return Number.isFinite(delaySeconds) ? delaySeconds : undefined; + return asFiniteNumberInRange(parseStrictNonNegativeInteger(trimmed), { + min: 0, + max: MAX_SAFE_RETRY_AFTER_SECONDS, + }); } if (!RETRY_AFTER_HTTP_DATE_RE.test(trimmed)) { return undefined; @@ -27,7 +36,7 @@ export function parseDiscordRetryAfterBodySeconds(value: unknown): number | unde typeof value === "number" ? value : typeof value === "string" && RETRY_AFTER_BODY_SECONDS_RE.test(value.trim()) - ? Number(value.trim()) + ? parseStrictFiniteNumber(value.trim()) : undefined; - return seconds !== undefined && Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined; + return asFiniteNumberInRange(seconds, { min: 0, max: MAX_SAFE_RETRY_AFTER_SECONDS }); }