653b9700c5c0

fix inline ADD_ENTRY payload shape and wire inline generator to detectPasswordPolicy with forbidden-char support (v1.8.2)

time
2026-04-24 21:15 UTC · 6 months ago
machine
koh2
parent
fc82a1c7601a
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/index.ts+2 −48src/content/inject.ts+12 −17src/content/policy.test.ts+106 −0src/content/policy.ts+94 −0src/popup/components/GeneratorDrawer.tsx+3 −0src/shared/generate.test.ts+76 −0src/shared/generate.ts+37 −6src/shared/types.ts+2 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.8.1",4+  "version": "1.8.2",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.8.1",4+  "version": "1.8.2",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "1.8.1",3+  "version": "1.8.2",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/content/index.ts+2 −48
@@ -16,9 +16,10 @@1616 import { fillFields } from "./fill";1717 import { setupSaveDetection } from "./save";1818 import { contentMLDetector } from "./content-ml-detector";19+import { detectPasswordPolicy } from "./policy";1920 import type { FieldPrediction } from "../shared/ml";2021 import { generateTOTP } from "../crypto/totp";21-import type { PasswordPolicy, VaultEntry } from "../shared/types";22+import type { VaultEntry } from "../shared/types";2223 2324 // WeakMap (not WeakSet) — we keep the per-input cleanup callback as the value2425 // so we can tear down listeners if we ever need to re-process the same field.@@ -26,53 +27,6 @@2627 // it and we can re-inject without leaking the old handler.2728 const processedInputs = new WeakMap<HTMLInputElement, () => void>();2829 const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);29-30-function detectPasswordPolicy(doc: Document = document): PasswordPolicy {31-  const policy: PasswordPolicy = {};32-33-  const passwordField = doc.querySelector<HTMLInputElement>(34-    'input[autocomplete="new-password"], input[type="password"]'35-  );36-  const maxLen = passwordField?.maxLength ?? 0;37-  if (maxLen > 0) policy.maxLength = maxLen;38-  if (passwordField?.pattern) {39-    const pat = passwordField.pattern;40-    const minMatch = pat.match(/\{(\d+),/);41-    const maxMatch = pat.match(/,(\d+)\}/);42-    if (minMatch) policy.minLength = parseInt(minMatch[1]);43-    if (maxMatch) policy.maxLength = parseInt(maxMatch[1]);44-  }45-46-  const requirementText = extractNearbyText(passwordField);47-  const minMatch = requirementText.match(/(?:at least|minimum|min)[^\d]*(\d+)/i);48-  const maxMatch = requirementText.match(/(?:at most|maximum|max|up to)[^\d]*(\d+)/i);49-  if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]);50-  if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]);51-52-  if (/no\s+(?:special|symbol)/i.test(requirementText)) policy.forbidsSymbols = true;53-  if (/(?:special|symbol|punctuation)/i.test(requirementText)) policy.requiresSymbols = true;54-  if (/(?:uppercase|capital)/i.test(requirementText)) policy.requiresUppercase = true;55-  if (/number|digit/i.test(requirementText)) policy.requiresNumbers = true;56-57-  return policy;58-}59-60-function extractNearbyText(el: HTMLInputElement | null): string {61-  if (!el) return "";62-  const describedBy = el.getAttribute("aria-describedby");63-  if (describedBy) {64-    const desc = document.getElementById(describedBy);65-    if (desc) return desc.textContent ?? "";66-  }67-  let node: Element | null = el;68-  for (let i = 0; i < 3; i++) {69-    node = node?.parentElement ?? null;70-    if (!node) break;71-    const hints = node.querySelectorAll("p, span, li, small, .hint, .requirement, .password-rule");72-    if (hints.length > 0) return Array.from(hints).map((h) => h.textContent).join(" ");73-  }74-  return "";75-}7630 7731 let currentDismiss: (() => void) | null = null;7832 // Field whose selector is currently open — used to avoid dismiss+reopen flicker

view file

