vault-security.spec.ts

23.0 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";67const __filename = fileURLToPath(import.meta.url);8const __dirname = dirname(__filename);9const EXTENSION_PATH = join(__dirname, "..", "dist");1011async function makeContext() {12  const { chromium } = await import("playwright");13  const userDataDir = join(os.tmpdir(), `peach-sec-${Date.now()}`);14  await fs.mkdir(userDataDir, { recursive: true });15  return chromium.launchPersistentContext(userDataDir, {16    headless: false,17    args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],18  });19}2021async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {22  const start = Date.now();23  let sw = ctx.serviceWorkers()[0];24  while (!sw && Date.now() - start < maxWait) {25    await new Promise(r => setTimeout(r, 500));26    sw = ctx.serviceWorkers()[0];27  }28  if (!sw) throw new Error(`No SW after ${maxWait}ms`);29  return new URL(sw.url()).host;30}3132async function setupVault(ctx: BrowserContext): Promise<{ popup: any, extId: string }> {33  const extId = await waitForSW(ctx);34  const popup = await ctx.newPage();35  await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);36  await popup.waitForTimeout(3000);37  const pw = popup.locator("#master-password");38  if (await pw.isVisible()) {39    await pw.fill("password123");40    await popup.locator("#confirm-password").fill("password123");41    await popup.getByRole("button", { name: /create vault/i }).click();42    await popup.waitForTimeout(2000);43  }44  return { popup, extId };45}4647test.describe("XSS Prevention", () => {48  let ctx: BrowserContext;49  test.afterEach(async () => { await ctx.close(); });50  test.beforeEach(async () => { ctx = await makeContext(); });5152  test("SECX01 - entry titles are escaped in DOM", async () => {53    const { popup } = await setupVault(ctx);54    await popup.locator('input[placeholder="search vault..."]').click();55    await popup.getByRole("button", { name: /new entry/i }).click();56    await popup.locator(".peach-new-name-input").fill('<script>alert("xss")</script>');57    await popup.locator('input[placeholder="domain.com"]').fill("https://xss.com");58    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");59    await popup.locator(".peach-new-field-input[placeholder='password']").fill("xss");60    await popup.getByRole("button", { name: /create/i }).click();61    await popup.waitForTimeout(1500);62    const entryName = popup.locator(".peach-entry-name").first();63    const text = await entryName.textContent();64    expect(text).not.toContain("<script>");65    await ctx.close();66  });6768  test("SECX02 - entry URLs are sanitized before display", async () => {69    const { popup } = await setupVault(ctx);70    await popup.locator('input[placeholder="search vault..."]').click();71    await popup.getByRole("button", { name: /new entry/i }).click();72    await popup.locator(".peach-new-name-input").fill("URLTest");73    await popup.locator('input[placeholder="domain.com"]').fill('javascript:alert("xss")');74    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");75    await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");76    await popup.getByRole("button", { name: /create/i }).click();77    await popup.waitForTimeout(1500);78    const domain = popup.locator(".peach-domain-value").first();79    const text = await domain.textContent().catch(() => "");80    expect(text).not.toContain("javascript:");81    await ctx.close();82  });8384  test("SECX03 - no inline event handlers in entry rendering", async () => {85    const { popup } = await setupVault(ctx);86    await popup.locator('input[placeholder="search vault..."]').click();87    await popup.getByRole("button", { name: /new entry/i }).click();88    await popup.locator(".peach-new-name-input").fill('" onmouseover="alert(1)" "');89    await popup.locator('input[placeholder="domain.com"]').fill("https://test.com");90    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");91    await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");92    await popup.getByRole("button", { name: /create/i }).click();93    await popup.waitForTimeout(1500);94    const entry = popup.locator(".peach-entry-name").first();95    const html = await entry.innerHTML();96    expect(html).not.toContain("onmouseover");97    await ctx.close();98  });99100  test("SECX04 - notes field renders without HTML execution", async () => {101    const { popup } = await setupVault(ctx);102    await popup.locator('input[placeholder="search vault..."]').click();103    await popup.getByRole("button", { name: /new entry/i }).click();104    await popup.locator(".peach-new-name-input").fill("NotesXSS");105    await popup.locator('input[placeholder="domain.com"]').fill("https://notes.com");106    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");107    await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");108    const notes = popup.locator("textarea");109    await notes.fill('<img src=x onerror="alert(1)">');110    await popup.getByRole("button", { name: /create/i }).click();111    await popup.waitForTimeout(1500);112    await popup.locator(".peach-entry-name-button").first().click();113    await popup.waitForTimeout(500);114    const noteContent = popup.locator(".peach-note-content").first();115    const html = await noteContent.innerHTML().catch(() => "");116    expect(html).not.toContain("onerror");117    await ctx.close();118  });119120  test("SECX05 - search input sanitizes query before display", async () => {121    const { popup } = await setupVault(ctx);122    await popup.locator('input[placeholder="search vault..."]').click();123    await popup.getByRole("button", { name: /new entry/i }).click();124    await popup.locator(".peach-new-name-input").fill("SearchTest");125    await popup.locator('input[placeholder="domain.com"]').fill("https://search.com");126    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");127    await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");128    await popup.getByRole("button", { name: /create/i }).click();129    await popup.waitForTimeout(1500);130    const searchInput = popup.locator(".peach-search-input, .peach-search-input-list").first();131    await searchInput.click();132    await searchInput.fill('<script>alert("xss")</script>');133    await popup.waitForTimeout(300);134    const value = await searchInput.inputValue();135    expect(value).not.toContain("<script>");136    await ctx.close();137  });138});139140test.describe("Session Security", () => {141  let ctx: BrowserContext;142  test.afterEach(async () => { await ctx.close(); });143  test.beforeEach(async () => { ctx = await makeContext(); });144145  test("SECS01 - session key cleared on lock", async () => {146    const { popup } = await setupVault(ctx);147    await popup.waitForTimeout(500);148    const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");149    await lockBtn.click();150    await popup.waitForTimeout(1500);151    const response = await popup.evaluate(async () => {152      return new Promise(resolve => {153        chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));154      });155    });156    expect(response).toBeFalsy();157    await ctx.close();158  });159160  test("SECS02 - session does not persist across browser restarts", async () => {161    const { popup } = await setupVault(ctx);162    await popup.locator('input[placeholder="search vault..."]').click();163    await popup.getByRole("button", { name: /new entry/i }).click();164    await popup.locator(".peach-new-name-input").fill("PersistSession");165    await popup.locator('input[placeholder="domain.com"]').fill("https://persist.com");166    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");167    await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");168    await popup.getByRole("button", { name: /create/i }).click();169    await popup.waitForTimeout(1500);170    const newPage = await ctx.newPage();171    const extId = await waitForSW(ctx);172    await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`);173    await newPage.waitForTimeout(2000);174    const pwField = newPage.locator("#master-password");175    const isVisible = await pwField.isVisible({ timeout: 3000 }).catch(() => false);176    expect(isVisible).toBe(true);177    await ctx.close();178  });179180  test("SECS03 - idle timeout clears session", async () => {181    const { popup } = await setupVault(ctx);182    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();183    await popup.waitForTimeout(300);184    const autoLock = popup.locator("text=auto-lock").first();185    await expect(autoLock).toBeVisible();186    await ctx.close();187  });188189  test("SECS04 - master password not stored in memory after unlock", async () => {190    const { popup } = await setupVault(ctx);191    const hasStoredPW = await popup.evaluate(() => {192      return (window as any).__PEACH_MASTER_PASSWORD !== undefined ||193             (window as any).masterPassword !== undefined;194    });195    expect(hasStoredPW).toBeFalsy();196    await ctx.close();197  });198199  test("SECS05 - vault data not accessible without unlock", async () => {200    const { popup: _popup } = await setupVault(ctx);201    const newPage = await ctx.newPage();202    const extId = await waitForSW(ctx);203    await newPage.goto(`chrome-extension://${extId}/src/popup/index.html`);204    await newPage.waitForTimeout(2000);205    const response = await newPage.evaluate(async () => {206      return new Promise(resolve => {207        chrome.runtime.sendMessage({ type: "GET_ENTRIES" }, r => resolve(r));208      });209    });210    expect(response).toBeFalsy();211    await ctx.close();212  });213214  test("SECS06 - service worker message validates sender origin", async () => {215    const ctx2 = await makeContext();216    const { popup } = await setupVault(ctx);217    const response = await popup.evaluate(async () => {218      return new Promise(resolve => {219        chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));220      });221    });222    expect(response).toBeDefined();223    await ctx.close();224    await ctx2.close();225  });226227  test("SECS07 - no sensitive data in extension localStorage", async () => {228    const { popup } = await setupVault(ctx);229    await popup.waitForTimeout(1000);230    const storage = await popup.evaluate(() => {231      return new Promise(resolve => {232        chrome.storage.local.get(null, r => resolve(r));233      });234    }) as any;235    if (storage && typeof storage === 'object') {236      const hasSensitive = Object.keys(storage).some(k =>237        k.toLowerCase().includes('password') ||238        k.toLowerCase().includes('secret') ||239        k.toLowerCase().includes('key')240      );241      expect(hasSensitive).toBeFalsy();242    }243    await ctx.close();244  });245});246247test.describe("CSP Compliance", () => {248  let ctx: BrowserContext;249  test.afterEach(async () => { await ctx.close(); });250  test.beforeEach(async () => { ctx = await makeContext(); });251252  test("SEQC01 - no inline scripts in popup HTML", async () => {253    const extId = await waitForSW(ctx);254    const page = await ctx.newPage();255    await page.goto(`chrome-extension://${extId}/src/popup/index.html`);256    await page.waitForTimeout(2000);257    const scripts = await page.evaluate(() => {258      const html = document.documentElement.innerHTML;259      const scriptMatches = html.match(/<script[^>]*>/gi) || [];260      return scriptMatches.filter(s => !s.includes('src='));261    });262    expect(scripts.length).toBe(0);263    await ctx.close();264  });265266  test("SEQC02 - no eval() in content script", async () => {267    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));268    const jsFiles = distFiles.filter(f => f.endsWith(".js"));269    let foundEval = false;270    for (const file of jsFiles) {271      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");272      if (content.includes("eval(") && !content.includes("new Function")) {273        foundEval = true;274        break;275      }276    }277    expect(foundEval).toBeFalsy();278    await ctx.close();279  });280281  test("SEQC03 - CSP meta tag present in HTML", async () => {282    const html = await fs.readFile(join(__dirname, "..", "dist", "src", "popup", "index.html"), "utf-8");283    const hasCSP = html.includes("Content-Security-Policy") || html.includes("csp");284    expect(hasCSP).toBeTruthy();285    await ctx.close();286  });287288  test("SEQC04 - no remote script loading in content script", async () => {289    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));290    const jsFiles = distFiles.filter(f => f.endsWith(".js"));291    let foundRemote = false;292    for (const file of jsFiles) {293      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");294      if (content.match(/https?:\/\/[^"']+\.js/)) {295        foundRemote = true;296        break;297      }298    }299    expect(foundRemote).toBeFalsy();300    await ctx.close();301  });302});303304test.describe("Encryption Verification", () => {305  let ctx: BrowserContext;306  test.afterEach(async () => { await ctx.close(); });307  test.beforeEach(async () => { ctx = await makeContext(); });308309  test("SECE01 - Argon2id parameters documented in code", async () => {310    const argon2File = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>311      f.find(f => f.includes("argon") || f.includes("crypto"))312    );313    expect(argon2File).toBeDefined();314    const content = await fs.readFile(join(__dirname, "..", "dist", "assets", argon2File!), "utf-8");315    expect(content).toMatch(/65536|3|4|argon/);316    await ctx.close();317  });318319  test("SECE02 - AES-GCM used for vault encryption", async () => {320    const aesFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>321      f.filter(f => f.includes("aes"))322    );323    expect(aesFiles.length).toBeGreaterThan(0);324    const content = await fs.readFile(join(__dirname, "..", "dist", "assets", aesFiles[0]), "utf-8");325    expect(content).toMatch(/AES-GCM|aes-gcm|AESGCM|AES.*GCM/);326    await ctx.close();327  });328329  test("SECE03 - no ECB mode in encryption", async () => {330    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));331    const jsFiles = distFiles.filter(f => f.endsWith(".js"));332    let foundECB = false;333    for (const file of jsFiles) {334      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");335      if (content.includes("ECB") && !content.includes("// ECB")) {336        foundECB = true;337        break;338      }339    }340    expect(foundECB).toBeFalsy();341    await ctx.close();342  });343344  test("SECE04 - crypto.getRandomValues used for all random", async () => {345    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));346    const jsFiles = distFiles.filter(f => f.endsWith(".js"));347    let foundMathRandom = false;348    for (const file of jsFiles) {349      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");350      const mathRandMatches = content.match(/Math\.random\(\)/g) || [];351      const cryptoRandMatches = content.match(/crypto\.getRandomValues\(/g) || [];352      if (mathRandMatches.length > cryptoRandMatches.length) {353        foundMathRandom = true;354        break;355      }356    }357    expect(foundMathRandom).toBeFalsy();358    await ctx.close();359  });360361  test("SECE05 - salt generated for each encryption operation", async () => {362    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));363    const jsFiles = distFiles.filter(f => f.endsWith(".js"));364    let hasSaltGen = false;365    for (const file of jsFiles) {366      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");367      if (content.includes("generateSalt") || content.includes("getRandomValues")) {368        hasSaltGen = true;369        break;370      }371    }372    expect(hasSaltGen).toBeTruthy();373    await ctx.close();374  });375376  test("SECE06 - key stored in secure memory not plain", async () => {377    const { popup } = await setupVault(ctx);378    await popup.waitForTimeout(1000);379    const keyInDOM = await popup.evaluate(() => {380      const inputs = document.querySelectorAll("input");381      for (const input of inputs) {382        if (input.value && input.value.length > 20 && !input.type) {383          return true;384        }385      }386      return false;387    });388    expect(keyInDOM).toBeFalsy();389    await ctx.close();390  });391392  test("SECE07 - export uses PBKDF2 with high iterations", async () => {393    const exportFile = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>394      f.find(f => f.includes("export") || f.includes("Export"))395    );396    expect(exportFile).toBeDefined();397    const content = await fs.readFile(join(__dirname, "..", "dist", "assets", exportFile!), "utf-8");398    if (content.includes("PBKDF2")) {399      expect(content).toMatch(/iterations.*[0-9]{4,}|250000/);400    }401    await ctx.close();402  });403});404405test.describe("CSRF Protection", () => {406  let ctx: BrowserContext;407  test.afterEach(async () => { await ctx.close(); });408  test.beforeEach(async () => { ctx = await makeContext(); });409410  test("SECC01 - service worker rejects cross-origin messages", async () => {411    const { popup } = await setupVault(ctx);412    const response = await popup.evaluate(async () => {413      return new Promise(resolve => {414        chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));415      });416    });417    expect(response).toBeDefined();418    await ctx.close();419  });420421  test("SECC02 - no token exposure in URL parameters", async () => {422    const { popup } = await setupVault(ctx);423    await popup.locator('input[placeholder="search vault..."]').click();424    await popup.getByRole("button", { name: /new entry/i }).click();425    await popup.locator(".peach-new-name-input").fill("URLToken");426    await popup.locator('input[placeholder="domain.com"]').fill("https://token.com");427    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");428    await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass");429    await popup.getByRole("button", { name: /create/i }).click();430    await popup.waitForTimeout(1500);431    const url = popup.url();432    expect(url).not.toMatch(/token|key|secret|session/i);433    await ctx.close();434  });435436  test("SECC03 - clipboard cleared after copy operation", async () => {437    const { popup } = await setupVault(ctx);438    await popup.locator('input[placeholder="search vault..."]').click();439    await popup.getByRole("button", { name: /new entry/i }).click();440    await popup.locator(".peach-new-name-input").fill("ClipboardSec");441    await popup.locator('input[placeholder="domain.com"]').fill("https://clip.com");442    await popup.locator('input[placeholder="username or email"]').fill("[email protected]");443    await popup.locator(".peach-new-field-input[placeholder='password']").fill("clipsecret");444    await popup.getByRole("button", { name: /create/i }).click();445    await popup.waitForTimeout(1500);446    await popup.locator(".peach-entry-name-button").first().click();447    await popup.waitForTimeout(500);448    const copyBtn = popup.locator(".peach-copy-btn").first();449    if (await copyBtn.isVisible({ timeout: 2000 }).catch(() => false)) {450      await copyBtn.click();451      await popup.waitForTimeout(35000);452      const clipboard = await popup.evaluate(() => navigator.clipboard.readText());453      expect(clipboard).not.toBe("clipsecret");454    }455    await ctx.close();456  });457});458459test.describe("Breach Detection Security", () => {460  let ctx: BrowserContext;461  test.afterEach(async () => { await ctx.close(); });462  test.beforeEach(async () => { ctx = await makeContext(); });463464  test("SECB01 - HIBP API call uses k-anonymity model", async () => {465    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));466    const jsFiles = distFiles.filter(f => f.endsWith(".js"));467    let foundHIBP = false;468    for (const file of jsFiles) {469      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");470      if (content.includes("hibp") || content.includes("pwned")) {471        foundHIBP = true;472        expect(content).toMatch(/range|hash|k-anon|sha1/i);473        break;474      }475    }476    expect(foundHIBP).toBeTruthy();477    await ctx.close();478  });479480  test("SECB02 - breach check does not expose full password", async () => {481    const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));482    const jsFiles = distFiles.filter(f => f.endsWith(".js"));483    let foundHash = false;484    for (const file of jsFiles) {485      const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");486      if (content.includes("breach") || content.includes("hibp")) {487        if (content.includes("sha1") || content.includes("SHA-1")) {488          foundHash = true;489          break;490        }491      }492    }493    expect(foundHash).toBeTruthy();494    await ctx.close();495  });496497  test("SECB03 - breach status stored securely", async () => {498    const { popup } = await setupVault(ctx);499    await popup.waitForTimeout(500);500    const storage = await popup.evaluate(() => {501      return new Promise(resolve => {502        chrome.storage.local.get(null, r => resolve(r));503      });504    }) as any;505    if (storage) {506      const hasBreachClearText = JSON.stringify(storage).includes("breached");507      expect(hasBreachClearText).toBeFalsy();508    }509    await ctx.close();510  });511});512513test.describe("Password Generation Security", () => {514  let ctx: BrowserContext;515  test.afterEach(async () => { await ctx.close(); });516  test.beforeEach(async () => { ctx = await makeContext(); });517518  test("SECP01 - generator uses crypto.getRandomValues", async () => {519    const { popup } = await setupVault(ctx);520    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();521    await popup.waitForTimeout(300);522    await popup.getByRole("button", { name: /password generator/i }).click();523    await popup.waitForTimeout(500);524    const pwd1 = await popup.locator(".peach-pwgen-output").textContent();525    expect(pwd1?.length).toBeGreaterThan(0);526    await ctx.close();527  });528529  test("SECP02 - generated passwords not logged", async () => {530    const { popup } = await setupVault(ctx);531    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();532    await popup.waitForTimeout(300);533    await popup.getByRole("button", { name: /password generator/i }).click();534    await popup.waitForTimeout(500);535    const pwd = await popup.locator(".peach-pwgen-output").textContent();536    expect(pwd).not.toContain("console.log");537    await ctx.close();538  });539540  test("SECP03 - password entropy meets minimum threshold", async () => {541    const { popup } = await setupVault(ctx);542    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();543    await popup.waitForTimeout(300);544    await popup.getByRole("button", { name: /password generator/i }).click();545    await popup.waitForTimeout(500);546    const slider = popup.locator(".peach-pwgen-slider");547    await slider.fill("32");548    await popup.waitForTimeout(300);549    const pwd = await popup.locator(".peach-pwgen-output").textContent();550    expect(pwd?.length).toBe(32);551    const uniqueChars = new Set(pwd?.split("")).size;552    expect(uniqueChars).toBeGreaterThan(10);553    await ctx.close();554  });555});