security-sentinels.spec.ts

7.5 KB · raw

1/**2 * security-sentinels.spec.ts — PB-26-210 mandatory Chrome release browser gate.3 *4 * Security sentinels that must pass (non-skipped, assertion-bearing) before5 * any release can report success:6 *7 * - PB-26-201: a hostile page cannot obtain vault-wide metadata, a foreign8 *   password, a raw TOTP seed, or password history via content-script message9 *   shapes. Attacker-supplied Booleans (userGesture, explicitSummon) have no10 *   authorization effect.11 * - PB-26-203: a real content script cannot read peach_session_dk from12 *   chrome.storage.session after unlock — the TRUSTED_CONTEXTS access level13 *   enforced by the storage-hardening initializer blocks content scripts.14 */1516import { test, expect, type BrowserContext } from "@playwright/test";17import { makeContext, setupVault, addEntry, lockVault, unlockVault } from "./helpers/vault";1819test.describe.configure({ timeout: 120_000 });2021test.describe("PB-26-201 @security: hostile page cannot obtain foreign secrets", () => {22  let ctx: BrowserContext;2324  test.beforeEach(async () => {25    ctx = await makeContext("pb26201-sentinel");26  });2728  test.afterEach(async () => {29    await ctx.close();30  });3132  test("content script GET_ENTRY_FOR_EDIT is denied (@security)", async () => {33    const { popup } = await setupVault(ctx);34    await addEntry(popup, {35      title: "Foreign Entry",36      url: "https://example.org/login",37      username: "[email protected]",38      password: "foreign-secret-totally-private",39    });4041    const page = await ctx.newPage();42    await page.goto("https://example.com/");4344    const result = await popup.evaluate(async () => {45      const tabs = await chrome.tabs.query({ url: "https://example.com/*" });46      const tab = tabs.find((t) => t.id !== undefined);47      if (!tab?.id) throw new Error("test page tab not found");4849      const exec = await chrome.scripting.executeScript({50        target: { tabId: tab.id },51        func: async () => {52          const editResp = await chrome.runtime.sendMessage({53            type: "GET_ENTRY_FOR_EDIT",54            payload: { id: "any-id" },55          });56          return { editResp };57        },58      });59      return exec[0]?.result as { editResp: { ok?: boolean; error?: string } };60    });6162    expect(result?.editResp?.ok).toBe(false);63    expect(result?.editResp?.error).toBe("invalid_sender");64  });6566  test("userGesture and explicitSummon Booleans cannot authorize GET_ENTRIES (@security)", async () => {67    const { popup } = await setupVault(ctx);68    await addEntry(popup, {69      title: "Secret Vault Entry",70      url: "https://example.org/login",71      username: "[email protected]",72      password: "should-never-leak",73    });7475    const page = await ctx.newPage();76    await page.goto("https://example.com/");7778    const result = await popup.evaluate(async () => {79      const tabs = await chrome.tabs.query({ url: "https://example.com/*" });80      const tab = tabs.find((t) => t.id !== undefined);81      if (!tab?.id) throw new Error("test page tab not found");8283      const exec = await chrome.scripting.executeScript({84        target: { tabId: tab.id },85        func: async () => {86          const entriesResp = await chrome.runtime.sendMessage({87            type: "GET_ENTRIES",88            payload: { includeAll: true, userGesture: true, explicitSummon: true },89          });90          return { entriesResp };91        },92      });93      return exec[0]?.result as { entriesResp: { ok?: boolean; error?: string } };94    });9596    expect(result?.entriesResp?.ok).toBe(false);97    expect(result?.entriesResp?.error).toBe("invalid_sender");98  });99100  test("AUTOFILL_QUERY on hostile page returns no foreign passwords or TOTP seeds (@security)", async () => {101    const { popup } = await setupVault(ctx);102    await addEntry(popup, {103      title: "Example Org",104      url: "https://example.org/login",105      username: "[email protected]",106      password: "foreign-pass-12345",107      totp: "JBSWY3DPEHPK3PXP",108    });109110    const page = await ctx.newPage();111    await page.goto("https://example.com/");112113    const result = await popup.evaluate(async () => {114      const tabs = await chrome.tabs.query({ url: "https://example.com/*" });115      const tab = tabs.find((t) => t.id !== undefined);116      if (!tab?.id) throw new Error("test page tab not found");117118      const exec = await chrome.scripting.executeScript({119        target: { tabId: tab.id },120        func: async () => {121          // AUTOFILL_QUERY is content-script-allowed but must be origin-scoped.122          // example.com should get NO results for example.org entries.123          const queryResp = await chrome.runtime.sendMessage({124            type: "AUTOFILL_QUERY",125            payload: { explicitSummon: true, userGesture: true },126          });127          return { queryResp: JSON.stringify(queryResp) };128        },129      });130      return exec[0]?.result as { queryResp: string };131    });132133    // The response must not contain the foreign password, TOTP seed, or134    // the foreign origin's entry data.135    expect(result?.queryResp).not.toContain("foreign-pass-12345");136    expect(result?.queryResp).not.toContain("JBSWY3DPEHPK3PXP");137    expect(result?.queryResp).not.toContain("example.org");138  });139});140141test.describe("PB-26-203 @security: content script cannot read session storage key", () => {142  let ctx: BrowserContext;143144  test.beforeEach(async () => {145    ctx = await makeContext("pb26203-sentinel");146  });147148  test.afterEach(async () => {149    await ctx.close();150  });151152  test("content script cannot read peach_session_dk from storage.session after unlock (@security)", async () => {153    const { popup } = await setupVault(ctx);154    await lockVault(popup);155    await unlockVault(popup);156157    // Navigate to a real page so a content script context exists.158    const page = await ctx.newPage();159    await page.goto("https://example.com/");160161    const result = await popup.evaluate(async () => {162      const tabs = await chrome.tabs.query({ url: "https://example.com/*" });163      const tab = tabs.find((t) => t.id !== undefined);164      if (!tab?.id) throw new Error("test page tab not found");165166      const exec = await chrome.scripting.executeScript({167        target: { tabId: tab.id },168        func: async () => {169          // Content scripts are NOT trusted contexts after the storage170          // hardening initializer sets TRUSTED_CONTEXTS. This call should171          // either throw or return an empty object — the key must not172          // be readable.173          try {174            const data = await chrome.storage.session.get("peach_session_dk");175            return {176              accessible: true,177              hasKey: "peach_session_dk" in data,178              keys: Object.keys(data),179            };180          } catch (e) {181            return { accessible: false, error: String(e) };182          }183        },184      });185      return exec[0]?.result as {186        accessible: boolean;187        hasKey?: boolean;188        keys?: string[];189        error?: string;190      };191    });192193    // The content script must not be able to read the session key.194    // With TRUSTED_CONTEXTS, chrome.storage.session.get throws.195    expect(result?.accessible).toBe(false);196197    // Verify the extension page (trusted context) CAN still read it —198    // proving the key exists but is isolated from content scripts.199    const extAccess = await popup.evaluate(async () => {200      try {201        const data = await chrome.storage.session.get("peach_session_dk");202        return { hasKey: "peach_session_dk" in data };203      } catch {204        return { hasKey: false };205      }206    });207    expect(extAccess?.hasKey).toBe(true);208  });209});