src/content/inject.ts+12 −17
@@ -9,7 +9,9 @@99  * to fill come back across the boundary.1010  */1111 12-import { ACCENT_COLORS } from "../shared/types";12+import { ACCENT_COLORS, type PasswordPolicy } from "../shared/types";13+import { generateSmartPassword } from "../shared/generate";14+import { detectPasswordPolicy } from "./policy";1315 1416 let accentHex = "#E8956D";1517 chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => {@@ -152,19 +154,8 @@152154   setTimeout(() => host.remove(), 9000);153155 }154156 155-function generatePassword(): string {156-  const u = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", l = "abcdefghijklmnopqrstuvwxyz";157-  const n = "0123456789", s = "!@#$%^&*";158-  const all = u + l + n + s;159-  const arr = new Uint8Array(20);160-  crypto.getRandomValues(arr);161-  const res = [u[arr[0]! % u.length]!, l[arr[1]! % l.length]!, n[arr[2]! % n.length]!, s[arr[3]! % s.length]!,162-    ...Array.from(arr.slice(4), b => all[b % all.length]!)];163-  for (let i = res.length - 1; i > 0; i--) {164-    const j = arr[i % arr.length]! % (i + 1);165-    [res[i], res[j]] = [res[j]!, res[i]!];166-  }167-  return res.join("");157+function generatePasswordForSite(policy: PasswordPolicy = {}): string {158+  return generateSmartPassword(policy);168159 }169160 170161 export function observeAnchorPosition(@@ -707,6 +698,10 @@707698       return;708699     }709700 701+    // Detect page password policy once for this form — reused on every702+    // regenerate click so the user always gets a compliant password.703+    const sitePolicy = detectPasswordPolicy();704+710705     const wrap = document.createElement("div");711706     wrap.className = "create-form-wrap";712707 @@ -750,13 +745,13 @@750745     // Password + generate751746     const pg = fGroup("password");752747     const pwInp = document.createElement("input"); pwInp.className = "f-input";753-    pwInp.type = "text"; pwInp.value = prePassword || generatePassword();748+    pwInp.type = "text"; pwInp.value = prePassword || generatePasswordForSite(sitePolicy);754749     pwInp.style.fontFamily = '"IBM Plex Mono", monospace';755750     pwInp.style.letterSpacing = "0.04em";756751     pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");757752     const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";758753     genBtn.innerHTML = genSvgStr() + " regen";759-    genBtn.addEventListener("click", () => { pwInp.value = generatePassword(); });754+    genBtn.addEventListener("click", () => { pwInp.value = generatePasswordForSite(sitePolicy); });760755     const pwRow = document.createElement("div"); pwRow.className = "f-row";761756     pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);762757     body.appendChild(pg.g);@@ -785,7 +780,7 @@785780       try {786781         const res = await chrome.runtime.sendMessage({787782           type: "ADD_ENTRY",788-          payload: { entry: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" } },783+          payload: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" },789784         });790785         if ((res as { ok?: boolean }).ok) {791786           // In signup mode, fill the username + password fields on the page

view file

src/content/policy.test.ts+106 −0
@@ -0,0 +1,106 @@1+// @vitest-environment jsdom2+3+import { beforeEach, describe, expect, it } from "vitest";4+import { detectPasswordPolicy, extractNearbyText } from "./policy";5+6+describe("detectPasswordPolicy", () => {7+  beforeEach(() => {8+    document.body.innerHTML = "";9+  });10+11+  function setupPasswordField(attrs: Record<string, string> = {}, nearby = ""): HTMLInputElement {12+    const wrap = document.createElement("div");13+    const input = document.createElement("input");14+    input.type = "password";15+    Object.entries(attrs).forEach(([k, v]) => input.setAttribute(k, v));16+    wrap.appendChild(input);17+    if (nearby) {18+      const hint = document.createElement("p");19+      hint.className = "requirement";20+      hint.textContent = nearby;21+      wrap.appendChild(hint);22+    }23+    document.body.appendChild(wrap);24+    return input;25+  }26+27+  it("reads maxlength attribute", () => {28+    setupPasswordField({ maxlength: "16" });29+    expect(detectPasswordPolicy().maxLength).toBe(16);30+  });31+32+  it("reads minLength/maxLength from pattern attribute", () => {33+    setupPasswordField({ pattern: ".{8,32}" });34+    const p = detectPasswordPolicy();35+    expect(p.minLength).toBe(8);36+    expect(p.maxLength).toBe(32);37+  });38+39+  it("parses 'at least N' from nearby text", () => {40+    setupPasswordField({}, "Password must contain at least 12 characters.");41+    expect(detectPasswordPolicy().minLength).toBe(12);42+  });43+44+  it("parses 'cannot exceed N characters'", () => {45+    setupPasswordField({}, "Password cannot exceed 40 characters.");46+    expect(detectPasswordPolicy().maxLength).toBe(40);47+  });48+49+  it("detects requires/forbids symbols from text", () => {50+    setupPasswordField({}, "Must contain a special character and an uppercase letter.");51+    const p = detectPasswordPolicy();52+    expect(p.requiresSymbols).toBe(true);53+    expect(p.requiresUppercase).toBe(true);54+  });55+56+  it("detects no-symbols phrasing", () => {57+    setupPasswordField({}, "No special characters allowed in this password.");58+    expect(detectPasswordPolicy().forbidsSymbols).toBe(true);59+  });60+61+  it("extracts Oracle-style forbidden character list (space-separated)", () => {62+    setupPasswordField(63+      {},64+      "Password cannot exceed 40 characters, contain the users first name, last name, email address, spaces, or ` ~ < > \\ characters."65+    );66+    const p = detectPasswordPolicy();67+    expect(p.maxLength).toBe(40);68+    const forbidden = p.forbiddenChars ?? "";69+    for (const c of ["`", "~", "<", ">", "\\"]) {70+      expect(forbidden).toContain(c);71+    }72+  });73+74+  it("extracts comma-separated forbidden chars", () => {75+    setupPasswordField(76+      {},77+      "Your password must not contain < , > , or \\ symbols."78+    );79+    const forbidden = detectPasswordPolicy().forbiddenChars ?? "";80+    expect(forbidden).toContain("<");81+    expect(forbidden).toContain(">");82+    expect(forbidden).toContain("\\");83+  });84+85+  it("does not leak commas or periods from descriptive prose", () => {86+    setupPasswordField(87+      {},88+      "Cannot contain your first name, last name, or email address."89+    );90+    const forbidden = detectPasswordPolicy().forbiddenChars ?? "";91+    expect(forbidden).toBe("");92+  });93+94+  it("returns empty policy when there is no password field", () => {95+    expect(detectPasswordPolicy()).toEqual({});96+  });97+98+  it("extractNearbyText honors aria-describedby", () => {99+    document.body.innerHTML = `100+      <input id="pw" type="password" aria-describedby="pw-help">101+      <div id="pw-help">At least 12 characters required.</div>102+    `;103+    const el = document.getElementById("pw") as HTMLInputElement;104+    expect(extractNearbyText(el)).toContain("At least 12");105+  });106+});

view file

src/content/policy.ts+94 −0
@@ -0,0 +1,94 @@1+/**2+ * policy.ts — page-scraping helpers for password requirements.3+ *4+ * Content-side only; no chrome.* API access. Called by both inject.ts5+ * (for the inline create form) and index.ts (for the popup's smart6+ * GET_SITE_PASSWORD_POLICY request).7+ */8+9+import type { PasswordPolicy } from "../shared/types";10+11+const NEGATION_WINDOW = 220;12+13+// Characters that plausibly appear in a site's forbidden-character list.14+// Restricted to ASCII punctuation a user could actually type. Anything else15+// (letters, digits, whitespace) is ignored so descriptive prose like16+// "cannot contain the users first name, last name" does not leak the comma.17+const FORBIDDEN_CHAR_SET = new Set([18+  "`", "~", "<", ">", "\\", "|", "@", "#", "$", "%", "^", "&", "*",19+  "(", ")", "[", "]", "{", "}", "+", "=", "?", "!", ";", ":", "/", '"', "'",20+]);21+22+export function extractNearbyText(el: HTMLInputElement | null): string {23+  if (!el) return "";24+  const describedBy = el.getAttribute("aria-describedby");25+  if (describedBy) {26+    const desc = el.ownerDocument.getElementById(describedBy);27+    if (desc) return desc.textContent ?? "";28+  }29+  let node: Element | null = el;30+  for (let i = 0; i < 3; i++) {31+    node = node?.parentElement ?? null;32+    if (!node) break;33+    const hints = node.querySelectorAll(34+      "p, span, li, small, .hint, .requirement, .password-rule"35+    );36+    if (hints.length > 0) {37+      return Array.from(hints).map((h) => h.textContent).join(" ");38+    }39+  }40+  return "";41+}42+43+export function detectPasswordPolicy(doc: Document = document): PasswordPolicy {44+  const policy: PasswordPolicy = {};45+46+  const passwordField = doc.querySelector<HTMLInputElement>(47+    'input[autocomplete="new-password"], input[type="password"]'48+  );49+  const maxLen = passwordField?.maxLength ?? 0;50+  if (maxLen > 0) policy.maxLength = maxLen;51+  if (passwordField?.pattern) {52+    const pat = passwordField.pattern;53+    const minMatch = pat.match(/\{(\d+),/);54+    const maxMatch = pat.match(/,(\d+)\}/);55+    if (minMatch) policy.minLength = parseInt(minMatch[1]!);56+    if (maxMatch) policy.maxLength = parseInt(maxMatch[1]!);57+  }58+59+  const text = extractNearbyText(passwordField);60+61+  const minMatch = text.match(/(?:at least|minimum|min)[^\d]*(\d+)/i);62+  const maxMatch = text.match(/(?:at most|maximum|max|up to|cannot exceed|must not exceed|no more than)[^\d]*(\d+)/i);63+  if (minMatch && !policy.minLength) policy.minLength = parseInt(minMatch[1]!);64+  if (maxMatch && !policy.maxLength) policy.maxLength = parseInt(maxMatch[1]!);65+66+  if (/no\s+(?:special|symbol)/i.test(text)) policy.forbidsSymbols = true;67+  if (/(?:special|symbol|punctuation)/i.test(text)) policy.requiresSymbols = true;68+  if (/(?:uppercase|capital)/i.test(text)) policy.requiresUppercase = true;69+  if (/number|digit/i.test(text)) policy.requiresNumbers = true;70+71+  // Forbidden characters — scan a ~220-char window after the first negation72+  // keyword and collect single-symbol tokens bounded by whitespace, commas,73+  // backticks, or quotes. This tolerates the phrasings actually seen in the74+  // wild ("cannot contain ` ~ < > \ characters", backtick-formatted code,75+  // comma lists). Descriptive prose ("cannot contain the user's email")76+  // does not contribute symbols because the only punctuation it includes77+  // (comma, period, apostrophe) is boundary, not content.78+  const negation = text.match(79+    /(?:cannot|can not|must not|may not|no |without|except|excluding|not allowed|not permitted|invalid|disallowed)[\s\S]*/i80+  );81+  if (negation) {82+    const window = negation[0].slice(0, NEGATION_WINDOW);83+    const seen = new Set<string>();84+    const tokenRegex = /(?:^|[\s`"',.(\[])([!-/:-@\[-`{-~])(?=[\s`"',.)\]]|$)/g;85+    let m: RegExpExecArray | null;86+    while ((m = tokenRegex.exec(window)) !== null) {87+      const ch = m[1]!;88+      if (FORBIDDEN_CHAR_SET.has(ch)) seen.add(ch);89+    }90+    if (seen.size > 0) policy.forbiddenChars = [...seen].join("");91+  }92+93+  return policy;94+}

view file

src/popup/components/GeneratorDrawer.tsx+3 −0
@@ -18,6 +18,7 @@1818   const [lower, setLower] = createSignal(true);1919   const [numbers, setNumbers] = createSignal(true);2020   const [symbols, setSymbols] = createSignal(true);21+  const [forbiddenChars, setForbiddenChars] = createSignal("");2122   const [copied, setCopied] = createSignal(false);2223 2324   function initFromPolicy(policy: PasswordPolicy) {@@ -31,6 +32,7 @@3132 3233     setLength(nextLength);3334     setSymbols(nextSymbols);35+    setForbiddenChars(policy.forbiddenChars ?? "");3436   }3537 3638   onMount(async () => {@@ -60,6 +62,7 @@6062         includeLowercase: options?.lower ?? lower(),6163         includeNumbers: options?.numbers ?? numbers(),6264         includeSymbols: options?.symbols ?? symbols(),65+        forbiddenChars: forbiddenChars(),6366       })6467     );6568   }

