4620e8e0a3ac

fix: MV-6 import whitelist — sanitizeRestoredEntry routed through normalizeEntry, nested passkey/canary shape sanitizers, peachPayload passkey blob field picker

time
2026-06-10 02:22 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
a207e6c140c2
3 files src/background/vault.test.ts+122 −0src/background/vault.ts+105 −22src/crypto/peachPayload.ts+21 −1
src/background/vault.test.ts+122 −0
@@ -1177,4 +1177,126 @@11771177       expect(hist[hist.length - 1].password).toBe("pw-5");11781178     });11791179   });1180+1181+  describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1182+    it("drops unknown/attacker keys from a restored entry", async () => {1183+      const vault = await import("./vault");1184+      await vault.createVault("correct horse battery staple");1185+      await vault.restoreEntries(1186+        [1187+          {1188+            id: "e1",1189+            type: "login",1190+            title: "Bank",1191+            username: "june",1192+            password: "pw",1193+            createdAt: 1,1194+            modifiedAt: 2,1195+            evil: "payload",1196+            isAdmin: true,1197+            nested: { junk: 1 },1198+          } as unknown as import("../shared/types").VaultEntry,1199+        ],1200+        "replace",1201+      );1202+      const restored = vault.getEntries().find((e) => e.id === "e1")!;1203+      expect(restored.password).toBe("pw");1204+      expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1205+      expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1206+      expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1207+    });1208+1209+    it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1210+      const vault = await import("./vault");1211+      await vault.createVault("correct horse battery staple");1212+      await vault.restoreEntries(1213+        [1214+          {1215+            id: "e2",1216+            type: "login",1217+            title: "Bank",1218+            password: "pw",1219+            createdAt: 1,1220+            modifiedAt: 2,1221+            canary: { webhookUrl: "http://attacker.example.com/x" },1222+          } as unknown as import("../shared/types").VaultEntry,1223+        ],1224+        "replace",1225+      );1226+      const restored = vault.getEntries().find((e) => e.id === "e2")!;1227+      expect(restored).toBeDefined();1228+      expect(restored.canary).toBeUndefined();1229+    });1230+1231+    it("preserves a well-formed HTTPS canary webhook", async () => {1232+      const vault = await import("./vault");1233+      await vault.createVault("correct horse battery staple");1234+      await vault.restoreEntries(1235+        [1236+          {1237+            id: "e3",1238+            type: "login",1239+            title: "Bank",1240+            password: "pw",1241+            createdAt: 1,1242+            modifiedAt: 2,1243+            canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1244+          } as unknown as import("../shared/types").VaultEntry,1245+        ],1246+        "replace",1247+      );1248+      const restored = vault.getEntries().find((e) => e.id === "e3")!;1249+      expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1250+      expect(restored.canary?.label).toBe("watch");1251+    });1252+1253+    it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1254+      const vault = await import("./vault");1255+      await vault.createVault("correct horse battery staple");1256+      await vault.restoreEntries(1257+        [1258+          {1259+            id: "e4",1260+            type: "login",1261+            title: "Has bad passkey",1262+            createdAt: 1,1263+            modifiedAt: 2,1264+            passkey: { rpId: "example.com", publicKeyCose: "x" },1265+          } as unknown as import("../shared/types").VaultEntry,1266+        ],1267+        "replace",1268+      );1269+      expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1270+    });1271+1272+    it("migrates a pre-v2.11 flat-passkey backup entry into the passkey sub-object", async () => {1273+      const vault = await import("./vault");1274+      await vault.createVault("correct horse battery staple");1275+      await vault.restoreEntries(1276+        [1277+          {1278+            id: "e5",1279+            type: "passkey",1280+            title: "Legacy Passkey",1281+            createdAt: 1,1282+            modifiedAt: 2,1283+            rpId: "example.com",1284+            rpName: "Example",1285+            credentialId: "cred-123",1286+            publicKeyCose: "cose-blob",1287+            userHandle: "uh",1288+            userName: "june",1289+            userDisplayName: "June",1290+            algorithm: -7,1291+          } as unknown as import("../shared/types").VaultEntry,1292+        ],1293+        "replace",1294+      );1295+      const restored = vault.getEntries().find((e) => e.id === "e5")!;1296+      expect(restored).toBeDefined();1297+      expect(restored.passkey?.credentialId).toBe("cred-123");1298+      // Flat legacy fields are not kept as top-level keys.1299+      expect((restored as unknown as Record<string, unknown>).credentialId).toBeUndefined();1300+    });1301+  });11801302 });

view file

src/background/vault.ts+105 −22
@@ -20,8 +20,10 @@2020 } from "../shared/recovery-words";2121 import type {2222   AccentColorName,23+  Canary,2324   LegacyVaultEntry,2425   ManualSummonRecord,26+  PasskeyData,2527   SavedIdentity,2628   StoredVault,2729   VaultEntry,@@ -1529,9 +1531,11 @@15291531 }15301532 15311533 /**1532- * Validate + normalise a VaultEntry coming from a backup. Accepts1533- * anything structurally sound; keeps all metadata verbatim. Returns1534- * null for values the live vault shouldn't accept (e.g., missing title).1534+ * Validate + normalise a VaultEntry coming from a backup, import, or sync1535+ * peer. MV-6: this is a strict field WHITELIST — `normalizeEntry` copies1536+ * only known VaultEntry fields (unknown/attacker keys are dropped), and1537+ * nested `passkey`/`canary` shapes are rebuilt field-by-field, never1538+ * spread. Returns null for values the live vault shouldn't accept.15351539  */15361540 function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {15371541   if (!entry || typeof entry !== "object") return null;@@ -1542,35 +1546,114 @@15421546   const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;15431547   const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15441548 1545-  // Migrate legacy passkey shape on import (same logic as on-disk read).1546-  // Imports from pre-v2.11.0 vault backups still carry type:"passkey" with1547-  // flat fields; bring them into the new shape immediately.1548-  const migrated: VaultEntry = migrateLegacyPasskey(1549-    {1550-      ...entry,1551-      id,1552-      title,1553-      type: entry.type ?? "login",1554-      createdAt,1555-      modifiedAt,1556-    },1557-    entry1558-  );1549+  // normalizeEntry whitelists every known VaultEntry field and runs the1550+  // same legacy-passkey migration as the on-disk read path — pre-v2.11.01551+  // backups with flat passkey fields still import correctly because the1552+  // legacy flat fields are themselves whitelisted VaultEntry members.1553+  const normalized = normalizeEntry({1554+    ...entry,1555+    id,1556+    title,1557+    type: entry.type ?? "login",1558+    createdAt,1559+    modifiedAt,1560+  });15591561 1562+  if (normalized.passkey) {1563+    normalized.passkey = sanitizePasskey(normalized.passkey);1564+  }1565+  if (normalized.canary) {1566+    normalized.canary = sanitizeCanary(normalized.canary);1567+  }1568+15601569   // Login entries from backup must carry a password (or be passkey-only,15611570   // in which case the passkey sub-object replaces the password requirement).15621571   if (1563-    migrated.type === "login" &&1564-    !migrated.passkey &&1565-    typeof migrated.password !== "string"1572+    normalized.type === "login" &&1573+    !normalized.passkey &&1574+    typeof normalized.password !== "string"15661575   ) {15671576     return null;15681577   }1569-  if (migrated.passkey && !migrated.passkey.credentialId) {1578+  if (entry.passkey && !normalized.passkey) {1579+    // Entry claimed a passkey but the shape didn't validate — refuse the1580+    // whole entry rather than silently importing a credential-less husk.15701581     return null;15711582   }15721583 1573-  return migrated;1584+  return normalized;1585+}1586+1587+/**1588+ * Rebuild a PasskeyData object from untrusted input, field by field.1589+ * Returns undefined when required fields are missing/mistyped — a rogue1590+ * passkey credential must not activate via import (MV-6 attack scenario).1591+ */1592+function sanitizePasskey(pk: unknown): PasskeyData | undefined {1593+  if (!pk || typeof pk !== "object") return undefined;1594+  const r = pk as Record<string, unknown>;1595+  if (1596+    typeof r.rpId !== "string" || !r.rpId ||1597+    typeof r.credentialId !== "string" || !r.credentialId ||1598+    typeof r.publicKeyCose !== "string" ||1599+    typeof r.userHandle !== "string" ||1600+    typeof r.userName !== "string"1601+  ) {1602+    return undefined;1603+  }1604+  return {1605+    rpId: r.rpId,1606+    rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1607+    credentialId: r.credentialId,1608+    publicKeyCose: r.publicKeyCose,1609+    privateKeyJwk:1610+      r.privateKeyJwk && typeof r.privateKeyJwk === "object"1611+        ? (r.privateKeyJwk as JsonWebKey)1612+        : undefined,1613+    userHandle: r.userHandle,1614+    userName: r.userName,1615+    userDisplayName:1616+      typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1617+    algorithm: r.algorithm === -8 ? -8 : -7,1618+    signCount: typeof r.signCount === "number" ? r.signCount : 0,1619+    aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1620+    backupEligible: r.backupEligible === true,1621+    backupState: r.backupState === true,1622+    lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1623+    browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1624+    transports: Array.isArray(r.transports)1625+      ? r.transports.filter((t): t is string => typeof t === "string")1626+      : undefined,1627+    createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1628+  };1629+}1630+1631+/**1632+ * Rebuild a Canary object from untrusted input. The webhook must be a1633+ * well-formed HTTPS URL — a poisoned canary.webhookUrl is a standing1634+ * exfiltration endpoint that the canary subsystem will POST entry titles1635+ * to (MV-6 attack scenario). Invalid → the canary is dropped, not the1636+ * entry. FIRE_CANARY_PING re-validates with the full SSRF guard at fire1637+ * time (isAllowedCanaryWebhookUrl), so this import-time check is the1638+ * outer of two gates.1639+ */1640+function sanitizeCanary(c: unknown): Canary | undefined {1641+  if (!c || typeof c !== "object") return undefined;1642+  const r = c as Record<string, unknown>;1643+  if (typeof r.webhookUrl !== "string") return undefined;1644+  let url: URL;1645+  try {1646+    url = new URL(r.webhookUrl);1647+  } catch {1648+    return undefined;1649+  }1650+  if (url.protocol !== "https:") return undefined;1651+  return {1652+    webhookUrl: url.toString(),1653+    label: typeof r.label === "string" ? r.label : undefined,1654+    lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,1655+    pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,1656+  };15741657 }15751658 15761659 // Soft-deletes every non-deleted entry in one call. The literal string

view file

src/crypto/peachPayload.ts+21 −1
@@ -759,11 +759,31 @@759759     const blobLen = reader.u32();760760     const blob = reader.read(blobLen);761761     const obj = decodeJsonSection<Record<string, unknown>>(blob);762+    // MV-6: pick known passkey-bearing fields only — `...obj` let an763+    // attacker-authored blob inject arbitrary keys past the section codec.764+    // The full nested-shape validation happens in vault.ts765+    // sanitizeRestoredEntry (every import path funnels through it); this766+    // picker just stops unknown top-level keys at the decode boundary.767+    const picked: Partial<VaultEntry> = {};768+    const allow = [769+      "passkey", "username", "email", "password", "url", "additionalUrls",770+      "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",771+      "canary", "deletedAt", "breach",772+      // legacy flat passkey fields (pre-v2.11.0) — consumed by773+      // migrateLegacyPasskey, then dropped on next save774+      "rpId", "rpName", "credentialId", "publicKeyCose", "privateKeyJwk",775+      "userHandle", "userName", "userDisplayName", "algorithm", "signCount",776+      "aaguid", "backupEligible", "backupState", "lastUsedAt", "browser",777+      "transports",778+    ] as const;779+    for (const k of allow) {780+      if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];781+    }762782     return {763783       entry: {764784         ...(baseEntry as VaultEntry),765785         type: "passkey",766-        ...obj,786+        ...picked,767787       } as VaultEntry,768788       cursor: reader.cursor,769789     };

view file