From 99e6179c84ba231b99ee4164da641f4902c5a8ed Mon Sep 17 00:00:00 2001 From: mdenner1234 Date: Thu, 20 Aug 2026 12:28:23 -0700 Subject: [PATCH] =?UTF-8?q?fix(discord):=20/fleety=20was=20blocked=20for?= =?UTF-8?q?=20everyone=20=E2=80=94=20wrong=20(auth-only)=20rate=20limiter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the /fleety adapter's underRateLimit called public.check_rate_limit, which is hardened for AUTH flows only — it rejects any identifier that isn't a 64-char SHA-256 hash (migration 20260606…, line 16) AND any action outside its login/signup/reset whitelist (line 20). Called with `discord:` + action "fleety", it returned {allowed:false} on EVERY invocation, so /fleety only ever posted "🚦 You've asked Fleety a lot in the last hour" and never reached the answer path — for days, for all users. Fix: point underRateLimit at the GENERIC public.check_edge_rate_limit (the same limiter fleety-extract + other edge endpoints use). It hashes the identifier itself, accepts any action, and is a clean sliding-window counter (no stuck-block state). Kept fail-open on error. Bumped the cap 10 -> 20 per hour (owner had flagged 10 as too tight for real use). Guards: - src/test/smoke/fleety-discord-ratelimit.smoke.test.ts (3 cases): asserts the adapter uses check_edge_rate_limit, NOT check_rate_limit, and still fails open. - BDD @regression scenario in fleety-2.1-discord.feature. Follow-on (config, owner): once deployed, /fleety passes the limiter and calls the 2.0 brain via FLEETY_INTERNAL_SECRET. If that secret is unset, members will now see the generic "⚠️ error" instead — that's the remaining 2.1 config step, not this bug. Co-Authored-By: Claude Opus 4.8 --- .../fleety-discord-ratelimit.smoke.test.ts | 29 +++++++++++++++++++ .../fleety-2.1-discord.feature | 9 ++++++ .../functions/discord-interactions/index.ts | 17 ++++++----- 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 src/test/smoke/fleety-discord-ratelimit.smoke.test.ts diff --git a/src/test/smoke/fleety-discord-ratelimit.smoke.test.ts b/src/test/smoke/fleety-discord-ratelimit.smoke.test.ts new file mode 100644 index 00000000..368c1a04 --- /dev/null +++ b/src/test/smoke/fleety-discord-ratelimit.smoke.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(__dirname, "../../.."); +const read = (p: string) => readFileSync(resolve(root, p), "utf8"); + +/** + * Regression guard for the Discord /fleety outage (2026-08): the adapter's rate limiter called + * the AUTH-ONLY check_rate_limit, which rejects any identifier that isn't a 64-char SHA-256 hash + * and any action outside its login/signup/reset whitelist. Passing `discord:` + "fleety" made + * it return {allowed:false} on EVERY call, so /fleety only ever posted "you've asked a lot in the + * last hour" and never answered — for days. It must use the GENERIC check_edge_rate_limit. + */ +describe("discord /fleety uses the generic edge rate limiter", () => { + const src = read("supabase/functions/discord-interactions/index.ts"); + + it("calls check_edge_rate_limit (generic, accepts any identifier/action)", () => { + expect(src).toMatch(/rpc\(\s*["']check_edge_rate_limit["']/); + }); + + it("does NOT call the auth-only check_rate_limit (it would block every /fleety)", () => { + expect(src).not.toMatch(/rpc\(\s*["']check_rate_limit["']/); + }); + + it("still fails OPEN so a limiter error never blocks a real question", () => { + expect(src).toMatch(/return true; \/\/ fail open/); + }); +}); diff --git a/supabase/functions/discord-interactions/fleety-2.1-discord.feature b/supabase/functions/discord-interactions/fleety-2.1-discord.feature index 88462d23..2385da73 100644 --- a/supabase/functions/discord-interactions/fleety-2.1-discord.feature +++ b/supabase/functions/discord-interactions/fleety-2.1-discord.feature @@ -44,6 +44,15 @@ Feature: Discord /fleety on the unified 2.0 brain (Fleety 2.1) When they exceed the per-user hourly limit Then further calls are refused with a friendly notice before any 2.0 call + @security @regression + Scenario: A normal user under the limit is NOT falsely throttled + Given a Discord user asking their first /fleety question of the hour + When the adapter checks the rate limit + Then it uses the generic edge limiter (check_edge_rate_limit), which accepts a "discord:" + And the call is allowed through to the 2.0 brain + # Guards the 2026-08 outage: the auth-only check_rate_limit rejected the non-hashed identifier + # and the "fleety" action, returning allowed=false on EVERY call — /fleety answered nobody. + # ── Untrusted content / prompt injection / output (ai-llm-agent-security) ── @security Scenario: Discord message content is subject to the 2.0 injection + scope gates diff --git a/supabase/functions/discord-interactions/index.ts b/supabase/functions/discord-interactions/index.ts index 0917c605..de9fd085 100644 --- a/supabase/functions/discord-interactions/index.ts +++ b/supabase/functions/discord-interactions/index.ts @@ -65,17 +65,20 @@ const SB_ANON_KEY = async function underRateLimit(discordUserId: string): Promise { try { const sb = createClient(SB_URL, SB_SERVICE_ROLE_KEY); - const { data, error } = await sb.rpc("check_rate_limit", { + // Use the GENERIC edge limiter (check_edge_rate_limit), NOT the auth-only check_rate_limit. + // check_rate_limit is hardened to auth flows: it rejects any identifier that isn't a 64-char + // SHA-256 hash AND any action outside its login/signup/reset whitelist — so calling it with + // `discord:` + "fleety" returned {allowed:false} on EVERY call, silently killing /fleety + // (members only ever saw "you've asked a lot in the last hour"). check_edge_rate_limit hashes + // the identifier itself, accepts any action, and is a clean sliding window (no stuck block). + const { data, error } = await sb.rpc("check_edge_rate_limit", { p_identifier: `discord:${discordUserId}`, p_action: "fleety", - p_max_attempts: 10, + p_max: 20, p_window_minutes: 60, - p_block_minutes: 15, }); - if (error) return true; // fail open - return typeof data === "object" && data !== null - ? (data as { allowed?: boolean }).allowed !== false - : data !== false; + if (error) return true; // fail open — a limiter error must never block a real question + return (data as { allowed?: boolean } | null)?.allowed !== false; } catch { return true; }