1import { test, expect, BrowserContext } from "@playwright/test";2import { fileURLToPath } from "url";3import { dirname, join } from "path";4import os from "os";5import fs from "fs/promises";6import {7 EXTENSION_PATH,8 UNLOCKED,9 createEntryViaForm,10 lockVault,11 makeContext,12 listSearch,13 openList,14 openSettings,15 setupVault,16 waitForSW,17} from "./helpers/vault";1819const __filename = fileURLToPath(import.meta.url);20const __dirname = dirname(__filename);212223test.describe.configure({ timeout: 90_000 });242526async function openGenerator(popup: import("@playwright/test").Page) {27 await popup.locator(".peach-new-entry-row").click();28 await popup.getByRole("button", { name: /generate/i }).click();29 await expect(popup.locator(".peach-pwgen-compact-output")).toBeVisible({ timeout: 5000 });30}3132test.describe("XSS Prevention", () => {33 let ctx: BrowserContext;34 test.afterEach(async () => { await ctx.close(); });35 test.beforeEach(async () => { ctx = await makeContext(); });3637 test("SECX01 - entry titles are escaped in DOM", async () => {38 const { popup } = await setupVault(ctx);39 await createEntryViaForm(popup, { title: '<script>alert("xss")</script>', url: "https://xss.com", username: "[email protected]", password: "xss" });40 41 42 43 44 const entryName = popup.locator(".peach-entry-name").first();45 expect(await entryName.textContent()).toContain("alert");46 expect(await popup.locator("script:not([src])").count()).toBe(0);47 await ctx.close();48 });4950 test("SECX02 - entry URLs are sanitized before display", async () => {51 const { popup } = await setupVault(ctx);52 await createEntryViaForm(popup, { title: "URLTest", url: 'javascript:alert("xss")', username: "[email protected]", password: "pass" });53 const domain = popup.locator(".peach-domain-value").first();54 const text = await domain.textContent().catch(() => "");55 expect(text).not.toContain("javascript:");56 await ctx.close();57 });5859 test("SECX03 - no inline event handlers in entry rendering", async () => {60 const { popup } = await setupVault(ctx);61 await createEntryViaForm(popup, { title: '" onmouseover="alert(1)" "', url: "https://test.com", username: "[email protected]", password: "pass" });62 63 64 const injected = await popup.evaluate(() =>65 document.querySelectorAll("[onmouseover]").length,66 );67 expect(injected).toBe(0);68 await ctx.close();69 });7071 test("SECX04 - notes field renders without HTML execution", async () => {72 const { popup } = await setupVault(ctx);73 await createEntryViaForm(popup, { title: "NotesXSS", url: "https://notes.com", username: "[email protected]", password: "pass", notes: '<img src=x onerror="alert(1)">' });74 75 76 await expect(popup.locator(".peach-note-content")).toContainText("onerror");77 expect(await popup.locator(".peach-note-content img").count()).toBe(0);78 expect(await popup.evaluate(() => document.querySelectorAll("[onerror]").length)).toBe(0);79 await ctx.close();80 });8182 test("SECX05 - search input sanitizes query before display", async () => {83 const { popup } = await setupVault(ctx);84 await createEntryViaForm(popup, { title: "SearchTest", url: "https://search.com", username: "[email protected]", password: "pass" });85 await openList(popup);86 const searchInput = listSearch(popup);87 await searchInput.fill('<script>alert("xss")</script>');88 await popup.waitForTimeout(300);89 90 91 expect(await searchInput.inputValue()).toContain("<script>");92 expect(await popup.locator("script:not([src])").count()).toBe(0);93 await ctx.close();94 });95});9697test.describe("Session Security", () => {98 let ctx: BrowserContext;99 test.afterEach(async () => { await ctx.close(); });100 test.beforeEach(async () => { ctx = await makeContext(); });101102 test("SECS01 - session key cleared on lock", async () => {103 const { popup } = await setupVault(ctx);104 await lockVault(popup);105 106 107 const response = await popup.evaluate(async () => {108 return new Promise<{ ok: boolean; data: boolean }>(resolve => {109 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));110 });111 });112 expect(response.data).toBe(false);113 await ctx.close();114 });115116 test("SECS02 - session does not persist across browser restarts", async () => {117 const { chromium } = await import("playwright");118 const userDataDir = join(os.tmpdir(), `peach-sec-restart-${Date.now()}`);119 await fs.mkdir(userDataDir, { recursive: true });120 const launch = () => chromium.launchPersistentContext(userDataDir, {121 headless: false,122 args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],123 });124125 const first = await launch();126 const { popup } = await setupVault(first);127 await createEntryViaForm(popup, { title: "PersistSession", url: "https://persist.com", username: "[email protected]", password: "pass" });128 await first.close();129130 const second = await launch();131 const extId = await waitForSW(second);132 const reopened = await second.newPage();133 await reopened.goto(`chrome-extension://${extId}/src/popup/index.html`);134 await expect(reopened.locator("#unlock-password")).toBeVisible({ timeout: 20000 });135 await expect(reopened.locator(UNLOCKED)).toHaveCount(0);136 await second.close();137 });138139 test("SECS03 - idle timeout clears session", async () => {140 const { popup } = await setupVault(ctx);141 await openSettings(popup);142 await popup.locator('[data-settings-card-id="protection"]').click();143 await expect(144 popup.locator(".peach-settings-row", { hasText: "Auto-lock after" }),145 ).toHaveCount(1);146 await ctx.close();147 });148149 test("SECS04 - master password not stored in memory after unlock", async () => {150 const { popup } = await setupVault(ctx);151 const hasStoredPW = await popup.evaluate(() => {152 return (window as any).__PEACH_MASTER_PASSWORD !== undefined ||153 (window as any).masterPassword !== undefined;154 });155 expect(hasStoredPW).toBeFalsy();156 await ctx.close();157 });158159 test("SECS05 - vault data not accessible without unlock", async () => {160 const { popup } = await setupVault(ctx);161 await createEntryViaForm(popup, { title: "LockedRead", url: "https://locked.test", username: "u", password: "p" });162 await lockVault(popup);163 const response = await popup.evaluate(async () => {164 return new Promise<{ ok: boolean; error?: string; data?: unknown }>(resolve => {165 chrome.runtime.sendMessage({ type: "GET_ENTRIES" }, r => resolve(r));166 });167 });168 expect(response.ok).toBe(false);169 expect(response.error).toBe("vault_locked");170 expect(response.data).toBeUndefined();171 await ctx.close();172 });173174 test("SECS06 - service worker message validates sender origin", async () => {175 const ctx2 = await makeContext();176 const { popup } = await setupVault(ctx);177 const response = await popup.evaluate(async () => {178 return new Promise(resolve => {179 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));180 });181 });182 expect(response).toBeDefined();183 await ctx.close();184 await ctx2.close();185 });186187 test("SECS07 - no sensitive data in extension localStorage", async () => {188 const { popup } = await setupVault(ctx);189 await popup.waitForTimeout(1000);190 const storage = await popup.evaluate(() => {191 return new Promise(resolve => {192 chrome.storage.local.get(null, r => resolve(r));193 });194 }) as any;195 if (storage && typeof storage === 'object') {196 const hasSensitive = Object.keys(storage).some(k =>197 k.toLowerCase().includes('password') ||198 k.toLowerCase().includes('secret') ||199 k.toLowerCase().includes('key')200 );201 expect(hasSensitive).toBeFalsy();202 }203 await ctx.close();204 });205});206207test.describe("CSP Compliance", () => {208 let ctx: BrowserContext;209 test.afterEach(async () => { await ctx.close(); });210 test.beforeEach(async () => { ctx = await makeContext(); });211212 test("SEQC01 - no inline scripts in popup HTML", async () => {213 const extId = await waitForSW(ctx);214 const page = await ctx.newPage();215 await page.goto(`chrome-extension://${extId}/src/popup/index.html`);216 await page.waitForTimeout(2000);217 const scripts = await page.evaluate(() => {218 const html = document.documentElement.innerHTML;219 const scriptMatches = html.match(/<script[^>]*>/gi) || [];220 return scriptMatches.filter(s => !s.includes('src='));221 });222 expect(scripts.length).toBe(0);223 await ctx.close();224 });225226 test("SEQC02 - no eval() in content script", async () => {227 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));228 const jsFiles = distFiles.filter(f => f.endsWith(".js"));229 let foundEval = false;230 for (const file of jsFiles) {231 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");232 if (content.includes("eval(") && !content.includes("new Function")) {233 foundEval = true;234 break;235 }236 }237 expect(foundEval).toBeFalsy();238 await ctx.close();239 });240241 test("SEQC03 - CSP meta tag present in HTML", async () => {242 const html = await fs.readFile(join(__dirname, "..", "dist", "src", "popup", "index.html"), "utf-8");243 const hasCSP = html.includes("Content-Security-Policy") || html.includes("csp");244 expect(hasCSP).toBeTruthy();245 await ctx.close();246 });247248 test("SEQC04 - no remote script loading in content script", async () => {249 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));250 const jsFiles = distFiles.filter(f => f.endsWith(".js"));251 let foundRemote = false;252 for (const file of jsFiles) {253 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");254 if (content.match(/https?:\/\/[^"']+\.js/)) {255 foundRemote = true;256 break;257 }258 }259 expect(foundRemote).toBeFalsy();260 await ctx.close();261 });262});263264test.describe("Encryption Verification", () => {265 let ctx: BrowserContext;266 test.afterEach(async () => { await ctx.close(); });267 test.beforeEach(async () => { ctx = await makeContext(); });268269 test("SECE01 - Argon2id parameters documented in code", async () => {270 const argon2File = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>271 f.find(f => f.includes("argon") || f.includes("crypto"))272 );273 expect(argon2File).toBeDefined();274 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", argon2File!), "utf-8");275 expect(content).toMatch(/65536|3|4|argon/);276 await ctx.close();277 });278279 test("SECE02 - AES-GCM used for vault encryption", async () => {280 const aesFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>281 f.filter(f => f.includes("aes"))282 );283 expect(aesFiles.length).toBeGreaterThan(0);284 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", aesFiles[0]), "utf-8");285 expect(content).toMatch(/AES-GCM|aes-gcm|AESGCM|AES.*GCM/);286 await ctx.close();287 });288289 test("SECE03 - no ECB mode in encryption", async () => {290 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));291 const jsFiles = distFiles.filter(f => f.endsWith(".js"));292 let foundECB = false;293 for (const file of jsFiles) {294 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");295 if (content.includes("ECB") && !content.includes("// ECB")) {296 foundECB = true;297 break;298 }299 }300 expect(foundECB).toBeFalsy();301 await ctx.close();302 });303304 test("SECE04 - crypto.getRandomValues used for all random", async () => {305 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));306 const jsFiles = distFiles.filter(f => f.endsWith(".js"));307 let foundMathRandom = false;308 for (const file of jsFiles) {309 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");310 const mathRandMatches = content.match(/Math\.random\(\)/g) || [];311 const cryptoRandMatches = content.match(/crypto\.getRandomValues\(/g) || [];312 if (mathRandMatches.length > cryptoRandMatches.length) {313 foundMathRandom = true;314 break;315 }316 }317 expect(foundMathRandom).toBeFalsy();318 await ctx.close();319 });320321 test("SECE05 - salt generated for each encryption operation", async () => {322 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));323 const jsFiles = distFiles.filter(f => f.endsWith(".js"));324 let hasSaltGen = false;325 for (const file of jsFiles) {326 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");327 if (content.includes("generateSalt") || content.includes("getRandomValues")) {328 hasSaltGen = true;329 break;330 }331 }332 expect(hasSaltGen).toBeTruthy();333 await ctx.close();334 });335336 test("SECE06 - key stored in secure memory not plain", async () => {337 const { popup } = await setupVault(ctx);338 await popup.waitForTimeout(1000);339 const keyInDOM = await popup.evaluate(() => {340 const inputs = document.querySelectorAll("input");341 for (const input of inputs) {342 if (input.value && input.value.length > 20 && !input.type) {343 return true;344 }345 }346 return false;347 });348 expect(keyInDOM).toBeFalsy();349 await ctx.close();350 });351352 test("SECE07 - export uses PBKDF2 with high iterations", async () => {353 const exportFile = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>354 f.find(f => f.includes("export") || f.includes("Export"))355 );356 expect(exportFile).toBeDefined();357 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", exportFile!), "utf-8");358 if (content.includes("PBKDF2")) {359 expect(content).toMatch(/iterations.*[0-9]{4,}|250000/);360 }361 await ctx.close();362 });363});364365test.describe("CSRF Protection", () => {366 let ctx: BrowserContext;367 test.afterEach(async () => { await ctx.close(); });368 test.beforeEach(async () => { ctx = await makeContext(); });369370 test("SECC01 - service worker rejects cross-origin messages", async () => {371 const { popup } = await setupVault(ctx);372 const response = await popup.evaluate(async () => {373 return new Promise(resolve => {374 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));375 });376 });377 expect(response).toBeDefined();378 await ctx.close();379 });380381 test("SECC02 - no token exposure in URL parameters", async () => {382 const { popup } = await setupVault(ctx);383 await createEntryViaForm(popup, { title: "URLToken", url: "https://token.com", username: "[email protected]", password: "pass" });384 const url = popup.url();385 expect(url).not.toMatch(/token|key|secret|session/i);386 await ctx.close();387 });388389 test("SECC03 - clipboard cleared after copy operation", async () => {390 const { popup } = await setupVault(ctx);391 await createEntryViaForm(popup, { title: "ClipboardSec", url: "https://clip.com", username: "[email protected]", password: "clipsecret" });392 await popup.locator(".peach-entry-name-button").first().click();393 await popup.waitForTimeout(500);394 const copyBtn = popup.locator(".peach-copy-btn").first();395 if (await copyBtn.isVisible({ timeout: 2000 }).catch(() => false)) {396 await copyBtn.click();397 await popup.waitForTimeout(35000);398 const clipboard = await popup.evaluate(() => navigator.clipboard.readText());399 expect(clipboard).not.toBe("clipsecret");400 }401 await ctx.close();402 });403});404405test.describe("Breach Detection Security", () => {406 let ctx: BrowserContext;407 test.afterEach(async () => { await ctx.close(); });408 test.beforeEach(async () => { ctx = await makeContext(); });409410 test("SECB01 - HIBP API call uses k-anonymity model", async () => {411 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));412 const jsFiles = distFiles.filter(f => f.endsWith(".js"));413 let foundHIBP = false;414 for (const file of jsFiles) {415 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");416 if (content.includes("hibp") || content.includes("pwned")) {417 foundHIBP = true;418 expect(content).toMatch(/range|hash|k-anon|sha1/i);419 break;420 }421 }422 expect(foundHIBP).toBeTruthy();423 await ctx.close();424 });425426 test("SECB02 - breach check does not expose full password", async () => {427 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));428 const jsFiles = distFiles.filter(f => f.endsWith(".js"));429 let foundHash = false;430 for (const file of jsFiles) {431 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");432 if (content.includes("breach") || content.includes("hibp")) {433 if (content.includes("sha1") || content.includes("SHA-1")) {434 foundHash = true;435 break;436 }437 }438 }439 expect(foundHash).toBeTruthy();440 await ctx.close();441 });442443 test("SECB03 - breach status stored securely", async () => {444 const { popup } = await setupVault(ctx);445 await popup.waitForTimeout(500);446 const storage = await popup.evaluate(() => {447 return new Promise(resolve => {448 chrome.storage.local.get(null, r => resolve(r));449 });450 }) as any;451 if (storage) {452 const hasBreachClearText = JSON.stringify(storage).includes("breached");453 expect(hasBreachClearText).toBeFalsy();454 }455 await ctx.close();456 });457});458459test.describe("Password Generation Security", () => {460 let ctx: BrowserContext;461 test.afterEach(async () => { await ctx.close(); });462 test.beforeEach(async () => { ctx = await makeContext(); });463464 test("SECP01 - generator uses crypto.getRandomValues", async () => {465 const { popup } = await setupVault(ctx);466 await openGenerator(popup);467 const pwd1 = await popup.locator(".peach-pwgen-compact-output").textContent();468 expect(pwd1?.length).toBeGreaterThan(0);469 await ctx.close();470 });471472 test("SECP02 - generated passwords not logged", async () => {473 const { popup } = await setupVault(ctx);474 await openGenerator(popup);475 const pwd = await popup.locator(".peach-pwgen-compact-output").textContent();476 expect(pwd).not.toContain("console.log");477 await ctx.close();478 });479480 test("SECP03 - password entropy meets minimum threshold", async () => {481 const { popup } = await setupVault(ctx);482 await openGenerator(popup);483 const slider = popup.locator(".peach-pwgen-slider");484 await slider.fill("32");485 await popup.waitForTimeout(300);486 const pwd = await popup.locator(".peach-pwgen-compact-output").textContent();487 expect(pwd?.length).toBe(32);488 const uniqueChars = new Set(pwd?.split("")).size;489 expect(uniqueChars).toBeGreaterThan(10);490 await ctx.close();491 });492});