vault-security.spec.ts

16.9 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 openList(popup);28  await popup.locator(".peach-new-entry-row").click();29  await popup.locator(".peach-generate-toggle-link").click();30  await expect(popup.locator(".peach-pwgen-compact-output")).toBeVisible({ timeout: 5000 });31}3233test.describe("XSS Prevention", () => {34  let ctx: BrowserContext;35  test.afterEach(async () => { await ctx.close(); });36  test.beforeEach(async () => { ctx = await makeContext(); });3738  test("SECX01 - entry titles are escaped in DOM", async () => {39    const { popup } = await setupVault(ctx);40    await createEntryViaForm(popup, { title: '<script>alert("xss")</script>', url: "https://xss.com", username: "[email protected]", password: "xss" });41    // The old assertion demanded the title NOT contain "<script>" in its text —42    // but correct escaping renders it as visible text, so that check failed on43    // correct behaviour. The real invariant is that no script element is44    // created from a title.45    const entryName = popup.locator(".peach-entry-name").first();46    expect(await entryName.textContent()).toContain("alert");47    expect(await popup.locator("script:not([src])").count()).toBe(0);48    await ctx.close();49  });5051  test("SECX02 - entry URLs are sanitized before display", async () => {52    const { popup } = await setupVault(ctx);53    await createEntryViaForm(popup, { title: "URLTest", url: 'javascript:alert("xss")', username: "[email protected]", password: "pass" });54    // The URL is displayed as text and nothing in the popup navigates to it.55    // What must never appear is an anchor carrying the javascript: scheme.56    await expect(popup.locator(".peach-domain-value").first()).toBeVisible();57    expect(await popup.locator('a[href^="javascript:"]').count()).toBe(0);58    await ctx.close();59  });6061  test("SECX03 - no inline event handlers in entry rendering", async () => {62    const { popup } = await setupVault(ctx);63    await createEntryViaForm(popup, { title: '" onmouseover="alert(1)" "', url: "https://test.com", username: "[email protected]", password: "pass" });64    // Same shape as SECX01: the handler text is rendered as text, so assert no65    // element actually carries an onmouseover attribute.66    const injected = await popup.evaluate(() =>67      document.querySelectorAll("[onmouseover]").length,68    );69    expect(injected).toBe(0);70    await ctx.close();71  });7273  test("SECX04 - notes field renders without HTML execution", async () => {74    const { popup } = await setupVault(ctx);75    await createEntryViaForm(popup, { title: "NotesXSS", url: "https://notes.com", username: "[email protected]", password: "pass", notes: '<img src=x onerror="alert(1)">' });76    // Notes render as a text node, so the markup survives as visible text —77    // the invariant is that no <img> is created and no handler is installed.78    await expect(popup.locator(".peach-note-content")).toContainText("onerror");79    expect(await popup.locator(".peach-note-content img").count()).toBe(0);80    expect(await popup.evaluate(() => document.querySelectorAll("[onerror]").length)).toBe(0);81    await ctx.close();82  });8384  test("SECX05 - search input sanitizes query before display", async () => {85    const { popup } = await setupVault(ctx);86    await createEntryViaForm(popup, { title: "SearchTest", url: "https://search.com", username: "[email protected]", password: "pass" });87    await openList(popup);88    const searchInput = listSearch(popup);89    await searchInput.fill('<script>alert("xss")</script>');90    await popup.waitForTimeout(300);91    // An input legitimately holds whatever was typed; what must not happen is92    // the query becoming markup somewhere in the list.93    expect(await searchInput.inputValue()).toContain("<script>");94    expect(await popup.locator("script:not([src])").count()).toBe(0);95    await ctx.close();96  });97});9899test.describe("Session Security", () => {100  let ctx: BrowserContext;101  test.afterEach(async () => { await ctx.close(); });102  test.beforeEach(async () => { ctx = await makeContext(); });103104  test("SECS01 - session key cleared on lock", async () => {105    const { popup } = await setupVault(ctx);106    await lockVault(popup);107    // IS_UNLOCKED answers { ok: true, data: boolean } — assert the flag, not108    // the envelope (the envelope is always truthy).109    const response = await popup.evaluate(async () => {110      return new Promise<{ ok: boolean; data: boolean }>(resolve => {111        chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));112      });113    });114    expect(response.data).toBe(false);115    await ctx.close();116  });117118  test("SECS02 - session does not persist across browser restarts", async () => {119    const { chromium } = await import("playwright");120    const userDataDir = join(os.tmpdir(), `peach-sec-restart-${Date.now()}`);121    await fs.mkdir(userDataDir, { recursive: true });122    const launch = () => chromium.launchPersistentContext(userDataDir, {123      headless: false,124      args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],125    });126127    const first = await launch();128    const { popup } = await setupVault(first);129    await createEntryViaForm(popup, { title: "PersistSession", url: "https://persist.com", username: "[email protected]", password: "pass" });130    await first.close();131132    const second = await launch();133    const extId = await waitForSW(second);134    const reopened = await second.newPage();135    await reopened.goto(`chrome-extension://${extId}/src/popup/index.html`);136    await expect(reopened.locator("#unlock-password")).toBeVisible({ timeout: 20000 });137    await expect(reopened.locator(UNLOCKED)).toHaveCount(0);138    await second.close();139  });140141  test("SECS03 - idle timeout clears session", async () => {142    const { popup } = await setupVault(ctx);143    await openSettings(popup);144    await popup.locator('[data-settings-card-id="protection"]').click();145    await expect(146      popup.locator(".peach-settings-row", { hasText: "Auto-lock after" }),147    ).toHaveCount(1);148    await ctx.close();149  });150151  test("SECS04 - master password not stored in memory after unlock", async () => {152    const { popup } = await setupVault(ctx);153    const hasStoredPW = await popup.evaluate(() => {154      return (window as any).__PEACH_MASTER_PASSWORD !== undefined ||155             (window as any).masterPassword !== undefined;156    });157    expect(hasStoredPW).toBeFalsy();158    await ctx.close();159  });160161  test("SECS05 - vault data not accessible without unlock", async () => {162    const { popup } = await setupVault(ctx);163    await createEntryViaForm(popup, { title: "LockedRead", url: "https://locked.test", username: "u", password: "p" });164    await lockVault(popup);165    const response = await popup.evaluate(async () => {166      return new Promise<{ ok: boolean; error?: string; data?: unknown }>(resolve => {167        chrome.runtime.sendMessage({ type: "GET_ENTRIES" }, r => resolve(r));168      });169    });170    expect(response.ok).toBe(false);171    expect(response.error).toBe("vault_locked");172    expect(response.data).toBeUndefined();173    await ctx.close();174  });175176  test("SECS06 - service worker message validates sender origin", async () => {177    const ctx2 = await makeContext();178    const { popup } = await setupVault(ctx);179    const response = await popup.evaluate(async () => {180      return new Promise(resolve => {181        chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));182      });183    });184    expect(response).toBeDefined();185    await ctx.close();186    await ctx2.close();187  });188189  test("SECS07 - no sensitive data in extension localStorage", async () => {190    const { popup } = await setupVault(ctx);191    await popup.waitForTimeout(1000);192    const storage = await popup.evaluate(() => {193      return new Promise(resolve => {194        chrome.storage.local.get(null, r => resolve(r));195      });196    }) as any;197    if (storage && typeof storage === 'object') {198      const hasSensitive = Object.keys(storage).some(k =>199        k.toLowerCase().includes('password') ||200        k.toLowerCase().includes('secret') ||201        k.toLowerCase().includes('key')202      );203      expect(hasSensitive).toBeFalsy();204    }205    await ctx.close();206  });207});208209test.describe("CSP Compliance", () => {210  let ctx: BrowserContext;211  test.afterEach(async () => { await ctx.close(); });212  test.beforeEach(async () => { ctx = await makeContext(); });213214  test("SEQC01 - no inline scripts in popup HTML", async () => {215    const extId = await waitForSW(ctx);216    const page = await ctx.newPage();217    await page.goto(`chrome-extension://${extId}/src/popup/index.html`);218    await page.waitForTimeout(2000);219    const scripts = await page.evaluate(() => {220      const html = document.documentElement.innerHTML;221      const scriptMatches = html.match(/<script[^>]*>/gi) || [];222      return scriptMatches.filter(s => !s.includes('src='));223    });224    expect(scripts.length).toBe(0);225    await ctx.close();226  });227228  test("SEQC02 - no eval() in content script", async () => {229    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));230    const jsFiles = distFiles.filter(f => f.endsWith(".js"));231    let foundEval = false;232    for (const file of jsFiles) {233      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");234      if (content.includes("eval(") && !content.includes("new Function")) {235        foundEval = true;236        break;237      }238    }239    expect(foundEval).toBeFalsy();240    await ctx.close();241  });242243  test("SEQC03 - CSP meta tag present in HTML", async () => {244    const manifest = JSON.parse(245      await fs.readFile(join(__dirname, "..", "dist", "manifest.json"), "utf-8"),246    ) as { content_security_policy?: { extension_pages?: string } };247    expect(manifest.content_security_policy?.extension_pages).toContain("script-src 'self'");248    await ctx.close();249  });250251});252253test.describe("Encryption Verification", () => {254  let ctx: BrowserContext;255  test.afterEach(async () => { await ctx.close(); });256  test.beforeEach(async () => { ctx = await makeContext(); });257258259  test("SECE02 - AES-GCM used for vault encryption", async () => {260    const aesFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>261      f.filter(f => f.includes("aes"))262    );263    expect(aesFiles.length).toBeGreaterThan(0);264    const content = await fs.readFile(join(__dirname, "..", "dist", "assets", aesFiles[0]), "utf-8");265    expect(content).toMatch(/AES-GCM|aes-gcm|AESGCM|AES.*GCM/);266    await ctx.close();267  });268269270271  test("SECE05 - salt generated for each encryption operation", async () => {272    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));273    const jsFiles = distFiles.filter(f => f.endsWith(".js"));274    let hasSaltGen = false;275    for (const file of jsFiles) {276      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");277      if (content.includes("generateSalt") || content.includes("getRandomValues")) {278        hasSaltGen = true;279        break;280      }281    }282    expect(hasSaltGen).toBeTruthy();283    await ctx.close();284  });285286  test("SECE06 - key stored in secure memory not plain", async () => {287    const { popup } = await setupVault(ctx);288    await popup.waitForTimeout(1000);289    const keyInDOM = await popup.evaluate(() => {290      const inputs = document.querySelectorAll("input");291      for (const input of inputs) {292        if (input.value && input.value.length > 20 && !input.type) {293          return true;294        }295      }296      return false;297    });298    expect(keyInDOM).toBeFalsy();299    await ctx.close();300  });301302  test("SECE07 - export uses PBKDF2 with high iterations", async () => {303    const exportFile = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>304      f.find(f => f.includes("export") || f.includes("Export"))305    );306    expect(exportFile).toBeDefined();307    const content = await fs.readFile(join(__dirname, "..", "dist", "assets", exportFile!), "utf-8");308    if (content.includes("PBKDF2")) {309      expect(content).toMatch(/iterations.*[0-9]{4,}|250000/);310    }311    await ctx.close();312  });313});314315// SECC03 (clipboard auto-clear) lived here as a 35s wall-clock wait against316// the real OS clipboard. src/background/clipboard.test.ts covers the same317// timer deterministically, so the e2e copy is gone rather than flaky.318test.describe("CSRF Protection", () => {319  let ctx: BrowserContext;320  test.afterEach(async () => { await ctx.close(); });321  test.beforeEach(async () => { ctx = await makeContext(); });322323  test("SECC01 - service worker rejects cross-origin messages", async () => {324    const { popup } = await setupVault(ctx);325    const response = await popup.evaluate(async () => {326      return new Promise(resolve => {327        chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));328      });329    });330    expect(response).toBeDefined();331    await ctx.close();332  });333334  test("SECC02 - no token exposure in URL parameters", async () => {335    const { popup } = await setupVault(ctx);336    await createEntryViaForm(popup, { title: "URLToken", url: "https://token.com", username: "[email protected]", password: "pass" });337    const url = popup.url();338    expect(url).not.toMatch(/token|key|secret|session/i);339    await ctx.close();340  });341342});343344test.describe("Breach Detection Security", () => {345  let ctx: BrowserContext;346  test.afterEach(async () => { await ctx.close(); });347  test.beforeEach(async () => { ctx = await makeContext(); });348349  test("SECB01 - HIBP API call uses k-anonymity model", async () => {350    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));351    const jsFiles = distFiles.filter(f => f.endsWith(".js"));352    let foundHIBP = false;353    for (const file of jsFiles) {354      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");355      if (content.includes("hibp") || content.includes("pwned")) {356        foundHIBP = true;357        expect(content).toMatch(/range|hash|k-anon|sha1/i);358        break;359      }360    }361    expect(foundHIBP).toBeTruthy();362    await ctx.close();363  });364365  test("SECB02 - breach check does not expose full password", async () => {366    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));367    const jsFiles = distFiles.filter(f => f.endsWith(".js"));368    let foundHash = false;369    for (const file of jsFiles) {370      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");371      if (content.includes("breach") || content.includes("hibp")) {372        if (content.includes("sha1") || content.includes("SHA-1")) {373          foundHash = true;374          break;375        }376      }377    }378    expect(foundHash).toBeTruthy();379    await ctx.close();380  });381382  test("SECB03 - breach status stored securely", async () => {383    const { popup } = await setupVault(ctx);384    await popup.waitForTimeout(500);385    const storage = await popup.evaluate(() => {386      return new Promise(resolve => {387        chrome.storage.local.get(null, r => resolve(r));388      });389    }) as any;390    if (storage) {391      const hasBreachClearText = JSON.stringify(storage).includes("breached");392      expect(hasBreachClearText).toBeFalsy();393    }394    await ctx.close();395  });396});397398test.describe("Password Generation Security", () => {399  let ctx: BrowserContext;400  test.afterEach(async () => { await ctx.close(); });401  test.beforeEach(async () => { ctx = await makeContext(); });402403  test("SECP01 - generator uses crypto.getRandomValues", async () => {404    const { popup } = await setupVault(ctx);405    await openGenerator(popup);406    const pwd1 = await popup.locator(".peach-pwgen-compact-output").inputValue();407    expect(pwd1.length).toBeGreaterThan(0);408    await ctx.close();409  });410411  test("SECP02 - generated passwords not logged", async () => {412    const { popup } = await setupVault(ctx);413    await openGenerator(popup);414    const pwd = await popup.locator(".peach-pwgen-compact-output").inputValue();415    expect(pwd).not.toContain("console.log");416    await ctx.close();417  });418419  test("SECP03 - password entropy meets minimum threshold", async () => {420    const { popup } = await setupVault(ctx);421    await openGenerator(popup);422    const slider = popup.locator(".peach-pwgen-slider");423    await slider.fill("32");424    await expect(popup.locator(".peach-pwgen-len-num")).toHaveText("32");425    const pwd = await popup.locator(".peach-pwgen-compact-output").inputValue();426    expect(pwd.length).toBe(32);427    const uniqueChars = new Set(pwd.split("")).size;428    expect(uniqueChars).toBeGreaterThan(10);429    await ctx.close();430  });431});