vault-security.spec.ts

17.5 KB · raw

1import { chromium, 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 userDataDir = join(os.tmpdir(), `peach-sec-restart-${Date.now()}`);120    await fs.mkdir(userDataDir, { recursive: true });121    const launch = () => chromium.launchPersistentContext(userDataDir, {122      executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,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-chrome", "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-chrome", "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-chrome", "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 - extension page supports AES-GCM WebCrypto", async () => {260    const extId = await waitForSW(ctx);261    const page = await ctx.newPage();262    await page.goto(`chrome-extension://${extId}/src/popup/index.html`);263    const plaintext = "Peach WebCrypto";264    const decrypted = await page.evaluate(async (value) => {265      const key = await crypto.subtle.generateKey(266        { name: "AES-GCM", length: 256 },267        true,268        ["encrypt", "decrypt"],269      );270      const iv = crypto.getRandomValues(new Uint8Array(12));271      const ciphertext = await crypto.subtle.encrypt(272        { name: "AES-GCM", iv },273        key,274        new TextEncoder().encode(value),275      );276      const recovered = await crypto.subtle.decrypt(277        { name: "AES-GCM", iv },278        key,279        ciphertext,280      );281      return new TextDecoder().decode(recovered);282    }, plaintext);283    expect(decrypted).toBe(plaintext);284    await ctx.close();285  });286287288289  test("SECE05 - salt generated for each encryption operation", async () => {290    const distFiles = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets"));291    const jsFiles = distFiles.filter(f => f.endsWith(".js"));292    let hasSaltGen = false;293    for (const file of jsFiles) {294      const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", file), "utf-8");295      if (content.includes("generateSalt") || content.includes("getRandomValues")) {296        hasSaltGen = true;297        break;298      }299    }300    expect(hasSaltGen).toBeTruthy();301    await ctx.close();302  });303304  test("SECE06 - key stored in secure memory not plain", async () => {305    const { popup } = await setupVault(ctx);306    await popup.waitForTimeout(1000);307    const keyInDOM = await popup.evaluate(() => {308      const inputs = document.querySelectorAll("input");309      for (const input of inputs) {310        if (input.value && input.value.length > 20 && !input.type) {311          return true;312        }313      }314      return false;315    });316    expect(keyInDOM).toBeFalsy();317    await ctx.close();318  });319320  test("SECE07 - export uses PBKDF2 with high iterations", async () => {321    const exportFile = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets")).then(f =>322      f.find(f => f.includes("export") || f.includes("Export"))323    );324    expect(exportFile).toBeDefined();325    const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", exportFile!), "utf-8");326    if (content.includes("PBKDF2")) {327      expect(content).toMatch(/iterations.*[0-9]{4,}|250000/);328    }329    await ctx.close();330  });331});332333// SECC03 (clipboard auto-clear) lived here as a 35s wall-clock wait against334// the real OS clipboard. src/background/clipboard.test.ts covers the same335// timer deterministically, so the e2e copy is gone rather than flaky.336test.describe("CSRF Protection", () => {337  let ctx: BrowserContext;338  test.afterEach(async () => { await ctx.close(); });339  test.beforeEach(async () => { ctx = await makeContext(); });340341  test("SECC01 - service worker rejects cross-origin messages", async () => {342    const { popup } = await setupVault(ctx);343    const response = await popup.evaluate(async () => {344      return new Promise(resolve => {345        chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));346      });347    });348    expect(response).toBeDefined();349    await ctx.close();350  });351352  test("SECC02 - no token exposure in URL parameters", async () => {353    const { popup } = await setupVault(ctx);354    await createEntryViaForm(popup, { title: "URLToken", url: "https://token.com", username: "[email protected]", password: "pass" });355    const url = popup.url();356    expect(url).not.toMatch(/token|key|secret|session/i);357    await ctx.close();358  });359360});361362test.describe("Breach Detection Security", () => {363  let ctx: BrowserContext;364  test.afterEach(async () => { await ctx.close(); });365  test.beforeEach(async () => { ctx = await makeContext(); });366367  test("SECB01 - HIBP API call uses k-anonymity model", async () => {368    const distFiles = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets"));369    const jsFiles = distFiles.filter(f => f.endsWith(".js"));370    let foundHIBP = false;371    for (const file of jsFiles) {372      const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", file), "utf-8");373      if (content.includes("hibp") || content.includes("pwned")) {374        foundHIBP = true;375        expect(content).toMatch(/range|hash|k-anon|sha1/i);376        break;377      }378    }379    expect(foundHIBP).toBeTruthy();380    await ctx.close();381  });382383  test("SECB02 - breach check does not expose full password", async () => {384    const distFiles = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets"));385    const jsFiles = distFiles.filter(f => f.endsWith(".js"));386    let foundHash = false;387    for (const file of jsFiles) {388      const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", file), "utf-8");389      if (content.includes("breach") || content.includes("hibp")) {390        if (content.includes("sha1") || content.includes("SHA-1")) {391          foundHash = true;392          break;393        }394      }395    }396    expect(foundHash).toBeTruthy();397    await ctx.close();398  });399400  test("SECB03 - breach status stored securely", async () => {401    const { popup } = await setupVault(ctx);402    await popup.waitForTimeout(500);403    const storage = await popup.evaluate(() => {404      return new Promise(resolve => {405        chrome.storage.local.get(null, r => resolve(r));406      });407    }) as any;408    if (storage) {409      const hasBreachClearText = JSON.stringify(storage).includes("breached");410      expect(hasBreachClearText).toBeFalsy();411    }412    await ctx.close();413  });414});415416test.describe("Password Generation Security", () => {417  let ctx: BrowserContext;418  test.afterEach(async () => { await ctx.close(); });419  test.beforeEach(async () => { ctx = await makeContext(); });420421  test("SECP01 - generator uses crypto.getRandomValues", async () => {422    const { popup } = await setupVault(ctx);423    await openGenerator(popup);424    const pwd1 = await popup.locator(".peach-pwgen-compact-output").inputValue();425    expect(pwd1.length).toBeGreaterThan(0);426    await ctx.close();427  });428429  test("SECP02 - generated passwords not logged", async () => {430    const { popup } = await setupVault(ctx);431    await openGenerator(popup);432    const pwd = await popup.locator(".peach-pwgen-compact-output").inputValue();433    expect(pwd).not.toContain("console.log");434    await ctx.close();435  });436437  test("SECP03 - password entropy meets minimum threshold", async () => {438    const { popup } = await setupVault(ctx);439    await openGenerator(popup);440    const slider = popup.locator(".peach-pwgen-slider");441    await slider.fill("32");442    await expect(popup.locator(".peach-pwgen-len-num")).toHaveText("32");443    const pwd = await popup.locator(".peach-pwgen-compact-output").inputValue();444    expect(pwd.length).toBe(32);445    const uniqueChars = new Set(pwd.split("")).size;446    expect(uniqueChars).toBeGreaterThan(10);447    await ctx.close();448  });449});