3b81fc8ed88e

M2/QUA-002: add runtime payload validation at message handler trust boundary

time
2026-05-16 03:55 UTC · 5 months ago
machine
koh2
parent
13f21e71dd16
2 files src/background/messages.adversarial.test.ts+48 −0src/background/messages.ts+55 −17
src/background/messages.adversarial.test.ts+48 −0
@@ -932,4 +932,52 @@932932       });933933     }934934   });935+936+  // ── M2/QUA-002: payload validation at trust boundary ────────────────────937+938+  describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {939+    // Each entry: handler type + payloads that must produce ok:false.940+    // The accepted error codes include 'invalid_payload' (the new validator)941+    // or 'vault_locked' (handlers that lock-check before validating, which942+    // is still a correct rejection of the request).943+    const cases: Array<{ type: string; bad: unknown[] }> = [944+      {945+        type: "ADD_ENTRY",946+        bad: [null, "string", 42, [], true],947+      },948+      {949+        type: "BATCH_ADD_ENTRIES",950+        bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],951+      },952+      {953+        type: "UPDATE_ENTRY",954+        bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],955+      },956+      {957+        type: "RESTORE_ENTRIES",958+        bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],959+      },960+      {961+        type: "UPDATE_SETTINGS",962+        bad: [null, "string", 42, []],963+      },964+      {965+        type: "REMOVE_TRUSTED_NETWORK",966+        bad: [null, "string", 42, []],967+      },968+    ];969+970+    for (const { type, bad } of cases) {971+      for (const payload of bad) {972+        const label = JSON.stringify(payload);973+        it(`${type} rejects malformed payload: ${label}`, async () => {974+          await setupUnlockedVault();975+          const r = await dispatch({ type, payload } as unknown as Message);976+          expect(r.ok).toBe(false);977+          const err = (r as { error?: string }).error ?? "";978+          expect(err).toMatch(/invalid_payload|vault[_ ]locked/);979+        });980+      }981+    }982+  });935983 });

view file

src/background/messages.ts+55 −17
@@ -530,24 +530,45 @@530530         return { ok: true, data: vault.getEntries() };531531       case "ADD_ENTRY": {532532         if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };533-        const entry = await vault.addEntry(msg.payload as any);533+        const payload = msg.payload;534+        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {535+          return { ok: false, error: "invalid_payload" };536+        }537+        const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);534538         return { ok: true, data: entry };535539       }536540       case "BATCH_ADD_ENTRIES": {537-        const { entries } = msg.payload as { entries: any[] };538-        const added = await vault.batchAddEntries(entries);541+        const payload = msg.payload as { entries?: unknown } | null | undefined;542+        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {543+          return { ok: false, error: "invalid_payload" };544+        }545+        if (!Array.isArray(payload.entries)) {546+          return { ok: false, error: "invalid_payload" };547+        }548+        const added = await vault.batchAddEntries(payload.entries as Parameters<typeof vault.batchAddEntries>[0]);539549         return { ok: true, data: added };540550       }541551       case "RESTORE_ENTRIES": {542-        const { entries, mode, settings } = msg.payload as {543-          entries: any[];544-          mode: "replace" | "merge";545-          settings?: vault.VaultSettings;546-        };547-        const summary = await vault.restoreEntries(entries, mode);548-        if (settings && typeof settings === "object") {549-          await vault.updateSettings(settings);552+        const payload = msg.payload as553+          | { entries?: unknown; mode?: unknown; settings?: unknown }554+          | null555+          | undefined;556+        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {557+          return { ok: false, error: "invalid_payload" };550558         }559+        if (!Array.isArray(payload.entries)) {560+          return { ok: false, error: "invalid_payload" };561+        }562+        if (payload.mode !== "replace" && payload.mode !== "merge") {563+          return { ok: false, error: "invalid_payload" };564+        }565+        const summary = await vault.restoreEntries(566+          payload.entries as Parameters<typeof vault.restoreEntries>[0],567+          payload.mode568+        );569+        if (payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)) {570+          await vault.updateSettings(payload.settings as vault.VaultSettings);571+        }551572         return { ok: true, data: summary };552573       }553574       case "CLEAR_VAULT_ENTRIES": {@@ -573,8 +594,15 @@573594         return { ok: true, data: removed };574595       }575596       case "UPDATE_ENTRY": {576-        const { id, patch } = msg.payload as any;577-        await vault.updateEntry(id, patch);597+        const payload = msg.payload as { id?: unknown; patch?: unknown } | null | undefined;598+        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {599+          return { ok: false, error: "invalid_payload" };600+        }601+        const { id, patch } = payload;602+        if (typeof id !== "string" || !id || !patch || typeof patch !== "object" || Array.isArray(patch)) {603+          return { ok: false, error: "invalid_payload" };604+        }605+        await vault.updateEntry(id, patch as Parameters<typeof vault.updateEntry>[1]);578606         return { ok: true };579607       }580608       case "ADD_ADDITIONAL_URL": {@@ -801,9 +829,12 @@801829       case "GET_SETTINGS":802830         return { ok: true, data: await vault.ensureSettingsLoaded() };803831       case "UPDATE_SETTINGS": {804-        const patch = msg.payload as Record<string, unknown>;805-        await vault.updateSettings(patch as any);806-        if (typeof patch.autoLockMinutes === "number") {832+        const patch = msg.payload;833+        if (!patch || typeof patch !== "object" || Array.isArray(patch)) {834+          return { ok: false, error: "invalid_payload" };835+        }836+        await vault.updateSettings(patch as vault.VaultSettings);837+        if (typeof (patch as Record<string, unknown>).autoLockMinutes === "number") {807838           await setupAutoLock();808839           if (vault.isUnlocked()) touchAutoLock();809840         }@@ -1046,7 +1077,14 @@10461077         return { ok: true };10471078       }10481079       case "REMOVE_TRUSTED_NETWORK": {1049-        const { ssid, bssid, isTailscale } = msg.payload as any;1080+        const payload = msg.payload as1081+          | { ssid?: unknown; bssid?: unknown; isTailscale?: unknown }1082+          | null1083+          | undefined;1084+        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {1085+          return { ok: false, error: "invalid_payload" };1086+        }1087+        const { ssid, bssid, isTailscale } = payload as { ssid?: string; bssid?: string; isTailscale?: boolean };10501088         const networks = await getTrustedNetworks();10511089         const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 10521090           (isTailscale ? n.isTailscale : n.ssid === ssid) && 

view file