6982d0beb2da

fix: budget page-triggered ML inference (PB-26-702, v5.2.9)

time
2026-09-07 01:06 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
red team remediation
parent
9d36d625ca49
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.adversarial.test.ts+33 −0src/background/messages.ts+26 −5src/background/ml.ts+11 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.8",4+  "version": "5.2.9",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": "5.2.8",5+  "version": "5.2.9",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": "5.2.8",3+  "version": "5.2.9",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.adversarial.test.ts+33 −0
@@ -14,6 +14,7 @@1414 import { beforeEach, describe, expect, it, vi } from "vitest";1515 1616 import type { Message, MessageResponse } from "../shared/types";17+import { ML_FEATURE_KEYS } from "./ml";1718 1819 type StorageShape = Record<string, unknown>;1920 type MessageListener = (@@ -1215,6 +1216,7 @@12151216     });12161217 12171218     describe("ML_PREDICT_BATCH", () => {1219+      const validFeatures = Object.fromEntries(ML_FEATURE_KEYS.map((key) => [key, 0]));12181220       const bads: Array<[string, unknown]> = [12191221         ["null", null],12201222         ["object", { not: "array" }],@@ -1239,6 +1241,37 @@12391241           payload: { featuresArray: [] },12401242         } as unknown as Message);12411243         expect((r as { error?: string }).error).not.toBe("invalid_payload");1244+      });1245+1246+      it("rejects oversized and non-finite feature batches before inference", async () => {1247+        const oversized = await dispatch({1248+          type: "ML_PREDICT_BATCH",1249+          payload: { featuresArray: Array.from({ length: 129 }, () => validFeatures) },1250+        } as unknown as Message);1251+        expect(oversized).toEqual({ ok: false, error: "invalid_payload" });1252+1253+        const malformed = await dispatch({1254+          type: "ML_PREDICT_BATCH",1255+          payload: { featuresArray: [{ ...validFeatures, type_text: Number.NaN }] },1256+        } as unknown as Message);1257+        expect(malformed).toEqual({ ok: false, error: "invalid_payload" });1258+      });1259+1260+      it("rate-limits repeated bounded work per browser-attested sender", async () => {1261+        const sender = {1262+          id: "peach-extension",1263+          url: "https://login.example.test/frame",1264+          origin: "https://login.example.test",1265+          tab: { id: 7, url: "https://login.example.test/" },1266+          frameId: 0,1267+        } as chrome.runtime.MessageSender;1268+        const batch = Array.from({ length: 128 }, () => validFeatures);1269+        expect((await dispatch({ type: "ML_PREDICT_BATCH", payload: { featuresArray: batch } } as unknown as Message, sender)).ok).toBe(true);1270+        expect((await dispatch({ type: "ML_PREDICT_BATCH", payload: { featuresArray: batch } } as unknown as Message, sender)).ok).toBe(true);1271+        expect(await dispatch({ type: "ML_PREDICT_BATCH", payload: { featuresArray: [validFeatures] } } as unknown as Message, sender)).toEqual({1272+          ok: false,1273+          error: "rate_limited",1274+        });12421275       });12431276     });12441277   });

view file

