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";67const __filename = fileURLToPath(import.meta.url);8const __dirname = dirname(__filename);9const EXTENSION_PATH = join(__dirname, "..", "dist");1011async function makeContext() {12 const { chromium } = await import("playwright");13 const userDataDir = join(os.tmpdir(), `peach-sec-${Date.now()}`);14 await fs.mkdir(userDataDir, { recursive: true });15 return chromium.launchPersistentContext(userDataDir, {16 headless: false,17 args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],18 });19}2021async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {22 const start = Date.now();23 let sw = ctx.serviceWorkers()[0];24 while (!sw && Date.now() - start < maxWait) {25 await new Promise(r => setTimeout(r, 500));26 sw = ctx.serviceWorkers()[0];27 }28 if (!sw) throw new Error(`No SW after ${maxWait}ms`);29 return new URL(sw.url()).host;30}3132async function setupVault(ctx: BrowserContext): Promise<{ popup: any, extId: string }> {33 const extId = await waitForSW(ctx);34 const popup = await ctx.newPage();35 await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);36 await popup.waitForTimeout(3000);37 const pw = popup.locator("#master-password");38 if (await pw.isVisible()) {39 await pw.fill("password123");40 await popup.locator("#confirm-password").fill("password123");41 await popup.getByRole("button", { name: /create vault/i }).click();42 await popup.waitForTimeout(2000);43 }44 return { popup, extId };45}4647test.describe("XSS Prevention", () => {48 let ctx: BrowserContext;49 test.afterEach(async () => { await ctx.close(); });50 test.beforeEach(async () => { ctx = await makeContext(); });5152 test("SECX01 - entry titles are escaped in DOM", async () => {53 const { popup } = await setupVault(ctx);54 await popup.locator('input[placeholder="search vault..."]').click();55 await popup.getByRole("button", { name: /new entry/i }).click();56 await popup.locator(".peach-new-name-input").fill('<script>alert("xss")</script>');57 await popup.locator('input[placeholder="domain.com"]').fill("https://xss.com");58 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");59 await popup.locator(".peach-new-field-input[placeholder='password']").fill("xss");60 await popup.getByRole("button", { name: /create/i }).click();61 await popup.waitForTimeout(1500);62 const entryName = popup.locator(".peach-entry-name").first();63 const text = await entryName.textContent();64 expect(text).not.toContain("<script>");65 await ctx.close();66 });6768 test("SECX02 - entry URLs are sanitized before display", async () => {69 const { popup } = await setupVault(ctx);70 await popup.locator('input[placeholder="search vault..."]').click();71 await popup.getByRole("button", { name: /new entry/i }).click();72 await popup.locator(".peach-new-name-input").fill("URLTest");73 await popup.locator('input[placeholder="domain.com"]').fill('javascript:alert("xss")');74 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");75 await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");76 await popup.getByRole("button", { name: /create/i }).click();77 await popup.waitForTimeout(1500);78 const domain = popup.locator(".peach-domain-value").first();79 const text = await domain.textContent().catch(() => "");80 expect(text).not.toContain("javascript:");81 await ctx.close();82 });8384 test("SECX03 - no inline event handlers in entry rendering", async () => {85 const { popup } = await setupVault(ctx);86 await popup.locator('input[placeholder="search vault..."]').click();87 await popup.getByRole("button", { name: /new entry/i }).click();88 await popup.locator(".peach-new-name-input").fill('" onmouseover="alert(1)" "');89 await popup.locator('input[placeholder="domain.com"]').fill("https://test.com");90 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");91 await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");92 await popup.getByRole("button", { name: /create/i }).click();93 await popup.waitForTimeout(1500);94 const entry = popup.locator(".peach-entry-name").first();95 const html = await entry.innerHTML();96 expect(html).not.toContain("onmouseover");97 await ctx.close();98 });99100 test("SECX04 - notes field renders without HTML execution", async () => {101 const { popup } = await setupVault(ctx);102 await popup.locator('input[placeholder="search vault..."]').click();103 await popup.getByRole("button", { name: /new entry/i }).click();104 await popup.locator(".peach-new-name-input").fill("NotesXSS");105 await popup.locator('input[placeholder="domain.com"]').fill("https://notes.com");106 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");107 await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");108 const notes = popup.locator("textarea");109 await notes.fill('<img src=x onerror="alert(1)">');110 await popup.getByRole("button", { name: /create/i }).click();111 await popup.waitForTimeout(1500);112 await popup.locator(".peach-entry-name-button").first().click();113 await popup.waitForTimeout(500);114 const noteContent = popup.locator(".peach-note-content").first();115 const html = await noteContent.innerHTML().catch(() => "");116 expect(html).not.toContain("onerror");117 await ctx.close();118 });119120 test("SECX05 - search input sanitizes query before display", async () => {121 const { popup } = await setupVault(ctx);122 await popup.locator('input[placeholder="search vault..."]').click();123 await popup.getByRole("button", { name: /new entry/i }).click();124 await popup.locator(".peach-new-name-input").fill("SearchTest");125 await popup.locator('input[placeholder="domain.com"]').fill("https://search.com");126 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");127 await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");128 await popup.getByRole("button", { name: /create/i }).click();129 await popup.waitForTimeout(1500);130 const searchInput = popup.locator(".peach-search-input, .peach-search-input-list").first();131 await searchInput.click();132 await searchInput.fill('<script>alert("xss")</script>');133 await popup.waitForTimeout(300);134 const value = await searchInput.inputValue();135 expect(value).not.toContain("<script>");136 await ctx.close();137 });138});139140test.describe("Session Security", () => {141 let ctx: BrowserContext;142 test.afterEach(async () => { await ctx.close(); });143 test.beforeEach(async () => { ctx = await makeContext(); });144145 test("SECS01 - session key cleared on lock", async () => {146 const { popup } = await setupVault(ctx);147 await popup.waitForTimeout(500);148 const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");149 await lockBtn.click();150 await popup.waitForTimeout(1500);151 const response = await popup.evaluate(async () => {152 return new Promise(resolve => {153 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));154 });155 });156 expect(response).toBeFalsy();157 await ctx.close();158 });159160 test("SECS02 - session does not persist across browser restarts", async () => {161 const { popup } = await setupVault(ctx);162 await popup.locator('input[placeholder="search vault..."]').click();163 await popup.getByRole("button", { name: /new entry/i }).click();164 await popup.locator(".peach-new-name-input").fill("PersistSession");165 await popup.locator('input[placeholder="domain.com"]').fill("https://persist.com");166 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");167 await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");168 await popup.getByRole("button", { name: /create/i }).click();169 await popup.waitForTimeout(1500);170 const newPage = await ctx.newPage();171 const extId = await waitForSW(ctx);172 await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`);173 await newPage.waitForTimeout(2000);174 const pwField = newPage.locator("#master-password");175 const isVisible = await pwField.isVisible({ timeout: 3000 }).catch(() => false);176 expect(isVisible).toBe(true);177 await ctx.close();178 });179180 test("SECS03 - idle timeout clears session", async () => {181 const { popup } = await setupVault(ctx);182 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();183 await popup.waitForTimeout(300);184 const autoLock = popup.locator("text=auto-lock").first();185 await expect(autoLock).toBeVisible();186 await ctx.close();187 });188189 test("SECS04 - master password not stored in memory after unlock", async () => {190 const { popup } = await setupVault(ctx);191 const hasStoredPW = await popup.evaluate(() => {192 return (window as any).__PEACH_MASTER_PASSWORD !== undefined ||193 (window as any).masterPassword !== undefined;194 });195 expect(hasStoredPW).toBeFalsy();196 await ctx.close();197 });198199 test("SECS05 - vault data not accessible without unlock", async () => {200 const { popup: _popup } = await setupVault(ctx);201 const newPage = await ctx.newPage();202 const extId = await waitForSW(ctx);203 await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`);204 await newPage.waitForTimeout(2000);205 const response = await newPage.evaluate(async () => {206 return new Promise(resolve => {207 chrome.runtime.sendMessage({ type: "GET_ENTRIES" }, r => resolve(r));208 });209 });210 expect(response).toBeFalsy();211 await ctx.close();212 });213214 test("SECS06 - service worker message validates sender origin", async () => {215 const ctx2 = await makeContext();216 const { popup } = await setupVault(ctx);217 const response = await popup.evaluate(async () => {218 return new Promise(resolve => {219 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));220 });221 });222 expect(response).toBeDefined();223 await ctx.close();224 await ctx2.close();225 });226227 test("SECS07 - no sensitive data in extension localStorage", async () => {228 const { popup } = await setupVault(ctx);229 await popup.waitForTimeout(1000);230 const storage = await popup.evaluate(() => {231 return new Promise(resolve => {232 chrome.storage.local.get(null, r => resolve(r));233 });234 }) as any;235 if (storage && typeof storage === 'object') {236 const hasSensitive = Object.keys(storage).some(k =>237 k.toLowerCase().includes('password') ||238 k.toLowerCase().includes('secret') ||239 k.toLowerCase().includes('key')240 );241 expect(hasSensitive).toBeFalsy();242 }243 await ctx.close();244 });245});246247test.describe("CSP Compliance", () => {248 let ctx: BrowserContext;249 test.afterEach(async () => { await ctx.close(); });250 test.beforeEach(async () => { ctx = await makeContext(); });251252 test("SEQC01 - no inline scripts in popup HTML", async () => {253 const extId = await waitForSW(ctx);254 const page = await ctx.newPage();255 await page.goto(`chrome-extension://${extId}/src/popup/index.html`);256 await page.waitForTimeout(2000);257 const scripts = await page.evaluate(() => {258 const html = document.documentElement.innerHTML;259 const scriptMatches = html.match(/<script[^>]*>/gi) || [];260 return scriptMatches.filter(s => !s.includes('src='));261 });262 expect(scripts.length).toBe(0);263 await ctx.close();264 });265266 test("SEQC02 - no eval() in content script", async () => {267 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));268 const jsFiles = distFiles.filter(f => f.endsWith(".js"));269 let foundEval = false;270 for (const file of jsFiles) {271 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");272 if (content.includes("eval(") && !content.includes("new Function")) {273 foundEval = true;274 break;275 }276 }277 expect(foundEval).toBeFalsy();278 await ctx.close();279 });280281 test("SEQC03 - CSP meta tag present in HTML", async () => {282 const html = await fs.readFile(join(__dirname, "..", "dist", "src", "popup", "index.html"), "utf-8");283 const hasCSP = html.includes("Content-Security-Policy") || html.includes("csp");284 expect(hasCSP).toBeTruthy();285 await ctx.close();286 });287288 test("SEQC04 - no remote script loading in content script", async () => {289 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));290 const jsFiles = distFiles.filter(f => f.endsWith(".js"));291 let foundRemote = false;292 for (const file of jsFiles) {293 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");294 if (content.match(/https?:\/\/[^"']+\.js/)) {295 foundRemote = true;296 break;297 }298 }299 expect(foundRemote).toBeFalsy();300 await ctx.close();301 });302});303304test.describe("Encryption Verification", () => {305 let ctx: BrowserContext;306 test.afterEach(async () => { await ctx.close(); });307 test.beforeEach(async () => { ctx = await makeContext(); });308309 test("SECE01 - Argon2id parameters documented in code", async () => {310 const argon2File = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>311 f.find(f => f.includes("argon") || f.includes("crypto"))312 );313 expect(argon2File).toBeDefined();314 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", argon2File!), "utf-8");315 expect(content).toMatch(/65536|3|4|argon/);316 await ctx.close();317 });318319 test("SECE02 - AES-GCM used for vault encryption", async () => {320 const aesFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>321 f.filter(f => f.includes("aes"))322 );323 expect(aesFiles.length).toBeGreaterThan(0);324 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", aesFiles[0]), "utf-8");325 expect(content).toMatch(/AES-GCM|aes-gcm|AESGCM|AES.*GCM/);326 await ctx.close();327 });328329 test("SECE03 - no ECB mode in encryption", async () => {330 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));331 const jsFiles = distFiles.filter(f => f.endsWith(".js"));332 let foundECB = false;333 for (const file of jsFiles) {334 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");335 if (content.includes("ECB") && !content.includes("// ECB")) {336 foundECB = true;337 break;338 }339 }340 expect(foundECB).toBeFalsy();341 await ctx.close();342 });343344 test("SECE04 - crypto.getRandomValues used for all random", async () => {345 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));346 const jsFiles = distFiles.filter(f => f.endsWith(".js"));347 let foundMathRandom = false;348 for (const file of jsFiles) {349 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");350 const mathRandMatches = content.match(/Math\.random\(\)/g) || [];351 const cryptoRandMatches = content.match(/crypto\.getRandomValues\(/g) || [];352 if (mathRandMatches.length > cryptoRandMatches.length) {353 foundMathRandom = true;354 break;355 }356 }357 expect(foundMathRandom).toBeFalsy();358 await ctx.close();359 });360361 test("SECE05 - salt generated for each encryption operation", async () => {362 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));363 const jsFiles = distFiles.filter(f => f.endsWith(".js"));364 let hasSaltGen = false;365 for (const file of jsFiles) {366 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");367 if (content.includes("generateSalt") || content.includes("getRandomValues")) {368 hasSaltGen = true;369 break;370 }371 }372 expect(hasSaltGen).toBeTruthy();373 await ctx.close();374 });375376 test("SECE06 - key stored in secure memory not plain", async () => {377 const { popup } = await setupVault(ctx);378 await popup.waitForTimeout(1000);379 const keyInDOM = await popup.evaluate(() => {380 const inputs = document.querySelectorAll("input");381 for (const input of inputs) {382 if (input.value && input.value.length > 20 && !input.type) {383 return true;384 }385 }386 return false;387 });388 expect(keyInDOM).toBeFalsy();389 await ctx.close();390 });391392 test("SECE07 - export uses PBKDF2 with high iterations", async () => {393 const exportFile = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>394 f.find(f => f.includes("export") || f.includes("Export"))395 );396 expect(exportFile).toBeDefined();397 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", exportFile!), "utf-8");398 if (content.includes("PBKDF2")) {399 expect(content).toMatch(/iterations.*[0-9]{4,}|250000/);400 }401 await ctx.close();402 });403});404405test.describe("CSRF Protection", () => {406 let ctx: BrowserContext;407 test.afterEach(async () => { await ctx.close(); });408 test.beforeEach(async () => { ctx = await makeContext(); });409410 test("SECC01 - service worker rejects cross-origin messages", async () => {411 const { popup } = await setupVault(ctx);412 const response = await popup.evaluate(async () => {413 return new Promise(resolve => {414 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));415 });416 });417 expect(response).toBeDefined();418 await ctx.close();419 });420421 test("SECC02 - no token exposure in URL parameters", async () => {422 const { popup } = await setupVault(ctx);423 await popup.locator('input[placeholder="search vault..."]').click();424 await popup.getByRole("button", { name: /new entry/i }).click();425 await popup.locator(".peach-new-name-input").fill("URLToken");426 await popup.locator('input[placeholder="domain.com"]').fill("https://token.com");427 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");428 await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");429 await popup.getByRole("button", { name: /create/i }).click();430 await popup.waitForTimeout(1500);431 const url = popup.url();432 expect(url).not.toMatch(/token|key|secret|session/i);433 await ctx.close();434 });435436 test("SECC03 - clipboard cleared after copy operation", async () => {437 const { popup } = await setupVault(ctx);438 await popup.locator('input[placeholder="search vault..."]').click();439 await popup.getByRole("button", { name: /new entry/i }).click();440 await popup.locator(".peach-new-name-input").fill("ClipboardSec");441 await popup.locator('input[placeholder="domain.com"]').fill("https://clip.com");442 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");443 await popup.locator(".peach-new-field-input[placeholder='password']").fill("clipsecret");444 await popup.getByRole("button", { name: /create/i }).click();445 await popup.waitForTimeout(1500);446 await popup.locator(".peach-entry-name-button").first().click();447 await popup.waitForTimeout(500);448 const copyBtn = popup.locator(".peach-copy-btn").first();449 if (await copyBtn.isVisible({ timeout: 2000 }).catch(() => false)) {450 await copyBtn.click();451 await popup.waitForTimeout(35000);452 const clipboard = await popup.evaluate(() => navigator.clipboard.readText());453 expect(clipboard).not.toBe("clipsecret");454 }455 await ctx.close();456 });457});458459test.describe("Breach Detection Security", () => {460 let ctx: BrowserContext;461 test.afterEach(async () => { await ctx.close(); });462 test.beforeEach(async () => { ctx = await makeContext(); });463464 test("SECB01 - HIBP API call uses k-anonymity model", async () => {465 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));466 const jsFiles = distFiles.filter(f => f.endsWith(".js"));467 let foundHIBP = false;468 for (const file of jsFiles) {469 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");470 if (content.includes("hibp") || content.includes("pwned")) {471 foundHIBP = true;472 expect(content).toMatch(/range|hash|k-anon|sha1/i);473 break;474 }475 }476 expect(foundHIBP).toBeTruthy();477 await ctx.close();478 });479480 test("SECB02 - breach check does not expose full password", async () => {481 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));482 const jsFiles = distFiles.filter(f => f.endsWith(".js"));483 let foundHash = false;484 for (const file of jsFiles) {485 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");486 if (content.includes("breach") || content.includes("hibp")) {487 if (content.includes("sha1") || content.includes("SHA-1")) {488 foundHash = true;489 break;490 }491 }492 }493 expect(foundHash).toBeTruthy();494 await ctx.close();495 });496497 test("SECB03 - breach status stored securely", async () => {498 const { popup } = await setupVault(ctx);499 await popup.waitForTimeout(500);500 const storage = await popup.evaluate(() => {501 return new Promise(resolve => {502 chrome.storage.local.get(null, r => resolve(r));503 });504 }) as any;505 if (storage) {506 const hasBreachClearText = JSON.stringify(storage).includes("breached");507 expect(hasBreachClearText).toBeFalsy();508 }509 await ctx.close();510 });511});512513test.describe("Password Generation Security", () => {514 let ctx: BrowserContext;515 test.afterEach(async () => { await ctx.close(); });516 test.beforeEach(async () => { ctx = await makeContext(); });517518 test("SECP01 - generator uses crypto.getRandomValues", async () => {519 const { popup } = await setupVault(ctx);520 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();521 await popup.waitForTimeout(300);522 await popup.getByRole("button", { name: /password generator/i }).click();523 await popup.waitForTimeout(500);524 const pwd1 = await popup.locator(".peach-pwgen-output").textContent();525 expect(pwd1?.length).toBeGreaterThan(0);526 await ctx.close();527 });528529 test("SECP02 - generated passwords not logged", async () => {530 const { popup } = await setupVault(ctx);531 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();532 await popup.waitForTimeout(300);533 await popup.getByRole("button", { name: /password generator/i }).click();534 await popup.waitForTimeout(500);535 const pwd = await popup.locator(".peach-pwgen-output").textContent();536 expect(pwd).not.toContain("console.log");537 await ctx.close();538 });539540 test("SECP03 - password entropy meets minimum threshold", async () => {541 const { popup } = await setupVault(ctx);542 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();543 await popup.waitForTimeout(300);544 await popup.getByRole("button", { name: /password generator/i }).click();545 await popup.waitForTimeout(500);546 const slider = popup.locator(".peach-pwgen-slider");547 await slider.fill("32");548 await popup.waitForTimeout(300);549 const pwd = await popup.locator(".peach-pwgen-output").textContent();550 expect(pwd?.length).toBe(32);551 const uniqueChars = new Set(pwd?.split("")).size;552 expect(uniqueChars).toBeGreaterThan(10);553 await ctx.close();554 });555});