0c436fd73d57
fix: LV-3 canary fetch debounced per webhook+entry (1h), test pings exempt — caps page-induced ping firehose while keeping each canary live
- time
- 2026-06-10 02:39 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- audit fix integration
- parent
- 8c8a5b1478bc
↓ peach-browser-0c436fd7.tar.gzatlas at this save
files changed2 files
src/background/messages.test.ts+38 −0src/background/messages.ts+19 −0src/background/messages.test.ts+38 −0
@@ -659,6 +659,44 @@659659 });660660 });661661 662+ it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {663+ const payload = {664+ webhookUrl: "https://example.com/hook",665+ entryTitle: "GitHub",666+ trigger: "autofilled" as const,667+ };668+ const first = await dispatch({ type: "FIRE_CANARY_PING", payload });669+ expect(first).toMatchObject({ ok: true });670+ expect(fetchMock).toHaveBeenCalledTimes(1);671+672+ const second = await dispatch({ type: "FIRE_CANARY_PING", payload });673+ expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });674+ expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again675+ });676+677+ it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {678+ await dispatch({679+ type: "FIRE_CANARY_PING",680+ payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },681+ });682+ await dispatch({683+ type: "FIRE_CANARY_PING",684+ payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },685+ });686+ expect(fetchMock).toHaveBeenCalledTimes(2);687+ });688+689+ it("LV-3: test pings are exempt from the debounce", async () => {690+ const payload = {691+ webhookUrl: "https://example.com/hook",692+ entryTitle: "GitHub",693+ trigger: "test" as const,694+ };695+ await dispatch({ type: "FIRE_CANARY_PING", payload });696+ await dispatch({ type: "FIRE_CANARY_PING", payload });697+ expect(fetchMock).toHaveBeenCalledTimes(2);698+ });699+662700 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {663701 await expect(664702 dispatch({src/background/messages.ts+19 −0
@@ -133,6 +133,9 @@133133 134134 const FEEDBACK_ENDPOINT = "";135135 136+// LV-3: minimum interval between non-test canary pings per webhook+entry.137+const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour138+136139 const PRIVATE_IPV4_RANGES = [137140 { prefix: "10." },138141 { prefix: "127." },@@ -807,6 +810,22 @@807810 };808811 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {809812 return { ok: false, error: "invalid_webhook_url" };813+ }814+ // LV-3: debounce non-test pings per (webhook, entry) so a page that815+ // strobes focus/fill events can't turn the canary into a request816+ // firehose at the user's endpoint. Test pings are exempt — they are817+ // explicit user clicks in the settings UI and are awaited for818+ // feedback. The FIRST ping in a window always goes out, so the819+ // dead-man's-switch property (canary fires on access) is preserved;820+ // only repeats within the window are coalesced.821+ if (trigger !== "test") {822+ const rlKey = `canary-last-fetch:${webhookUrl}:${entryTitle}`;823+ const stored = await chrome.storage.local.get(rlKey);824+ const last = (stored as Record<string, unknown>)[rlKey];825+ if (typeof last === "number" && Date.now() - last < CANARY_MIN_INTERVAL_MS) {826+ return { ok: true, data: { rateLimited: true } };827+ }828+ await chrome.storage.local.set({ [rlKey]: Date.now() });810829 }811830 const body = JSON.stringify({812831 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",