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
↓ peach-browser-653b9700.tar.gzatlas at this save
files changed11 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 −0manifest.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": {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",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": {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 flickersrc/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 pagesrc/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+});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+}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 }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+});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 }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 {