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 const entryName = popup.locator(".peach-entry-name").first();41 const text = await entryName.textContent();42 expect(text).not.toContain("<script>");43 await ctx.close();44 });4546 test("SECX02 - entry URLs are sanitized before display", async () => {47 const { popup } = await setupVault(ctx);48 await createEntryViaForm(popup, { title: "URLTest", url: 'javascript:alert("xss")', username: "[email protected]", password: "pass" });49 const domain = popup.locator(".peach-domain-value").first();50 const text = await domain.textContent().catch(() => "");51 expect(text).not.toContain("javascript:");52 await ctx.close();53 });5455 test("SECX03 - no inline event handlers in entry rendering", async () => {56 const { popup } = await setupVault(ctx);57 await createEntryViaForm(popup, { title: '" onmouseover="alert(1)" "', url: "https://test.com", username: "[email protected]", password: "pass" });58 const entry = popup.locator(".peach-entry-name").first();59 const html = await entry.innerHTML();60 expect(html).not.toContain("onmouseover");61 await ctx.close();62 });6364 test("SECX04 - notes field renders without HTML execution", async () => {65 const { popup } = await setupVault(ctx);66 await createEntryViaForm(popup, { title: "NotesXSS", url: "https://notes.com", username: "[email protected]", password: "pass", notes: '<img src=x onerror="alert(1)">' });67 await popup.locator(".peach-entry-name-button").first().click();68 await popup.waitForTimeout(500);69 const noteContent = popup.locator(".peach-note-content").first();70 const html = await noteContent.innerHTML().catch(() => "");71 expect(html).not.toContain("onerror");72 await ctx.close();73 });7475 test("SECX05 - search input sanitizes query before display", async () => {76 const { popup } = await setupVault(ctx);77 await createEntryViaForm(popup, { title: "SearchTest", url: "https://search.com", username: "[email protected]", password: "pass" });78 await openList(popup);79 const searchInput = listSearch(popup);80 await searchInput.fill('<script>alert("xss")</script>');81 await popup.waitForTimeout(300);82 const value = await searchInput.inputValue();83 expect(value).not.toContain("<script>");84 await ctx.close();85 });86});8788test.describe("Session Security", () => {89 let ctx: BrowserContext;90 test.afterEach(async () => { await ctx.close(); });91 test.beforeEach(async () => { ctx = await makeContext(); });9293 test("SECS01 - session key cleared on lock", async () => {94 const { popup } = await setupVault(ctx);95 await lockVault(popup);96 97 98 const response = await popup.evaluate(async () => {99 return new Promise<{ ok: boolean; data: boolean }>(resolve => {100 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));101 });102 });103 expect(response.data).toBe(false);104 await ctx.close();105 });106107 test("SECS02 - session does not persist across browser restarts", async () => {108 const { chromium } = await import("playwright");109 const userDataDir = join(os.tmpdir(), `peach-sec-restart-${Date.now()}`);110 await fs.mkdir(userDataDir, { recursive: true });111 const launch = () => chromium.launchPersistentContext(userDataDir, {112 headless: false,113 args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],114 });115116 const first = await launch();117 const { popup } = await setupVault(first);118 await createEntryViaForm(popup, { title: "PersistSession", url: "https://persist.com", username: "[email protected]", password: "pass" });119 await first.close();120121 const second = await launch();122 const extId = await waitForSW(second);123 const reopened = await second.newPage();124 await reopened.goto(`chrome-extension://${extId}/src/popup/index.html`);125 await expect(reopened.locator("#unlock-password")).toBeVisible({ timeout: 20000 });126 await expect(reopened.locator(UNLOCKED)).toHaveCount(0);127 await second.close();128 });129130 test("SECS03 - idle timeout clears session", async () => {131 const { popup } = await setupVault(ctx);132 await openSettings(popup);133 await popup.locator('[data-settings-card-id="protection"]').click();134 await expect(135 popup.locator(".peach-settings-row", { hasText: "Auto-lock after" }),136 ).toHaveCount(1);137 await ctx.close();138 });139140 test("SECS04 - master password not stored in memory after unlock", async () => {141 const { popup } = await setupVault(ctx);142 const hasStoredPW = await popup.evaluate(() => {143 return (window as any).__PEACH_MASTER_PASSWORD !== undefined ||144 (window as any).masterPassword !== undefined;145 });146 expect(hasStoredPW).toBeFalsy();147 await ctx.close();148 });149150 test("SECS05 - vault data not accessible without unlock", async () => {151 const { popup } = await setupVault(ctx);152 await createEntryViaForm(popup, { title: "LockedRead", url: "https://locked.test", username: "u", password: "p" });153 await lockVault(popup);154 const response = await popup.evaluate(async () => {155 return new Promise<{ ok: boolean; error?: string; data?: unknown }>(resolve => {156 chrome.runtime.sendMessage({ type: "GET_ENTRIES" }, r => resolve(r));157 });158 });159 expect(response.ok).toBe(false);160 expect(response.error).toBe("vault_locked");161 expect(response.data).toBeUndefined();162 await ctx.close();163 });164165 test("SECS06 - service worker message validates sender origin", async () => {166 const ctx2 = await makeContext();167 const { popup } = await setupVault(ctx);168 const response = await popup.evaluate(async () => {169 return new Promise(resolve => {170 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));171 });172 });173 expect(response).toBeDefined();174 await ctx.close();175 await ctx2.close();176 });177178 test("SECS07 - no sensitive data in extension localStorage", async () => {179 const { popup } = await setupVault(ctx);180 await popup.waitForTimeout(1000);181 const storage = await popup.evaluate(() => {182 return new Promise(resolve => {183 chrome.storage.local.get(null, r => resolve(r));184 });185 }) as any;186 if (storage && typeof storage === 'object') {187 const hasSensitive = Object.keys(storage).some(k =>188 k.toLowerCase().includes('password') ||189 k.toLowerCase().includes('secret') ||190 k.toLowerCase().includes('key')191 );192 expect(hasSensitive).toBeFalsy();193 }194 await ctx.close();195 });196});197198test.describe("CSP Compliance", () => {199 let ctx: BrowserContext;200 test.afterEach(async () => { await ctx.close(); });201 test.beforeEach(async () => { ctx = await makeContext(); });202203 test("SEQC01 - no inline scripts in popup HTML", async () => {204 const extId = await waitForSW(ctx);205 const page = await ctx.newPage();206 await page.goto(`chrome-extension://${extId}/src/popup/index.html`);207 await page.waitForTimeout(2000);208 const scripts = await page.evaluate(() => {209 const html = document.documentElement.innerHTML;210 const scriptMatches = html.match(/<script[^>]*>/gi) || [];211 return scriptMatches.filter(s => !s.includes('src='));212 });213 expect(scripts.length).toBe(0);214 await ctx.close();215 });216217 test("SEQC02 - no eval() in content script", async () => {218 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));219 const jsFiles = distFiles.filter(f => f.endsWith(".js"));220 let foundEval = false;221 for (const file of jsFiles) {222 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");223 if (content.includes("eval(") && !content.includes("new Function")) {224 foundEval = true;225 break;226 }227 }228 expect(foundEval).toBeFalsy();229 await ctx.close();230 });231232 test("SEQC03 - CSP meta tag present in HTML", async () => {233 const html = await fs.readFile(join(__dirname, "..", "dist", "src", "popup", "index.html"), "utf-8");234 const hasCSP = html.includes("Content-Security-Policy") || html.includes("csp");235 expect(hasCSP).toBeTruthy();236 await ctx.close();237 });238239 test("SEQC04 - no remote script loading in content script", async () => {240 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));241 const jsFiles = distFiles.filter(f => f.endsWith(".js"));242 let foundRemote = false;243 for (const file of jsFiles) {244 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");245 if (content.match(/https?:\/\/[^"']+\.js/)) {246 foundRemote = true;247 break;248 }249 }250 expect(foundRemote).toBeFalsy();251 await ctx.close();252 });253});254255test.describe("Encryption Verification", () => {256 let ctx: BrowserContext;257 test.afterEach(async () => { await ctx.close(); });258 test.beforeEach(async () => { ctx = await makeContext(); });259260 test("SECE01 - Argon2id parameters documented in code", async () => {261 const argon2File = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>262 f.find(f => f.includes("argon") || f.includes("crypto"))263 );264 expect(argon2File).toBeDefined();265 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", argon2File!), "utf-8");266 expect(content).toMatch(/65536|3|4|argon/);267 await ctx.close();268 });269270 test("SECE02 - AES-GCM used for vault encryption", async () => {271 const aesFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>272 f.filter(f => f.includes("aes"))273 );274 expect(aesFiles.length).toBeGreaterThan(0);275 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", aesFiles[0]), "utf-8");276 expect(content).toMatch(/AES-GCM|aes-gcm|AESGCM|AES.*GCM/);277 await ctx.close();278 });279280 test("SECE03 - no ECB mode in encryption", async () => {281 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));282 const jsFiles = distFiles.filter(f => f.endsWith(".js"));283 let foundECB = false;284 for (const file of jsFiles) {285 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");286 if (content.includes("ECB") && !content.includes("// ECB")) {287 foundECB = true;288 break;289 }290 }291 expect(foundECB).toBeFalsy();292 await ctx.close();293 });294295 test("SECE04 - crypto.getRandomValues used for all random", async () => {296 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));297 const jsFiles = distFiles.filter(f => f.endsWith(".js"));298 let foundMathRandom = false;299 for (const file of jsFiles) {300 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");301 const mathRandMatches = content.match(/Math\.random\(\)/g) || [];302 const cryptoRandMatches = content.match(/crypto\.getRandomValues\(/g) || [];303 if (mathRandMatches.length > cryptoRandMatches.length) {304 foundMathRandom = true;305 break;306 }307 }308 expect(foundMathRandom).toBeFalsy();309 await ctx.close();310 });311312 test("SECE05 - salt generated for each encryption operation", async () => {313 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));314 const jsFiles = distFiles.filter(f => f.endsWith(".js"));315 let hasSaltGen = false;316 for (const file of jsFiles) {317 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");318 if (content.includes("generateSalt") || content.includes("getRandomValues")) {319 hasSaltGen = true;320 break;321 }322 }323 expect(hasSaltGen).toBeTruthy();324 await ctx.close();325 });326327 test("SECE06 - key stored in secure memory not plain", async () => {328 const { popup } = await setupVault(ctx);329 await popup.waitForTimeout(1000);330 const keyInDOM = await popup.evaluate(() => {331 const inputs = document.querySelectorAll("input");332 for (const input of inputs) {333 if (input.value && input.value.length > 20 && !input.type) {334 return true;335 }336 }337 return false;338 });339 expect(keyInDOM).toBeFalsy();340 await ctx.close();341 });342343 test("SECE07 - export uses PBKDF2 with high iterations", async () => {344 const exportFile = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>345 f.find(f => f.includes("export") || f.includes("Export"))346 );347 expect(exportFile).toBeDefined();348 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", exportFile!), "utf-8");349 if (content.includes("PBKDF2")) {350 expect(content).toMatch(/iterations.*[0-9]{4,}|250000/);351 }352 await ctx.close();353 });354});355356test.describe("CSRF Protection", () => {357 let ctx: BrowserContext;358 test.afterEach(async () => { await ctx.close(); });359 test.beforeEach(async () => { ctx = await makeContext(); });360361 test("SECC01 - service worker rejects cross-origin messages", async () => {362 const { popup } = await setupVault(ctx);363 const response = await popup.evaluate(async () => {364 return new Promise(resolve => {365 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));366 });367 });368 expect(response).toBeDefined();369 await ctx.close();370 });371372 test("SECC02 - no token exposure in URL parameters", async () => {373 const { popup } = await setupVault(ctx);374 await createEntryViaForm(popup, { title: "URLToken", url: "https://token.com", username: "[email protected]", password: "pass" });375 const url = popup.url();376 expect(url).not.toMatch(/token|key|secret|session/i);377 await ctx.close();378 });379380 test("SECC03 - clipboard cleared after copy operation", async () => {381 const { popup } = await setupVault(ctx);382 await createEntryViaForm(popup, { title: "ClipboardSec", url: "https://clip.com", username: "[email protected]", password: "clipsecret" });383 await popup.locator(".peach-entry-name-button").first().click();384 await popup.waitForTimeout(500);385 const copyBtn = popup.locator(".peach-copy-btn").first();386 if (await copyBtn.isVisible({ timeout: 2000 }).catch(() => false)) {387 await copyBtn.click();388 await popup.waitForTimeout(35000);389 const clipboard = await popup.evaluate(() => navigator.clipboard.readText());390 expect(clipboard).not.toBe("clipsecret");391 }392 await ctx.close();393 });394});395396test.describe("Breach Detection Security", () => {397 let ctx: BrowserContext;398 test.afterEach(async () => { await ctx.close(); });399 test.beforeEach(async () => { ctx = await makeContext(); });400401 test("SECB01 - HIBP API call uses k-anonymity model", async () => {402 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));403 const jsFiles = distFiles.filter(f => f.endsWith(".js"));404 let foundHIBP = false;405 for (const file of jsFiles) {406 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");407 if (content.includes("hibp") || content.includes("pwned")) {408 foundHIBP = true;409 expect(content).toMatch(/range|hash|k-anon|sha1/i);410 break;411 }412 }413 expect(foundHIBP).toBeTruthy();414 await ctx.close();415 });416417 test("SECB02 - breach check does not expose full password", async () => {418 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));419 const jsFiles = distFiles.filter(f => f.endsWith(".js"));420 let foundHash = false;421 for (const file of jsFiles) {422 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");423 if (content.includes("breach") || content.includes("hibp")) {424 if (content.includes("sha1") || content.includes("SHA-1")) {425 foundHash = true;426 break;427 }428 }429 }430 expect(foundHash).toBeTruthy();431 await ctx.close();432 });433434 test("SECB03 - breach status stored securely", async () => {435 const { popup } = await setupVault(ctx);436 await popup.waitForTimeout(500);437 const storage = await popup.evaluate(() => {438 return new Promise(resolve => {439 chrome.storage.local.get(null, r => resolve(r));440 });441 }) as any;442 if (storage) {443 const hasBreachClearText = JSON.stringify(storage).includes("breached");444 expect(hasBreachClearText).toBeFalsy();445 }446 await ctx.close();447 });448});449450test.describe("Password Generation Security", () => {451 let ctx: BrowserContext;452 test.afterEach(async () => { await ctx.close(); });453 test.beforeEach(async () => { ctx = await makeContext(); });454455 test("SECP01 - generator uses crypto.getRandomValues", async () => {456 const { popup } = await setupVault(ctx);457 await openGenerator(popup);458 const pwd1 = await popup.locator(".peach-pwgen-compact-output").textContent();459 expect(pwd1?.length).toBeGreaterThan(0);460 await ctx.close();461 });462463 test("SECP02 - generated passwords not logged", async () => {464 const { popup } = await setupVault(ctx);465 await openGenerator(popup);466 const pwd = await popup.locator(".peach-pwgen-compact-output").textContent();467 expect(pwd).not.toContain("console.log");468 await ctx.close();469 });470471 test("SECP03 - password entropy meets minimum threshold", async () => {472 const { popup } = await setupVault(ctx);473 await openGenerator(popup);474 const slider = popup.locator(".peach-pwgen-slider");475 await slider.fill("32");476 await popup.waitForTimeout(300);477 const pwd = await popup.locator(".peach-pwgen-compact-output").textContent();478 expect(pwd?.length).toBe(32);479 const uniqueChars = new Set(pwd?.split("")).size;480 expect(uniqueChars).toBeGreaterThan(10);481 await ctx.close();482 });483});