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 openSettings,13 setupVault,14 waitForSW,15} from "./helpers/vault";1617const __filename = fileURLToPath(import.meta.url);18const __dirname = dirname(__filename);192021test.describe.configure({ timeout: 90_000 });222324async function openGenerator(popup: import("@playwright/test").Page) {25 await popup.locator(".peach-new-entry-row").click();26 await popup.getByRole("button", { name: /generate/i }).click();27 await expect(popup.locator(".peach-pwgen-compact-output")).toBeVisible({ timeout: 5000 });28}2930test.describe("XSS Prevention", () => {31 let ctx: BrowserContext;32 test.afterEach(async () => { await ctx.close(); });33 test.beforeEach(async () => { ctx = await makeContext(); });3435 test("SECX01 - entry titles are escaped in DOM", async () => {36 const { popup } = await setupVault(ctx);37 await createEntryViaForm(popup, { title: '<script>alert("xss")</script>', url: "https://xss.com", username: "[email protected]", password: "xss" });38 const entryName = popup.locator(".peach-entry-name").first();39 const text = await entryName.textContent();40 expect(text).not.toContain("<script>");41 await ctx.close();42 });4344 test("SECX02 - entry URLs are sanitized before display", async () => {45 const { popup } = await setupVault(ctx);46 await createEntryViaForm(popup, { title: "URLTest", url: 'javascript:alert("xss")', username: "[email protected]", password: "pass" });47 const domain = popup.locator(".peach-domain-value").first();48 const text = await domain.textContent().catch(() => "");49 expect(text).not.toContain("javascript:");50 await ctx.close();51 });5253 test("SECX03 - no inline event handlers in entry rendering", async () => {54 const { popup } = await setupVault(ctx);55 await createEntryViaForm(popup, { title: '" onmouseover="alert(1)" "', url: "https://test.com", username: "[email protected]", password: "pass" });56 const entry = popup.locator(".peach-entry-name").first();57 const html = await entry.innerHTML();58 expect(html).not.toContain("onmouseover");59 await ctx.close();60 });6162 test("SECX04 - notes field renders without HTML execution", async () => {63 const { popup } = await setupVault(ctx);64 await createEntryViaForm(popup, { title: "NotesXSS", url: "https://notes.com", username: "[email protected]", password: "pass", notes: '<img src=x onerror="alert(1)">' });65 await popup.locator(".peach-entry-name-button").first().click();66 await popup.waitForTimeout(500);67 const noteContent = popup.locator(".peach-note-content").first();68 const html = await noteContent.innerHTML().catch(() => "");69 expect(html).not.toContain("onerror");70 await ctx.close();71 });7273 test("SECX05 - search input sanitizes query before display", async () => {74 const { popup } = await setupVault(ctx);75 await createEntryViaForm(popup, { title: "SearchTest", url: "https://search.com", username: "[email protected]", password: "pass" });76 const searchInput = popup.locator(".peach-search-input, .peach-search-input-list").first();77 await searchInput.click();78 await searchInput.fill('<script>alert("xss")</script>');79 await popup.waitForTimeout(300);80 const value = await searchInput.inputValue();81 expect(value).not.toContain("<script>");82 await ctx.close();83 });84});8586test.describe("Session Security", () => {87 let ctx: BrowserContext;88 test.afterEach(async () => { await ctx.close(); });89 test.beforeEach(async () => { ctx = await makeContext(); });9091 test("SECS01 - session key cleared on lock", async () => {92 const { popup } = await setupVault(ctx);93 await lockVault(popup);94 95 96 const response = await popup.evaluate(async () => {97 return new Promise<{ ok: boolean; data: boolean }>(resolve => {98 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));99 });100 });101 expect(response.data).toBe(false);102 await ctx.close();103 });104105 test("SECS02 - session does not persist across browser restarts", async () => {106 const { chromium } = await import("playwright");107 const userDataDir = join(os.tmpdir(), `peach-sec-restart-${Date.now()}`);108 await fs.mkdir(userDataDir, { recursive: true });109 const launch = () => chromium.launchPersistentContext(userDataDir, {110 headless: false,111 args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],112 });113114 const first = await launch();115 const { popup } = await setupVault(first);116 await createEntryViaForm(popup, { title: "PersistSession", url: "https://persist.com", username: "[email protected]", password: "pass" });117 await first.close();118119 const second = await launch();120 const extId = await waitForSW(second);121 const reopened = await second.newPage();122 await reopened.goto(`chrome-extension://${extId}/src/popup/index.html`);123 await expect(reopened.locator("#unlock-password")).toBeVisible({ timeout: 20000 });124 await expect(reopened.locator(UNLOCKED)).toHaveCount(0);125 await second.close();126 });127128 test("SECS03 - idle timeout clears session", async () => {129 const { popup } = await setupVault(ctx);130 await openSettings(popup);131 await popup.locator('[data-settings-card-id="protection"]').click();132 await expect(133 popup.locator(".peach-settings-row", { hasText: "Auto-lock after" }),134 ).toHaveCount(1);135 await ctx.close();136 });137138 test("SECS04 - master password not stored in memory after unlock", async () => {139 const { popup } = await setupVault(ctx);140 const hasStoredPW = await popup.evaluate(() => {141 return (window as any).__PEACH_MASTER_PASSWORD !== undefined ||142 (window as any).masterPassword !== undefined;143 });144 expect(hasStoredPW).toBeFalsy();145 await ctx.close();146 });147148 test("SECS05 - vault data not accessible without unlock", async () => {149 const { popup } = await setupVault(ctx);150 await createEntryViaForm(popup, { title: "LockedRead", url: "https://locked.test", username: "u", password: "p" });151 await lockVault(popup);152 const response = await popup.evaluate(async () => {153 return new Promise<{ ok: boolean; error?: string; data?: unknown }>(resolve => {154 chrome.runtime.sendMessage({ type: "GET_ENTRIES" }, r => resolve(r));155 });156 });157 expect(response.ok).toBe(false);158 expect(response.error).toBe("vault_locked");159 expect(response.data).toBeUndefined();160 await ctx.close();161 });162163 test("SECS06 - service worker message validates sender origin", async () => {164 const ctx2 = await makeContext();165 const { popup } = await setupVault(ctx);166 const response = await popup.evaluate(async () => {167 return new Promise(resolve => {168 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));169 });170 });171 expect(response).toBeDefined();172 await ctx.close();173 await ctx2.close();174 });175176 test("SECS07 - no sensitive data in extension localStorage", async () => {177 const { popup } = await setupVault(ctx);178 await popup.waitForTimeout(1000);179 const storage = await popup.evaluate(() => {180 return new Promise(resolve => {181 chrome.storage.local.get(null, r => resolve(r));182 });183 }) as any;184 if (storage && typeof storage === 'object') {185 const hasSensitive = Object.keys(storage).some(k =>186 k.toLowerCase().includes('password') ||187 k.toLowerCase().includes('secret') ||188 k.toLowerCase().includes('key')189 );190 expect(hasSensitive).toBeFalsy();191 }192 await ctx.close();193 });194});195196test.describe("CSP Compliance", () => {197 let ctx: BrowserContext;198 test.afterEach(async () => { await ctx.close(); });199 test.beforeEach(async () => { ctx = await makeContext(); });200201 test("SEQC01 - no inline scripts in popup HTML", async () => {202 const extId = await waitForSW(ctx);203 const page = await ctx.newPage();204 await page.goto(`chrome-extension://${extId}/src/popup/index.html`);205 await page.waitForTimeout(2000);206 const scripts = await page.evaluate(() => {207 const html = document.documentElement.innerHTML;208 const scriptMatches = html.match(/<script[^>]*>/gi) || [];209 return scriptMatches.filter(s => !s.includes('src='));210 });211 expect(scripts.length).toBe(0);212 await ctx.close();213 });214215 test("SEQC02 - no eval() in content script", async () => {216 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));217 const jsFiles = distFiles.filter(f => f.endsWith(".js"));218 let foundEval = false;219 for (const file of jsFiles) {220 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");221 if (content.includes("eval(") && !content.includes("new Function")) {222 foundEval = true;223 break;224 }225 }226 expect(foundEval).toBeFalsy();227 await ctx.close();228 });229230 test("SEQC03 - CSP meta tag present in HTML", async () => {231 const html = await fs.readFile(join(__dirname, "..", "dist", "src", "popup", "index.html"), "utf-8");232 const hasCSP = html.includes("Content-Security-Policy") || html.includes("csp");233 expect(hasCSP).toBeTruthy();234 await ctx.close();235 });236237 test("SEQC04 - no remote script loading in content script", async () => {238 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));239 const jsFiles = distFiles.filter(f => f.endsWith(".js"));240 let foundRemote = false;241 for (const file of jsFiles) {242 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");243 if (content.match(/https?:\/\/[^"']+\.js/)) {244 foundRemote = true;245 break;246 }247 }248 expect(foundRemote).toBeFalsy();249 await ctx.close();250 });251});252253test.describe("Encryption Verification", () => {254 let ctx: BrowserContext;255 test.afterEach(async () => { await ctx.close(); });256 test.beforeEach(async () => { ctx = await makeContext(); });257258 test("SECE01 - Argon2id parameters documented in code", async () => {259 const argon2File = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>260 f.find(f => f.includes("argon") || f.includes("crypto"))261 );262 expect(argon2File).toBeDefined();263 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", argon2File!), "utf-8");264 expect(content).toMatch(/65536|3|4|argon/);265 await ctx.close();266 });267268 test("SECE02 - AES-GCM used for vault encryption", async () => {269 const aesFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>270 f.filter(f => f.includes("aes"))271 );272 expect(aesFiles.length).toBeGreaterThan(0);273 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", aesFiles[0]), "utf-8");274 expect(content).toMatch(/AES-GCM|aes-gcm|AESGCM|AES.*GCM/);275 await ctx.close();276 });277278 test("SECE03 - no ECB mode in encryption", async () => {279 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));280 const jsFiles = distFiles.filter(f => f.endsWith(".js"));281 let foundECB = false;282 for (const file of jsFiles) {283 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");284 if (content.includes("ECB") && !content.includes("// ECB")) {285 foundECB = true;286 break;287 }288 }289 expect(foundECB).toBeFalsy();290 await ctx.close();291 });292293 test("SECE04 - crypto.getRandomValues used for all random", async () => {294 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));295 const jsFiles = distFiles.filter(f => f.endsWith(".js"));296 let foundMathRandom = false;297 for (const file of jsFiles) {298 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");299 const mathRandMatches = content.match(/Math\.random\(\)/g) || [];300 const cryptoRandMatches = content.match(/crypto\.getRandomValues\(/g) || [];301 if (mathRandMatches.length > cryptoRandMatches.length) {302 foundMathRandom = true;303 break;304 }305 }306 expect(foundMathRandom).toBeFalsy();307 await ctx.close();308 });309310 test("SECE05 - salt generated for each encryption operation", async () => {311 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));312 const jsFiles = distFiles.filter(f => f.endsWith(".js"));313 let hasSaltGen = false;314 for (const file of jsFiles) {315 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");316 if (content.includes("generateSalt") || content.includes("getRandomValues")) {317 hasSaltGen = true;318 break;319 }320 }321 expect(hasSaltGen).toBeTruthy();322 await ctx.close();323 });324325 test("SECE06 - key stored in secure memory not plain", async () => {326 const { popup } = await setupVault(ctx);327 await popup.waitForTimeout(1000);328 const keyInDOM = await popup.evaluate(() => {329 const inputs = document.querySelectorAll("input");330 for (const input of inputs) {331 if (input.value && input.value.length > 20 && !input.type) {332 return true;333 }334 }335 return false;336 });337 expect(keyInDOM).toBeFalsy();338 await ctx.close();339 });340341 test("SECE07 - export uses PBKDF2 with high iterations", async () => {342 const exportFile = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>343 f.find(f => f.includes("export") || f.includes("Export"))344 );345 expect(exportFile).toBeDefined();346 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", exportFile!), "utf-8");347 if (content.includes("PBKDF2")) {348 expect(content).toMatch(/iterations.*[0-9]{4,}|250000/);349 }350 await ctx.close();351 });352});353354test.describe("CSRF Protection", () => {355 let ctx: BrowserContext;356 test.afterEach(async () => { await ctx.close(); });357 test.beforeEach(async () => { ctx = await makeContext(); });358359 test("SECC01 - service worker rejects cross-origin messages", async () => {360 const { popup } = await setupVault(ctx);361 const response = await popup.evaluate(async () => {362 return new Promise(resolve => {363 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));364 });365 });366 expect(response).toBeDefined();367 await ctx.close();368 });369370 test("SECC02 - no token exposure in URL parameters", async () => {371 const { popup } = await setupVault(ctx);372 await createEntryViaForm(popup, { title: "URLToken", url: "https://token.com", username: "[email protected]", password: "pass" });373 const url = popup.url();374 expect(url).not.toMatch(/token|key|secret|session/i);375 await ctx.close();376 });377378 test("SECC03 - clipboard cleared after copy operation", async () => {379 const { popup } = await setupVault(ctx);380 await createEntryViaForm(popup, { title: "ClipboardSec", url: "https://clip.com", username: "[email protected]", password: "clipsecret" });381 await popup.locator(".peach-entry-name-button").first().click();382 await popup.waitForTimeout(500);383 const copyBtn = popup.locator(".peach-copy-btn").first();384 if (await copyBtn.isVisible({ timeout: 2000 }).catch(() => false)) {385 await copyBtn.click();386 await popup.waitForTimeout(35000);387 const clipboard = await popup.evaluate(() => navigator.clipboard.readText());388 expect(clipboard).not.toBe("clipsecret");389 }390 await ctx.close();391 });392});393394test.describe("Breach Detection Security", () => {395 let ctx: BrowserContext;396 test.afterEach(async () => { await ctx.close(); });397 test.beforeEach(async () => { ctx = await makeContext(); });398399 test("SECB01 - HIBP API call uses k-anonymity model", async () => {400 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));401 const jsFiles = distFiles.filter(f => f.endsWith(".js"));402 let foundHIBP = false;403 for (const file of jsFiles) {404 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");405 if (content.includes("hibp") || content.includes("pwned")) {406 foundHIBP = true;407 expect(content).toMatch(/range|hash|k-anon|sha1/i);408 break;409 }410 }411 expect(foundHIBP).toBeTruthy();412 await ctx.close();413 });414415 test("SECB02 - breach check does not expose full password", async () => {416 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));417 const jsFiles = distFiles.filter(f => f.endsWith(".js"));418 let foundHash = false;419 for (const file of jsFiles) {420 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");421 if (content.includes("breach") || content.includes("hibp")) {422 if (content.includes("sha1") || content.includes("SHA-1")) {423 foundHash = true;424 break;425 }426 }427 }428 expect(foundHash).toBeTruthy();429 await ctx.close();430 });431432 test("SECB03 - breach status stored securely", async () => {433 const { popup } = await setupVault(ctx);434 await popup.waitForTimeout(500);435 const storage = await popup.evaluate(() => {436 return new Promise(resolve => {437 chrome.storage.local.get(null, r => resolve(r));438 });439 }) as any;440 if (storage) {441 const hasBreachClearText = JSON.stringify(storage).includes("breached");442 expect(hasBreachClearText).toBeFalsy();443 }444 await ctx.close();445 });446});447448test.describe("Password Generation Security", () => {449 let ctx: BrowserContext;450 test.afterEach(async () => { await ctx.close(); });451 test.beforeEach(async () => { ctx = await makeContext(); });452453 test("SECP01 - generator uses crypto.getRandomValues", async () => {454 const { popup } = await setupVault(ctx);455 await openGenerator(popup);456 const pwd1 = await popup.locator(".peach-pwgen-compact-output").textContent();457 expect(pwd1?.length).toBeGreaterThan(0);458 await ctx.close();459 });460461 test("SECP02 - generated passwords not logged", async () => {462 const { popup } = await setupVault(ctx);463 await openGenerator(popup);464 const pwd = await popup.locator(".peach-pwgen-compact-output").textContent();465 expect(pwd).not.toContain("console.log");466 await ctx.close();467 });468469 test("SECP03 - password entropy meets minimum threshold", async () => {470 const { popup } = await setupVault(ctx);471 await openGenerator(popup);472 const slider = popup.locator(".peach-pwgen-slider");473 await slider.fill("32");474 await popup.waitForTimeout(300);475 const pwd = await popup.locator(".peach-pwgen-compact-output").textContent();476 expect(pwd?.length).toBe(32);477 const uniqueChars = new Set(pwd?.split("")).size;478 expect(uniqueChars).toBeGreaterThan(10);479 await ctx.close();480 });481});