7cf0e6d4b0c4

fix: canary webhook filter rejects IPv4-mapped IPv6 private literals (v4.8.4)

Codex Security 2026-08-04 LOW (ssrf.webhook-url-private-ip-filter, CWE-918): isPrivateIpv6 matched only ::1/fc*/fd*/fe80:*, so IPv4-mapped IPv6 literals (WHATWG URL serializes [::ffff:192.168.0.1] as ::ffff:c0a8:1) sailed through isAllowedCanaryWebhookUrl and were fetched by FIRE_CANARY_PING — a blind SSRF onto the user's LAN (requires a prior write primitive to plant the URL). Fix: isPrivateIpv6 unwraps ::ffff:xxyy:zzww (and dotted) mapped forms and runs the embedded IPv4 through isPrivateIpv4; unparseable mapped tails fail closed. DNS rebinding remains out of scope for a one-shot host check. Test: six mapped-private literals rejected, no fetch issued.

time
2026-08-04 14:36 UTC · 2 months ago
machine
koh2
parent
2945bb7a571a
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+20 −0src/background/messages.ts+22 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.8.3",4+  "version": "4.8.4",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.8.3",5+  "version": "4.8.4",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.8.3",3+  "version": "4.8.4",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.test.ts+20 −0
@@ -719,6 +719,26 @@719719     expect(fetchMock).not.toHaveBeenCalled();720720   });721721 722+  it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {723+    for (const webhookUrl of [724+      "https://[::ffff:192.168.0.1]/hook",725+      "https://[::ffff:c0a8:1]/hook",726+      "https://[::ffff:10.0.0.1]/hook",727+      "https://[::ffff:127.0.0.1]/hook",728+      "https://[::ffff:169.254.1.1]/hook",729+      "https://[::ffff:172.16.0.1]/hook",730+    ]) {731+      await expect(732+        dispatch({733+          type: "FIRE_CANARY_PING",734+          payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },735+        })736+      ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });737+    }738+739+    expect(fetchMock).not.toHaveBeenCalled();740+  });741+722742   it("GET_IDENTITIES when locked returns vault locked error", async () => {723743     const vault = await import("./vault");724744     await vault.createVault("correct-pass-12345");

view file

src/background/messages.ts+22 −0
@@ -1770,6 +1770,28 @@17701770 17711771 function isPrivateIpv6(hostname: string): boolean {17721772   const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();1773+  // IPv4-mapped IPv6 literals embed a plain IPv4 address that the prefix1774+  // list below can never match (WHATWG URL serializes [::ffff:192.168.0.1]1775+  // as ::ffff:c0a8:1). Unwrap and test the embedded IPv4; unparseable mapped1776+  // tails fail closed. (Codex Security 2026-08-04 LOW,1777+  // ssrf.webhook-url-private-ip-filter.) DNS rebinding is out of scope for a1778+  // one-shot host check.1779+  const mapped = /^::ffff:(.+)$/.exec(normalized);1780+  if (mapped) {1781+    const tail = mapped[1]!;1782+    if (tail.includes(".")) return isPrivateIpv4(tail);1783+    const parts = tail.split(":");1784+    if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {1785+      return true;1786+    }1787+    const octets = [1788+      (parseInt(parts[0]!, 16) >>> 8) & 0xff,1789+      parseInt(parts[0]!, 16) & 0xff,1790+      (parseInt(parts[1]!, 16) >>> 8) & 0xff,1791+      parseInt(parts[1]!, 16) & 0xff,1792+    ];1793+    return isPrivateIpv4(octets.join("."));1794+  }17731795   return (17741796     normalized === "::1" ||17751797     normalized.startsWith("fc") ||

view file