vault-security.spec.ts

18.7 KB · raw

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);2122// Real vault creation runs Argon2id (≈12s with the recovery confirmation).23test.describe.configure({ timeout: 90_000 });2425/** The generator drawer lives inside the new-entry form. */26async 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    // IS_UNLOCKED answers { ok: true, data: boolean } — assert the flag, not97    // the envelope (the envelope is always truthy).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});