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
↓ peach-browser-4620e8e0.tar.gzatlas at this save
files changed3 files
src/background/vault.test.ts+122 −0src/background/vault.ts+105 −22src/crypto/peachPayload.ts+21 −1src/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 });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 stringsrc/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 };