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
↓ peach-browser-6982d0be.tar.gzatlas at this save
files changed6 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 −2manifest.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": {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",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",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 });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) ───────────────────────────────────────────────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[] {