view file

src/shared/generate.test.ts+76 −0
@@ -0,0 +1,76 @@1+import { describe, expect, it } from "vitest";2+import { generatePassword, generateSmartPassword } from "./generate";3+4+describe("generatePassword", () => {5+  it("produces a password of the requested length", () => {6+    const pw = generatePassword({ length: 32 });7+    expect(pw).toHaveLength(32);8+  });9+10+  it("omits forbidden characters from the symbol pool", () => {11+    const pw = generatePassword({12+      length: 200,13+      includeSymbols: true,14+      forbiddenChars: "^*!@",15+    });16+    for (const c of ["^", "*", "!", "@"]) {17+      expect(pw).not.toContain(c);18+    }19+  });20+21+  it("omits forbidden characters from letter/number pools too", () => {22+    // Defensive: if a site ever bans a digit, honor it.23+    const pw = generatePassword({24+      length: 200,25+      includeNumbers: true,26+      includeSymbols: false,27+      includeUppercase: false,28+      forbiddenChars: "0",29+    });30+    expect(pw).not.toContain("0");31+  });32+33+  it("falls back to a non-empty pool when every category is disabled", () => {34+    const pw = generatePassword({35+      length: 16,36+      includeUppercase: false,37+      includeLowercase: false,38+      includeNumbers: false,39+      includeSymbols: false,40+    });41+    expect(pw).toHaveLength(16);42+  });43+});44+45+describe("generateSmartPassword", () => {46+  it("clamps to policy.maxLength", () => {47+    const pw = generateSmartPassword({ maxLength: 12 });48+    expect(pw.length).toBeLessThanOrEqual(12);49+  });50+51+  it("raises to policy.minLength", () => {52+    const pw = generateSmartPassword({ minLength: 32 });53+    expect(pw.length).toBeGreaterThanOrEqual(32);54+  });55+56+  it("excludes forbidden chars (Oracle-style)", () => {57+    const pw = generateSmartPassword({58+      maxLength: 40,59+      forbiddenChars: "`~<>\\",60+    });61+    expect(pw.length).toBeLessThanOrEqual(40);62+    for (const c of ["`", "~", "<", ">", "\\"]) {63+      expect(pw).not.toContain(c);64+    }65+  });66+67+  it("honors forbidsSymbols", () => {68+    const pw = generateSmartPassword({ forbidsSymbols: true });69+    expect(pw).toMatch(/^[A-Za-z0-9]+$/);70+  });71+72+  it("defaults to length 20 when policy is empty", () => {73+    const pw = generateSmartPassword({});74+    expect(pw).toHaveLength(20);75+  });76+});

view file

src/shared/generate.ts+37 −6
@@ -1,9 +1,13 @@1+import type { PasswordPolicy } from "./types";2+13 export interface GeneratorOptions {24   length: number;35   includeUppercase: boolean;46   includeLowercase: boolean;57   includeNumbers: boolean;68   includeSymbols: boolean;9+  /** Characters to exclude from every pool — honors site forbidden-char lists. */10+  forbiddenChars?: string;711 }812 913 /**@@ -16,20 +20,47 @@1620     includeLowercase = true,1721     includeNumbers = true,1822     includeSymbols = true,23+    forbiddenChars = "",1924   } = options;2025 26+  const forbidden = new Set(forbiddenChars);27+  const filter = (pool: string): string =>28+    forbidden.size === 029+      ? pool30+      : Array.from(pool).filter((c) => !forbidden.has(c)).join("");31+2132   let chars = "";22-  if (includeLowercase) chars += "abcdefghijklmnopqrstuvwxyz";23-  if (includeUppercase) chars += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";24-  if (includeNumbers) chars += "0123456789";25-  if (includeSymbols) chars += "!@#$%^&*";33+  if (includeLowercase) chars += filter("abcdefghijklmnopqrstuvwxyz");34+  if (includeUppercase) chars += filter("ABCDEFGHIJKLMNOPQRSTUVWXYZ");35+  if (includeNumbers) chars += filter("0123456789");36+  if (includeSymbols) chars += filter("!@#$%^&*");2637 27-  if (chars.length === 0) chars = "abcdefghijklmnopqrstuvwxyz";38+  if (chars.length === 0) chars = filter("abcdefghijklmnopqrstuvwxyz") || "abcdefghijklmnopqrstuvwxyz";2839 2940   const array = crypto.getRandomValues(new Uint32Array(length));3041   let password = "";3142   for (let i = 0; i < length; i++) {32-    password += chars[array[i] % chars.length];43+    password += chars[array[i]! % chars.length];3344   }3445   return password;46+}47+48+/**49+ * Generate a password that satisfies a detected site policy — clamps length50+ * to maxLength, raises to minLength, honors forbidsSymbols, and excludes51+ * forbiddenChars. Used by the inline popup when the user clicks "regen" on a52+ * signup form with parseable requirements.53+ */54+export function generateSmartPassword(policy: PasswordPolicy = {}): string {55+  const defaultLength = 20;56+  let length = defaultLength;57+  if (policy.minLength) length = Math.max(length, policy.minLength);58+  if (policy.maxLength) length = Math.min(length, policy.maxLength);59+  if (length < 8) length = 8;60+61+  return generatePassword({62+    length,63+    includeSymbols: !policy.forbidsSymbols,64+    forbiddenChars: policy.forbiddenChars,65+  });3566 }

view file

src/shared/types.ts+2 −0
@@ -225,6 +225,8 @@225225   requiresSymbols?: boolean;226226   forbidsSymbols?: boolean;227227   allowedSymbols?: string;228+  /** Characters the site explicitly disallows. Filtered from every pool before generation. */229+  forbiddenChars?: string;228230 }229231 230232 export interface Message {

view file