vault-security.spec.ts

19.4 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    // The old assertion demanded the title NOT contain "<script>" in its text —41    // but correct escaping renders it as visible text, so that check failed on42    // correct behaviour. The real invariant is that no script element is43    // created from a title.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    // Same shape as SECX01: the handler text is rendered as text, so assert no63    // element actually carries an onmouseover attribute.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    // Notes render as a text node, so the markup survives as visible text —75    // the invariant is that no <img> is created and no handler is installed.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    // An input legitimately holds whatever was typed; what must not happen is90    // the query becoming markup somewhere in the list.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    // IS_UNLOCKED answers { ok: true, data: boolean } — assert the flag, not106    // the envelope (the envelope is always truthy).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});