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
2 files src/background/messages.test.ts+38 −0src/background/messages.ts+19 −0
src/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({

view file

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",

view file