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  openSettings,13  setupVault,14  waitForSW,15} from "./helpers/vault";1617const __filename = fileURLToPath(import.meta.url);18const __dirname = dirname(__filename);1920// Real vault creation runs Argon2id (≈12s with the recovery confirmation).21test.describe.configure({ timeout: 90_000 });2223/** The generator drawer lives inside the new-entry form. */24async 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    // IS_UNLOCKED answers { ok: true, data: boolean } — assert the flag, not95    // the envelope (the envelope is always truthy).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});