src/background/messages.ts+26 −5
@@ -67,8 +67,7 @@6767 import { isPasskeyOnly } from "../shared/types";6868 import { generateTOTP } from "../crypto/totp";6969 import { isValidRpId } from "../shared/passkey-utils";70-import { predictBatch as mlPredictBatch } from "./ml";71-import type { MLFieldFeatures } from "../shared/ml";70+import { isValidMLFeatureBatch, predictBatch as mlPredictBatch } from "./ml";7271 import {7372   buildLocalIdentity,7473   getOrCreateDeviceId,@@ -338,7 +337,27 @@338337 // handler so the onMessage listener is added synchronously at SW startup339338 // (MV3 requirement) while unlock-dependent handlers still wait for restore.340339 let restoreReady: Promise<void> = Promise.resolve();340+const MAX_ML_BATCH_FIELDS = 128;341+const MAX_ML_FIELDS_PER_SECOND = 256;342+const mlWorkBySender = new Map<string, { startedAt: number; fields: number }>();341343 344+function claimMLWork(sender: Partial<chrome.runtime.MessageSender>, fields: number): boolean {345+  if (fields === 0) return true;346+  const now = Date.now();347+  for (const [key, value] of mlWorkBySender) {348+    if (now - value.startedAt >= 1_000) mlWorkBySender.delete(key);349+  }350+  const key = `${sender.tab?.id ?? "ui"}:${sender.frameId ?? 0}:${attestedSenderOrigin(sender) ?? "extension"}`;351+  const current = mlWorkBySender.get(key);352+  if (!current) {353+    mlWorkBySender.set(key, { startedAt: now, fields });354+    return fields <= MAX_ML_FIELDS_PER_SECOND;355+  }356+  if (current.fields + fields > MAX_ML_FIELDS_PER_SECOND) return false;357+  current.fields += fields;358+  return true;359+}360+342361 /** Called by entry.ts at startup with the in-flight restoreFromSession promise. */343362 export function setRestoreReady(p: Promise<unknown>): void {344363   restoreReady = p.then(() => undefined, () => undefined);@@ -1558,12 +1577,14 @@15581577         return { ok: true };15591578 15601579       case "ML_PREDICT_BATCH": {1561-        // AUD-002: shape-validate before forwarding to the ML helper.15621580         const { featuresArray } = (msg.payload ?? {}) as { featuresArray?: unknown };1563-        if (!Array.isArray(featuresArray)) {1581+        if (!isValidMLFeatureBatch(featuresArray, MAX_ML_BATCH_FIELDS)) {15641582           return { ok: false, error: "invalid_payload" };15651583         }1566-        return { ok: true, data: mlPredictBatch(featuresArray as MLFieldFeatures[]) };1584+        if (!claimMLWork(sender, featuresArray.length)) {1585+          return { ok: false, error: "rate_limited" };1586+        }1587+        return { ok: true, data: mlPredictBatch(featuresArray) };15671588       }15681589 15691590       // ── Vault sync (v3) ───────────────────────────────────────────────

view file

src/background/ml.ts+11 −2
@@ -4,7 +4,7 @@44 const CLASSES: FieldType[] = ['password', 'email', 'username', 'totp', 'none'];55 66 // Feature vector order — must stay in sync with ContentMLDetector.extractFeatures().7-const FEATURES: (keyof MLFieldFeatures)[] = [7+export const ML_FEATURE_KEYS: readonly (keyof MLFieldFeatures)[] = [88   // input type (8)99   'type_text', 'type_email', 'type_password', 'type_tel', 'type_number',1010   'type_search', 'type_url', 'type_other',@@ -180,7 +180,7 @@180180 }181181 182182 function predict(features: MLFieldFeatures): FieldPrediction {183-  const vec = FEATURES.map(k => features[k]);183+  const vec = ML_FEATURE_KEYS.map(k => features[k]);184184   const logits = W.map((row, ci) =>185185     B[ci] + row.reduce((s, w, fi) => s + w * vec[fi], 0)186186   );@@ -193,6 +193,15 @@193193       CLASSES.map((t, i) => [t, probs[i]])194194     ) as Record<FieldType, number>,195195   };196+}197+198+export function isValidMLFeatureBatch(value: unknown, maxFields: number): value is MLFieldFeatures[] {199+  return Array.isArray(value) && value.length <= maxFields && value.every((item) =>200+    typeof item === "object" && item !== null && ML_FEATURE_KEYS.every((key) =>201+      typeof (item as Record<string, unknown>)[key] === "number" &&202+      Number.isFinite((item as Record<string, unknown>)[key])203+    )204+  );196205 }197206 198207 export function predictBatch(featuresArray: MLFieldFeatures[]): FieldPrediction[] {

view file