diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml
index 0fa5c156..badc136c 100644
--- a/.github/workflows/pr-checks.yml
+++ b/.github/workflows/pr-checks.yml
@@ -64,3 +64,9 @@ jobs:
- name: Test desktop app
run: pnpm --filter @profullstack/threatcrush-desktop test
+
+ - name: Test browser extension
+ run: pnpm --filter @profullstack/threatcrush-extension test
+
+ - name: Build browser extension
+ run: pnpm --filter @profullstack/threatcrush-extension build
diff --git a/apps/extension/README.md b/apps/extension/README.md
index fc502285..d0b90d29 100644
--- a/apps/extension/README.md
+++ b/apps/extension/README.md
@@ -37,31 +37,82 @@ node scripts/build.js all
**Safari:**
Requires Xcode to convert to a Safari Web Extension bundle. See Apple's [Safari Web Extensions docs](https://developer.apple.com/documentation/safariservices/safari_web_extensions).
-## Features
+## Page checks
-- Scan any site (security headers, mixed content, basic checks)
-- Real-time alert popup when a ThreatCrush server emits a critical event
-- Dashboard popup — recent events + module status
+The popup's **This page** tab checks the site in the active tab. All of it runs in your browser.
+
+| Check | Source | Pass / warn / fail |
+| --- | --- | --- |
+| HTTPS | tab URL | fail on plain HTTP (warn on loopback) |
+| Strict-Transport-Security | response headers | fail if missing, `max-age=0` or unparseable; warn under 180 days; n/a over HTTP |
+| Content-Security-Policy | headers, then `` | fail on script `'unsafe-inline'` (without nonce/hash), `*`, `https:`, `data:`; warn on `'unsafe-eval'`, report-only, or missing |
+| Clickjacking | CSP `frame-ancestors`, else `X-Frame-Options` | fail if `frame-ancestors` allows any origin; warn if missing or `ALLOW-FROM` |
+| X-Content-Type-Options | headers | warn unless `nosniff` |
+| Referrer-Policy | headers, `` | fail on `unsafe-url`; warn on `no-referrer-when-downgrade` or missing |
+| Permissions-Policy | headers | warn if missing or only legacy `Feature-Policy` |
+| Mixed content | Resource Timing + DOM | fail on http:// scripts, frames, styles, fetches; warn on http:// images/media |
+| Forms | DOM | fail on a password field over HTTP or an HTTPS page posting to http:// |
+| Cookies | `chrome.cookies` | for likely session cookies (by name): fail on no `Secure` over HTTPS; warn on no `HttpOnly` (CSRF cookies exempt) or `SameSite` None/unset. Names only, never values |
+
+The toolbar badge is set per tab: the number of failures (red), else warnings (amber), else ✓. It is updated as each page finishes loading (toggle in Options → Page checks) and whenever the popup opens.
+
+The checks live in `src/lib/page-checks.js` as pure functions and are unit-tested in `__tests__/page-checks.test.js`. `src/background/page-checks.js` collects the inputs.
+
+## Privacy
+
+Nothing about the pages you visit leaves the browser. The extension keeps each tab's security headers in `storage.session` (cleared when the tab closes or the browser exits), reads cookie names and flags but never values, and reads form targets and subresource URLs from the page.
+
+The only exception is the **Scan with ThreatCrush** button. Clicking it sends the page's origin and path (query string and fragment removed) to `POST /api/scan` on the ThreatCrush web app, which fetches that URL server-side and grades its headers. The popup shows the exact URL before you click. The same statement is on the options page.
+
+## Permissions
+
+| Permission | Why | Install warning (Chrome) |
+| --- | --- | --- |
+| `activeTab` | Read the tab's URL and inspect its DOM when you click the toolbar button | none |
+| `scripting` | Run the DOM collector (`src/lib/collect-page.js`) in the page | none |
+| `webRequest` | Observe (never block or modify) main-frame response headers | none on its own |
+| `cookies` | Read cookie flags for the site | none on its own |
+| `storage`, `alarms`, `notifications` | Settings, account polling, alerts | none |
+| `optional_host_permissions: http://*/*, https://*/*` | Needed for `webRequest` and `cookies` to see a site, and for the per-page badge. **Requested at first use** from the popup's "Enable page checks" button or Options → Page checks, and revocable there | shown only when requested |
+
+Header capture has to be in place before the page loads, which is why the site access is broad rather than per-click: `activeTab` is granted after the page has already loaded, too late to observe its response headers. Without site access, the HTTPS, forms and mixed-content checks still run when you open the popup; headers and cookies show as Unknown. After granting access, reload the page once (the popup offers a button) so its headers are captured.
+
+### Firefox differences
+
+- Host permissions in MV3 are always user-controlled; `optional_host_permissions` needs Firefox 128+ (the manifest requires 142+).
+- The background is an event page (`background.scripts`), not a service worker. `webRequest` listeners are registered at top level, so they wake it.
+- `webRequest.onHeadersReceived` with `responseHeaders` works the same. Firefox MV3 still allows blocking listeners; this extension only observes.
+- `permissions.request()` must be called synchronously from the click handler. Firefox may close the popup while its permission prompt is open; reopen it afterwards.
+- With first-party isolation on, `cookies.getAll` needs `firstPartyDomain`; the extension retries with `firstPartyDomain: null`.
+- `data_collection_permissions` stays `none`: the scan button is a user-initiated, clearly labelled transmission of the current page URL, which AMO's add-on policy (§6.2.2.2) treats as implied consent.
+
+### Safari
+
+Safari's `webRequest` support is limited, so header checks may show Unknown there. Not tested; Safari needs an Xcode wrapper project and an Apple developer account.
+
+## Account features
+
+The **Account** tab signs in with Supabase and shows usage and alerts. The build reads `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` (and `NEXT_PUBLIC_APP_URL`, default `https://threatcrush.com`) from the environment or `apps/.env*`. Without the Supabase values, sign-in reports that it isn't configured; page checks still work.
## Structure
```
apps/extension/
-├── manifest.json MV3 manifest (per-browser variants in src/manifests/)
├── src/
-│ ├── background/ Service worker
-│ ├── popup/ React popup UI
+│ ├── manifest.{chrome,firefox,safari}.json
+│ ├── background/ Service worker: account polling, page checks, badge
+│ ├── lib/ API client, Supabase client, page checks, DOM collector
+│ ├── popup/ React popup UI (This page / Account)
│ ├── options/ React options page
-│ ├── content/ Content scripts
-│ └── store/ Zustand stores (shared)
+│ └── store/ Zustand stores
└── scripts/build.js Per-browser packager
```
## Store submission (post-launch)
Each store has its own review process:
-- **Chrome Web Store** — requires dev fee, screenshots, privacy policy, scope justification
-- **Firefox AMO** — free; source review if minified
-- **Safari** — App Store Connect, Apple Developer membership required
+- **Chrome Web Store** — needs a developer account (one-time fee), screenshots, a privacy policy, and a justification for the optional `http://*/*` / `https://*/*` host access (the Privacy and Permissions sections above are the basis)
+- **Firefox AMO** — needs an AMO account and API key/secret for `web-ext sign`; source review if minified
+- **Safari** — needs an Apple Developer membership and an Xcode Safari Web Extension wrapper project, signed and submitted through App Store Connect
-None submitted yet.
+None submitted yet; all three are blocked on those accounts.
diff --git a/apps/extension/__tests__/auth-token.test.js b/apps/extension/__tests__/auth-token.test.js
new file mode 100644
index 00000000..3d30b346
--- /dev/null
+++ b/apps/extension/__tests__/auth-token.test.js
@@ -0,0 +1,52 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+beforeEach(() => {
+ vi.resetModules();
+ global.chrome = { storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn(), remove: vi.fn() } } };
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+});
+
+afterEach(() => {
+ vi.doUnmock('../src/lib/supabase.js');
+});
+
+describe('API auth', () => {
+ it('sends the signed-in Supabase session token with API requests', async () => {
+ vi.doMock('../src/lib/supabase.js', () => ({
+ supabase: {
+ auth: { getSession: async () => ({ data: { session: { access_token: 'jwt-from-supabase' } } }) },
+ },
+ }));
+ const { getUsageStats } = await import('../src/lib/api.js');
+
+ await getUsageStats();
+
+ const [, init] = fetch.mock.calls[0];
+ expect(init.headers.Authorization).toBe('Bearer jwt-from-supabase');
+ });
+
+ it('sends no Authorization header when signed out', async () => {
+ vi.doMock('../src/lib/supabase.js', () => ({
+ supabase: { auth: { getSession: async () => ({ data: { session: null } }) } },
+ }));
+ const { getUsageStats } = await import('../src/lib/api.js');
+
+ await getUsageStats();
+
+ expect(fetch.mock.calls[0][1].headers).not.toHaveProperty('Authorization');
+ });
+});
+
+describe('build without Supabase config', () => {
+ it('reports sign-in as not configured instead of failing to load', async () => {
+ // The test build defines no VITE_SUPABASE_URL, like a CI build without secrets.
+ const { useAuthStore } = await import('../src/store/auth.js');
+
+ await useAuthStore.getState().initialize();
+
+ const state = useAuthStore.getState();
+ expect(state.loading).toBe(false);
+ expect(state.user).toBeNull();
+ expect(state.error).toMatch(/not configured/i);
+ });
+});
diff --git a/apps/extension/__tests__/collect-page.test.js b/apps/extension/__tests__/collect-page.test.js
new file mode 100644
index 00000000..b6a5d592
--- /dev/null
+++ b/apps/extension/__tests__/collect-page.test.js
@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { collectPageSignals } from '../src/lib/collect-page.js';
+import { checkForms, checkMixedContent } from '../src/lib/page-checks.js';
+
+function load(html, resourceEntries = []) {
+ document.head.innerHTML = '';
+ document.body.innerHTML = html;
+ vi.spyOn(performance, 'getEntriesByType').mockImplementation((type) => (type === 'resource' ? resourceEntries : []));
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('collectPageSignals', () => {
+ it('collects only http:// subresources from the DOM and Resource Timing', () => {
+ load(
+ `
+
+
+
+ `,
+ [
+ { name: 'http://api.example/data', initiatorType: 'fetch' },
+ { name: 'https://api.example/ok', initiatorType: 'fetch' },
+ ]
+ );
+
+ const { resources } = collectPageSignals();
+
+ expect(resources).toEqual(
+ expect.arrayContaining([
+ { url: 'http://api.example/data', kind: 'fetch' },
+ { url: 'http://cdn.example/app.js', kind: 'script' },
+ { url: 'http://cdn.example/site.css', kind: 'stylesheet' },
+ { url: 'http://widgets.example/frame', kind: 'iframe' },
+ { url: 'http://img.example/logo.png', kind: 'img' },
+ ])
+ );
+ expect(resources.every((r) => r.url.startsWith('http:'))).toBe(true);
+ expect(checkMixedContent('https://site.example/', resources).status).toBe('fail');
+ });
+
+ it('reads form targets from the attribute even when a field named "action" shadows form.action', () => {
+ load(
+ `
+ `
+ );
+
+ const signals = collectPageSignals();
+
+ expect(signals.forms).toEqual([
+ { action: 'http://collector.example/post', hasPassword: true },
+ { action: document.baseURI, hasPassword: false },
+ { action: 'http://other.example/go', hasPassword: false },
+ ]);
+ expect(signals.passwordOutsideForm).toBe(false);
+ expect(checkForms('https://site.example/', signals).status).toBe('fail');
+ });
+
+ it('notices password inputs outside any form and CSP/referrer meta tags', () => {
+ load('');
+ document.head.innerHTML = `
+ `;
+
+ const signals = collectPageSignals();
+
+ expect(signals.passwordOutsideForm).toBe(true);
+ expect(signals.metaCsp).toEqual(["script-src 'self'"]);
+ expect(signals.metaReferrer).toBe('no-referrer');
+ });
+});
diff --git a/apps/extension/__tests__/options-app.test.jsx b/apps/extension/__tests__/options-app.test.jsx
index 4094fdd4..b10150e4 100644
--- a/apps/extension/__tests__/options-app.test.jsx
+++ b/apps/extension/__tests__/options-app.test.jsx
@@ -10,6 +10,9 @@ describe('extension options app', () => {
alarms: {
create: vi.fn().mockResolvedValue(undefined),
},
+ permissions: {
+ contains: vi.fn().mockResolvedValue(false),
+ },
storage: {
local: {
get: vi.fn((keys, callback) => callback({})),
diff --git a/apps/extension/__tests__/page-checks.test.js b/apps/extension/__tests__/page-checks.test.js
new file mode 100644
index 00000000..ab2c672e
--- /dev/null
+++ b/apps/extension/__tests__/page-checks.test.js
@@ -0,0 +1,368 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ analyzeCsp,
+ badgeFor,
+ checkCookies,
+ checkCsp,
+ checkForms,
+ checkFraming,
+ checkHsts,
+ checkHttps,
+ checkMixedContent,
+ checkNosniff,
+ checkPermissionsPolicy,
+ checkReferrerPolicy,
+ headerMap,
+ isLikelySessionCookie,
+ runPageChecks,
+ scanTargetUrl,
+} from '../src/lib/page-checks.js';
+
+const HTTPS = 'https://shop.example/account';
+const HTTP = 'http://shop.example/account';
+
+const h = (pairs) => headerMap(Object.entries(pairs).map(([name, value]) => ({ name, value })));
+
+const STRONG_HEADERS = [
+ { name: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
+ { name: 'Content-Security-Policy', value: "default-src 'self'; frame-ancestors 'none'" },
+ { name: 'X-Content-Type-Options', value: 'nosniff' },
+ { name: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { name: 'Permissions-Policy', value: 'camera=(), microphone=()' },
+];
+
+describe('headerMap', () => {
+ it('matches header names case-insensitively and keeps repeated headers in order', () => {
+ const map = headerMap([
+ { name: 'Content-Security-Policy', value: "script-src 'self'" },
+ { name: 'content-security-policy', value: "frame-ancestors 'none'" },
+ ]);
+ expect(map.get('content-security-policy')).toEqual(["script-src 'self'", "frame-ancestors 'none'"]);
+ });
+});
+
+describe('checkHttps', () => {
+ it('passes https, fails public http, and only warns for loopback http', () => {
+ expect(checkHttps(HTTPS).status).toBe('pass');
+ expect(checkHttps(HTTP).status).toBe('fail');
+ expect(checkHttps('http://localhost:8080/').status).toBe('warn');
+ expect(checkHttps('http://127.0.0.1/').status).toBe('warn');
+ expect(checkHttps('http://[::1]:3000/').status).toBe('warn');
+ });
+});
+
+describe('checkHsts', () => {
+ it('does not apply to plain HTTP pages even if the header is sent', () => {
+ expect(checkHsts(HTTP, h({ 'Strict-Transport-Security': 'max-age=31536000' })).status).toBe('na');
+ });
+
+ it('fails when missing, max-age=0 or unparseable', () => {
+ expect(checkHsts(HTTPS, h({})).status).toBe('fail');
+ expect(checkHsts(HTTPS, h({ 'Strict-Transport-Security': 'max-age=0' })).status).toBe('fail');
+ expect(checkHsts(HTTPS, h({ 'Strict-Transport-Security': 'includeSubDomains' })).status).toBe('fail');
+ });
+
+ it('warns below 180 days and passes at 180 days', () => {
+ expect(checkHsts(HTTPS, h({ 'Strict-Transport-Security': 'max-age=86400' })).status).toBe('warn');
+ expect(checkHsts(HTTPS, h({ 'Strict-Transport-Security': 'max-age=15552000' })).status).toBe('pass');
+ expect(checkHsts(HTTPS, h({ 'Strict-Transport-Security': 'MAX-AGE="31536000"; preload' })).status).toBe('pass');
+ });
+});
+
+describe('analyzeCsp', () => {
+ const codes = (policy) => analyzeCsp(policy).issues.map((i) => i.code);
+
+ it('flags unsafe-inline, unsafe-eval and wildcard script sources', () => {
+ expect(codes("script-src 'self' 'unsafe-inline'")).toEqual(['unsafe-inline']);
+ expect(codes("script-src 'self' 'unsafe-eval'")).toEqual(['unsafe-eval']);
+ expect(codes('script-src *')).toEqual(['any-origin']);
+ expect(codes("default-src 'self' https:")).toEqual(['any-origin']);
+ expect(codes("script-src 'self' data:")).toEqual(['data-scripts']);
+ });
+
+ it('falls back to default-src when script-src is absent', () => {
+ expect(codes("default-src 'unsafe-inline'")).toEqual(['unsafe-inline']);
+ expect(codes("default-src *; script-src 'self'")).toEqual([]);
+ });
+
+ it("ignores 'unsafe-inline' next to a nonce or hash, as CSP2+ browsers do", () => {
+ expect(codes("script-src 'nonce-abc123' 'unsafe-inline'")).toEqual([]);
+ expect(codes("script-src 'sha256-AbCd=' 'unsafe-inline'")).toEqual([]);
+ });
+
+ it("ignores host and scheme fallbacks under 'strict-dynamic'", () => {
+ expect(codes("script-src 'nonce-r4nd' 'strict-dynamic' https: 'unsafe-inline'")).toEqual([]);
+ });
+
+ it('uses the first occurrence of a repeated directive', () => {
+ expect(codes("script-src 'self'; script-src *")).toEqual([]);
+ });
+
+ it('reports a policy without script-src or default-src as unrestricted', () => {
+ expect(analyzeCsp("frame-ancestors 'none'").restricted).toBe(false);
+ });
+});
+
+describe('checkCsp', () => {
+ it('warns when there is no policy or only a report-only policy', () => {
+ expect(checkCsp(h({})).status).toBe('warn');
+ const reportOnly = checkCsp(h({ 'Content-Security-Policy-Report-Only': "default-src 'self'" }));
+ expect(reportOnly.status).toBe('warn');
+ expect(reportOnly.detail).toMatch(/report-only/i);
+ });
+
+ it('passes a restrictive policy and fails a policy allowing inline script', () => {
+ expect(checkCsp(h({ 'Content-Security-Policy': "default-src 'self'" })).status).toBe('pass');
+ const weak = checkCsp(h({ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'" }));
+ expect(weak.status).toBe('fail');
+ expect(weak.items).toHaveLength(1);
+ });
+
+ it('only warns when the sole weakness is unsafe-eval', () => {
+ expect(checkCsp(h({ 'Content-Security-Policy': "script-src 'self' 'unsafe-eval'" })).status).toBe('warn');
+ });
+
+ it('treats a weakness as real only when every enforced policy has it', () => {
+ const tightened = headerMap([
+ { name: 'Content-Security-Policy', value: "script-src 'self' 'unsafe-inline'" },
+ { name: 'Content-Security-Policy', value: "script-src 'self'" },
+ ]);
+ expect(checkCsp(tightened).status).toBe('pass');
+
+ // Comma-joined policies in one header are separate policies too.
+ const joined = h({ 'Content-Security-Policy': "script-src *, frame-ancestors 'none'" });
+ expect(checkCsp(joined).status).toBe('fail');
+ });
+
+ it('warns when the enforced policy does not restrict scripts at all', () => {
+ expect(checkCsp(h({ 'Content-Security-Policy': "frame-ancestors 'none'" })).status).toBe('warn');
+ });
+
+ it('analyzes a policy when no header policy exists', () => {
+ expect(checkCsp(h({}), ["script-src 'self'"]).status).toBe('pass');
+ expect(checkCsp(h({}), ["script-src 'unsafe-inline'"]).status).toBe('fail');
+ });
+});
+
+describe('checkFraming', () => {
+ it('passes with X-Frame-Options DENY/SAMEORIGIN in any case', () => {
+ expect(checkFraming(h({ 'X-Frame-Options': 'deny' })).status).toBe('pass');
+ expect(checkFraming(h({ 'X-Frame-Options': 'SAMEORIGIN' })).status).toBe('pass');
+ });
+
+ it('passes with a restrictive CSP frame-ancestors even without X-Frame-Options', () => {
+ expect(checkFraming(h({ 'Content-Security-Policy': "frame-ancestors 'self'" })).status).toBe('pass');
+ });
+
+ it('fails when frame-ancestors allows any origin, even if X-Frame-Options is set', () => {
+ // frame-ancestors takes precedence over X-Frame-Options in browsers.
+ const headers = h({ 'Content-Security-Policy': 'frame-ancestors *', 'X-Frame-Options': 'DENY' });
+ expect(checkFraming(headers).status).toBe('fail');
+ });
+
+ it('warns when missing, obsolete ALLOW-FROM, or invalid', () => {
+ expect(checkFraming(h({})).status).toBe('warn');
+ expect(checkFraming(h({ 'X-Frame-Options': 'ALLOW-FROM https://a.example' })).status).toBe('warn');
+ expect(checkFraming(h({ 'X-Frame-Options': 'yes' })).status).toBe('warn');
+ });
+});
+
+describe('simple header checks', () => {
+ it('X-Content-Type-Options requires nosniff', () => {
+ expect(checkNosniff(h({ 'X-Content-Type-Options': 'NoSniff' })).status).toBe('pass');
+ expect(checkNosniff(h({ 'X-Content-Type-Options': 'sniff' })).status).toBe('warn');
+ expect(checkNosniff(h({})).status).toBe('warn');
+ });
+
+ it('Referrer-Policy uses the last recognised token and fails unsafe-url', () => {
+ expect(checkReferrerPolicy(h({ 'Referrer-Policy': 'no-referrer' })).status).toBe('pass');
+ expect(checkReferrerPolicy(h({ 'Referrer-Policy': 'unsafe-url' })).status).toBe('fail');
+ expect(checkReferrerPolicy(h({ 'Referrer-Policy': 'no-referrer-when-downgrade' })).status).toBe('warn');
+ expect(checkReferrerPolicy(h({ 'Referrer-Policy': 'unsafe-url, made-up-policy' })).status).toBe('fail');
+ expect(checkReferrerPolicy(h({ 'Referrer-Policy': 'unsafe-url, strict-origin' })).status).toBe('pass');
+ expect(checkReferrerPolicy(h({})).status).toBe('warn');
+ expect(checkReferrerPolicy(h({}), 'same-origin').status).toBe('pass');
+ });
+
+ it('Permissions-Policy passes when set and warns on legacy Feature-Policy or absence', () => {
+ expect(checkPermissionsPolicy(h({ 'Permissions-Policy': 'geolocation=()' })).status).toBe('pass');
+ expect(checkPermissionsPolicy(h({ 'Feature-Policy': "geolocation 'none'" })).status).toBe('warn');
+ expect(checkPermissionsPolicy(h({})).status).toBe('warn');
+ });
+});
+
+describe('checkMixedContent', () => {
+ it('does not apply to http pages', () => {
+ expect(checkMixedContent(HTTP, [{ url: 'http://cdn.example/a.js', kind: 'script' }]).status).toBe('na');
+ });
+
+ it('passes when every subresource is https', () => {
+ const resources = [
+ { url: 'https://cdn.example/a.js', kind: 'script' },
+ { url: 'https://cdn.example/b.png', kind: 'img' },
+ ];
+ expect(checkMixedContent(HTTPS, resources).status).toBe('pass');
+ });
+
+ it('warns for passive http content and fails for active http content', () => {
+ const passive = checkMixedContent(HTTPS, [{ url: 'http://cdn.example/b.png', kind: 'img' }]);
+ expect(passive.status).toBe('warn');
+ expect(passive.items).toEqual(['img: http://cdn.example/b.png']);
+
+ for (const kind of ['script', 'stylesheet', 'iframe', 'fetch', 'xmlhttprequest', 'object']) {
+ expect(checkMixedContent(HTTPS, [{ url: 'http://cdn.example/x', kind }]).status).toBe('fail');
+ }
+ });
+
+ it('counts a URL once and as active if any reference to it is active', () => {
+ const result = checkMixedContent(HTTPS, [
+ { url: 'http://cdn.example/x.svg', kind: 'img' },
+ { url: 'http://cdn.example/x.svg', kind: 'iframe' },
+ ]);
+ expect(result.status).toBe('fail');
+ expect(result.items).toEqual(['iframe: http://cdn.example/x.svg']);
+ });
+
+ it('ignores unparseable URLs', () => {
+ expect(checkMixedContent(HTTPS, [{ url: 'not a url', kind: 'script' }]).status).toBe('pass');
+ });
+});
+
+describe('checkForms', () => {
+ it('fails a password field on an http page, inside or outside a form', () => {
+ expect(checkForms(HTTP, { forms: [{ action: `${HTTP}/login`, hasPassword: true }] }).status).toBe('fail');
+ expect(checkForms(HTTP, { forms: [], passwordOutsideForm: true }).status).toBe('fail');
+ });
+
+ it('fails an https page with a form posting to http', () => {
+ const result = checkForms(HTTPS, {
+ forms: [{ action: 'http://collector.example/submit?x=1', hasPassword: false }],
+ });
+ expect(result.status).toBe('fail');
+ expect(result.items).toEqual(['http://collector.example/submit']);
+ });
+
+ it('passes https forms, including relative and empty actions', () => {
+ const forms = [
+ { action: 'https://shop.example/login', hasPassword: true },
+ { action: '/search', hasPassword: false },
+ { action: '', hasPassword: false },
+ ];
+ expect(checkForms(HTTPS, { forms }).status).toBe('pass');
+ expect(checkForms(HTTPS, { forms: [] }).status).toBe('pass');
+ });
+
+ it('warns about non-password forms on an http page', () => {
+ expect(checkForms(HTTP, { forms: [{ action: '/search', hasPassword: false }] }).status).toBe('warn');
+ });
+});
+
+describe('checkCookies', () => {
+ const cookie = (name, flags = {}) => ({ name, secure: true, httpOnly: true, sameSite: 'lax', ...flags });
+
+ it('recognises likely session cookie names, including __Host- prefixed ones', () => {
+ for (const name of ['PHPSESSID', 'connect.sid', 'session', '__Host-auth', 'remember_me', 'access_token', 'csrftoken']) {
+ expect(isLikelySessionCookie(name), name).toBe(true);
+ }
+ for (const name of ['_ga', '_gid', 'theme', 'lang', 'OptanonConsent']) {
+ expect(isLikelySessionCookie(name), name).toBe(false);
+ }
+ });
+
+ it('passes when there are no cookies or session cookies are fully flagged', () => {
+ expect(checkCookies(HTTPS, []).status).toBe('pass');
+ expect(checkCookies(HTTPS, [cookie('sessionid', { sameSite: 'strict' })]).status).toBe('pass');
+ });
+
+ it('fails a session cookie without Secure on an https site', () => {
+ const result = checkCookies(HTTPS, [cookie('sessionid', { secure: false })]);
+ expect(result.status).toBe('fail');
+ expect(result.items).toEqual(['sessionid: no Secure']);
+ });
+
+ it('warns for missing HttpOnly or SameSite, and lists names only', () => {
+ const result = checkCookies(HTTPS, [
+ cookie('auth', { httpOnly: false }),
+ cookie('sid', { sameSite: 'unspecified' }),
+ cookie('token', { sameSite: 'no_restriction' }),
+ cookie('_ga', { secure: false, httpOnly: false, sameSite: 'unspecified' }),
+ ]);
+ expect(result.status).toBe('warn');
+ expect(result.items).toEqual(['auth: no HttpOnly', 'sid: no SameSite', 'token: SameSite=None']);
+ });
+
+ it('does not require HttpOnly on CSRF cookies, which page scripts must read', () => {
+ expect(checkCookies(HTTPS, [cookie('XSRF-TOKEN', { httpOnly: false })]).status).toBe('pass');
+ });
+
+ it('does not fail a missing Secure flag on an http site (the HTTPS check covers that)', () => {
+ expect(checkCookies(HTTP, [cookie('sessionid', { secure: false })]).status).toBe('warn');
+ });
+});
+
+describe('runPageChecks', () => {
+ const cleanPage = { resources: [], forms: [], passwordOutsideForm: false, metaCsp: [], metaReferrer: null };
+
+ it('passes every check for a well-configured https page', () => {
+ const report = runPageChecks({ url: HTTPS, headers: STRONG_HEADERS, cookies: [], page: cleanPage });
+ expect(report.supported).toBe(true);
+ expect(report.checks.map((c) => [c.id, c.status])).toEqual([
+ ['https', 'pass'],
+ ['hsts', 'pass'],
+ ['csp', 'pass'],
+ ['framing', 'pass'],
+ ['nosniff', 'pass'],
+ ['referrer', 'pass'],
+ ['permissions', 'pass'],
+ ['mixed', 'pass'],
+ ['forms', 'pass'],
+ ['cookies', 'pass'],
+ ]);
+ expect(report.summary).toEqual({ pass: 10, warn: 0, fail: 0, status: 'pass' });
+ });
+
+ it('marks checks unknown when their data could not be collected, and does not count them', () => {
+ const report = runPageChecks({
+ url: HTTPS,
+ headers: null,
+ cookies: null,
+ page: null,
+ unavailable: { headers: 'Needs site access.' },
+ });
+ const unknown = report.checks.filter((c) => c.status === 'unknown');
+ expect(unknown).toHaveLength(9);
+ expect(report.checks.find((c) => c.id === 'csp').detail).toBe('Needs site access.');
+ expect(report.summary).toEqual({ pass: 1, warn: 0, fail: 0, status: 'pass' });
+ });
+
+ it('uses CSP and referrer values collected from the page', () => {
+ const report = runPageChecks({
+ url: HTTPS,
+ headers: [],
+ cookies: [],
+ page: { ...cleanPage, metaCsp: ["script-src 'self'"], metaReferrer: 'no-referrer' },
+ });
+ expect(report.checks.find((c) => c.id === 'csp').status).toBe('pass');
+ expect(report.checks.find((c) => c.id === 'referrer').status).toBe('pass');
+ });
+
+ it('refuses non-web URLs', () => {
+ expect(runPageChecks({ url: 'chrome://settings', headers: [], cookies: [], page: cleanPage }).supported).toBe(false);
+ expect(runPageChecks({ url: 'file:///etc/hosts', headers: [], cookies: [], page: cleanPage }).supported).toBe(false);
+ });
+});
+
+describe('badgeFor', () => {
+ it('shows failures first, then warnings, then a check mark', () => {
+ expect(badgeFor({ fail: 2, warn: 5, pass: 1 }).text).toBe('2');
+ expect(badgeFor({ fail: 0, warn: 3, pass: 1 }).text).toBe('3');
+ expect(badgeFor({ fail: 0, warn: 0, pass: 9 }).text).toBe('✓');
+ });
+});
+
+describe('scanTargetUrl', () => {
+ it('drops the query string and fragment before anything is sent to the server', () => {
+ expect(scanTargetUrl('https://shop.example/reset?token=s3cret#step2')).toBe('https://shop.example/reset');
+ });
+});
diff --git a/apps/extension/src/background/index.js b/apps/extension/src/background/index.js
index eb85fbab..bc281d08 100644
--- a/apps/extension/src/background/index.js
+++ b/apps/extension/src/background/index.js
@@ -1,10 +1,15 @@
/**
* ThreatCrush Background Service Worker
*
- * Handles periodic security event checks, badge updates, and notifications.
+ * Handles periodic security event checks, badge updates, notifications, and
+ * the local checks on the page in each tab (see ./page-checks.js).
*/
-import { getUsageStats, scanUrl as apiScanUrl } from '../lib/api.js';
+import { getAuthToken, getUsageStats, scanUrl as apiScanUrl } from '../lib/api.js';
+import { scanTargetUrl } from '../lib/page-checks.js';
+import { checkTab, registerPageChecks } from './page-checks.js';
+
+registerPageChecks();
const ALARM_NAME = 'threatcrush-event-check';
const CHECK_INTERVAL_MINUTES = 5;
@@ -45,17 +50,14 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
*/
async function checkForEvents() {
try {
- // Get stored auth token
- const { authToken } = await chrome.storage.local.get(['authToken']);
-
- if (!authToken) {
+ if (!(await getAuthToken())) {
// Not logged in, clear badge
await updateBadge({ threats: 0, warnings: 0 });
return;
}
// Fetch real usage stats from the API
- const usage = await getUsageStats(authToken);
+ const usage = await getUsageStats();
const threats = usage.threats || 0;
const warnings = usage.warnings || 0;
@@ -100,13 +102,13 @@ async function updateBadge({ threats, warnings }) {
*/
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_STATS') {
- chrome.storage.local.get(['authToken']).then(async ({ authToken }) => {
- if (!authToken) {
+ getAuthToken().then(async (token) => {
+ if (!token) {
sendResponse({ threats: 0, warnings: 0, eventsToday: 0, modulesRunning: 0 });
return;
}
try {
- const usage = await getUsageStats(authToken);
+ const usage = await getUsageStats();
sendResponse({
threats: usage.threats || 0,
warnings: usage.warnings || 0,
@@ -126,6 +128,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
+ if (message.type === 'PAGE_CHECKS') {
+ checkTab(message.tabId)
+ .then(sendResponse)
+ .catch((err) => sendResponse({ supported: false, error: err?.message || String(err), checks: [] }));
+ return true;
+ }
+
if (message.type === 'SCAN_URL') {
scanUrl(message.url).then(sendResponse);
return true;
@@ -133,17 +142,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
/**
- * Scan a URL for security issues using the real API
+ * Server-side scan of a URL. Only runs when the user clicks "Scan with
+ * ThreatCrush". Only the origin and path are sent; the query string and
+ * fragment are dropped. The endpoint is public, so no sign-in is needed.
*/
async function scanUrl(url) {
- const { authToken } = await chrome.storage.local.get(['authToken']);
- if (!authToken) {
- return { url, status: 'unauthenticated', error: 'Not logged in' };
- }
try {
- return await apiScanUrl(url, authToken);
+ return await apiScanUrl(scanTargetUrl(url));
} catch (err) {
console.error('[ThreatCrush] Scan failed:', err);
- return { url, status: 'error', error: err.message };
+ return { url, error: err.message };
}
}
diff --git a/apps/extension/src/background/page-checks.js b/apps/extension/src/background/page-checks.js
new file mode 100644
index 00000000..270c59a4
--- /dev/null
+++ b/apps/extension/src/background/page-checks.js
@@ -0,0 +1,174 @@
+/**
+ * Local page checks: capture main-frame response headers, read cookie flags,
+ * inspect the DOM, and set a per-tab badge. Nothing here talks to a server.
+ *
+ * Site access comes from the optional host permissions (PAGE_ORIGINS), granted
+ * by the user from the popup or options page. Without it, clicking the toolbar
+ * button still grants activeTab, which is enough for the DOM checks.
+ */
+
+import {
+ PAGE_ORIGINS,
+ SECURITY_HEADER_NAMES,
+ badgeFor,
+ isCheckableUrl,
+ runPageChecks,
+} from '../lib/page-checks.js';
+import { collectPageSignals } from '../lib/collect-page.js';
+
+/** Captured documents kept per tab, newest last (covers prerender and back/forward). */
+const HISTORY_PER_TAB = 5;
+const KEEP_HEADERS = new Set(SECURITY_HEADER_NAMES);
+
+const NO_ACCESS = 'Needs site access. Enable page checks to read headers and cookies.';
+
+const headersKey = (tabId) => `pageHeaders:${tabId}`;
+
+function withoutHash(url) {
+ const u = new URL(url);
+ u.hash = '';
+ return u.href;
+}
+
+function sitePattern(url) {
+ const { protocol, hostname } = new URL(url);
+ return `${protocol}//${hostname}/*`;
+}
+
+export async function hasSiteAccess(url) {
+ try {
+ return await chrome.permissions.contains({ origins: [sitePattern(url)] });
+ } catch {
+ return false;
+ }
+}
+
+async function autoCheckEnabled() {
+ const { autoCheck } = await chrome.storage.local.get('autoCheck');
+ return autoCheck !== false;
+}
+
+/** Serialize read-modify-write of a tab's header history (redirect hops arrive back to back). */
+const pendingWrites = new Map();
+
+function onMainFrameHeaders(details) {
+ if (details.tabId < 0) return Promise.resolve();
+ const headers = (details.responseHeaders || [])
+ .filter((h) => KEEP_HEADERS.has(h.name.toLowerCase()))
+ .map((h) => ({ name: h.name, value: h.value ?? '' }));
+ const url = withoutHash(details.url);
+ const key = headersKey(details.tabId);
+ const write = async () => {
+ const stored = (await chrome.storage.session.get(key))[key] || [];
+ // A redirect hop is followed by the next response for the same tab, so
+ // the last entry for a URL is the document that actually rendered.
+ const next = [...stored.filter((e) => e.url !== url), { url, headers }].slice(-HISTORY_PER_TAB);
+ await chrome.storage.session.set({ [key]: next });
+ };
+ const done = (pendingWrites.get(key) || Promise.resolve()).then(write, write);
+ pendingWrites.set(key, done);
+ return done.finally(() => {
+ if (pendingWrites.get(key) === done) pendingWrites.delete(key);
+ });
+}
+
+/** Headers for the document now in the tab: exact URL, else latest same-origin. */
+async function capturedHeaders(tabId, url) {
+ const key = headersKey(tabId);
+ const stored = (await chrome.storage.session.get(key))[key] || [];
+ const target = withoutHash(url);
+ const exact = stored.findLast((e) => e.url === target);
+ if (exact) return exact.headers;
+ // Same-document navigations (pushState) change the URL, not the response.
+ const origin = new URL(url).origin;
+ return stored.findLast((e) => new URL(e.url).origin === origin)?.headers ?? null;
+}
+
+async function readCookies(url) {
+ let cookies;
+ try {
+ cookies = await chrome.cookies.getAll({ url });
+ } catch (err) {
+ // Firefox with first-party isolation requires firstPartyDomain.
+ if (!/firstPartyDomain/.test(String(err?.message))) throw err;
+ cookies = await chrome.cookies.getAll({ url, firstPartyDomain: null });
+ }
+ // Flags only. Cookie values never leave this function.
+ return cookies.map(({ name, secure, httpOnly, sameSite }) => ({ name, secure, httpOnly, sameSite }));
+}
+
+async function inspectPage(tabId) {
+ const [injection] = await chrome.scripting.executeScript({ target: { tabId }, func: collectPageSignals });
+ return injection?.result ?? null;
+}
+
+async function setTabBadge(tabId, summary) {
+ const { text, color } = badgeFor(summary);
+ await chrome.action.setBadgeText({ tabId, text });
+ await chrome.action.setBadgeBackgroundColor({ tabId, color });
+}
+
+/** Run every check against the tab's current page and update its badge. */
+export async function checkTab(tabId) {
+ const tab = await chrome.tabs.get(tabId);
+ const url = tab.url;
+ if (!url || !isCheckableUrl(url)) {
+ return { url: url || null, supported: false, siteAccess: false, checks: [] };
+ }
+
+ const siteAccess = await hasSiteAccess(url);
+ const unavailable = {};
+ const settle = async (fn, field) => {
+ try {
+ return await fn();
+ } catch (err) {
+ unavailable[field] = `Could not be read: ${err?.message || err}`;
+ return null;
+ }
+ };
+
+ const [headers, cookies, page] = await Promise.all([
+ siteAccess ? settle(() => capturedHeaders(tabId, url), 'headers') : null,
+ siteAccess ? settle(() => readCookies(url), 'cookies') : null,
+ settle(() => inspectPage(tabId), 'page'),
+ ]);
+ if (!siteAccess) {
+ unavailable.headers = NO_ACCESS;
+ unavailable.cookies = NO_ACCESS;
+ } else if (!headers && !unavailable.headers) {
+ unavailable.headers = 'Not captured for this page load. Reload the page to capture its response headers.';
+ }
+
+ const report = runPageChecks({ url, headers, cookies, page, unavailable });
+ report.siteAccess = siteAccess;
+ report.headersCaptured = Boolean(headers);
+ await setTabBadge(tabId, report.summary).catch(() => {});
+ return report;
+}
+
+async function onTabUpdated(tabId, changeInfo, tab) {
+ if (changeInfo.status === 'loading') {
+ // null falls back to the global (account) badge until the new page is checked.
+ await chrome.action.setBadgeText({ tabId, text: null }).catch(() => {});
+ return;
+ }
+ // tab.url is only visible here when we hold host access for the page.
+ if (changeInfo.status !== 'complete' || !tab.url || !isCheckableUrl(tab.url)) return;
+ if (!(await autoCheckEnabled()) || !(await hasSiteAccess(tab.url))) return;
+ await checkTab(tabId).catch((err) => console.warn('[ThreatCrush] Page check failed:', err));
+}
+
+/** Register listeners. Must run synchronously at worker start-up. */
+export function registerPageChecks() {
+ chrome.webRequest.onHeadersReceived.addListener(
+ (details) => {
+ onMainFrameHeaders(details).catch((err) => console.warn('[ThreatCrush] Header capture failed:', err));
+ },
+ { urls: [...PAGE_ORIGINS], types: ['main_frame'] },
+ ['responseHeaders']
+ );
+ chrome.tabs.onUpdated.addListener(onTabUpdated);
+ chrome.tabs.onRemoved.addListener((tabId) => {
+ chrome.storage.session.remove(headersKey(tabId)).catch(() => {});
+ });
+}
diff --git a/apps/extension/src/lib/api.js b/apps/extension/src/lib/api.js
index a115d9e1..542dea62 100644
--- a/apps/extension/src/lib/api.js
+++ b/apps/extension/src/lib/api.js
@@ -1,12 +1,16 @@
/**
* ThreatCrush API client for browser extension.
* All data routes through our API — never direct to Supabase/CoinPayPortal.
+ * Sign-in itself goes through the Supabase client (store/auth.js); requests
+ * here carry that session's access token.
*/
+import { supabase } from './supabase.js';
+
const API_URL = import.meta.env.VITE_APP_URL || 'https://threatcrush.com';
async function request(path, options = {}) {
- const token = await getToken();
+ const token = await getAuthToken();
const headers = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -26,10 +30,12 @@ async function request(path, options = {}) {
return res.json();
}
-async function getToken() {
+/** Access token of the signed-in Supabase session, or null. */
+export async function getAuthToken() {
+ if (!supabase) return null;
try {
- const { session } = await chrome.storage.local.get('session');
- return session?.access_token || null;
+ const { data } = await supabase.auth.getSession();
+ return data.session?.access_token || null;
} catch {
return null;
}
@@ -37,17 +43,6 @@ async function getToken() {
// ─── Auth ───
-export async function login(email, password) {
- const data = await request('/api/auth/login', {
- method: 'POST',
- body: JSON.stringify({ email, password }),
- });
- if (data.session) {
- await chrome.storage.local.set({ session: data.session, user: data.user });
- }
- return data;
-}
-
export async function signup(email, phone, password, displayName, referralCode) {
return request('/api/auth/signup', {
method: 'POST',
@@ -76,10 +71,6 @@ export async function checkVerification() {
return request('/api/auth/check');
}
-export async function logout() {
- await chrome.storage.local.remove(['session', 'user']);
-}
-
// ─── Usage ───
export async function getUsageStats() {
@@ -141,12 +132,11 @@ export async function scanCode(content, { filename, language } = {}) {
}
export default {
- login,
+ getAuthToken,
signup,
getProfile,
updateProfile,
checkVerification,
- logout,
getUsageStats,
topUpCredits,
getModules,
diff --git a/apps/extension/src/lib/collect-page.js b/apps/extension/src/lib/collect-page.js
new file mode 100644
index 00000000..5a1446a8
--- /dev/null
+++ b/apps/extension/src/lib/collect-page.js
@@ -0,0 +1,73 @@
+/**
+ * Runs inside the inspected page via chrome.scripting.executeScript({ func }).
+ *
+ * The function is serialized and injected on its own, so it must not reference
+ * anything outside its body. It returns only what the checks need: http://
+ * subresource URLs, form targets, and CSP/referrer values. No page
+ * text, input values or cookies.
+ */
+export function collectPageSignals() {
+ const MAX_RESOURCES = 200;
+ const resources = [];
+ const seen = new Set();
+ const add = (url, kind) => {
+ if (resources.length >= MAX_RESOURCES || typeof url !== 'string') return;
+ if (!/^http:/i.test(url)) return;
+ const key = `${kind} ${url}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ resources.push({ url, kind });
+ };
+
+ for (const entry of performance.getEntriesByType('resource')) {
+ add(entry.name, entry.initiatorType || 'other');
+ }
+
+ const refs = [
+ ['script[src]', 'script', 'src'],
+ ['link[rel~="stylesheet" i][href]', 'stylesheet', 'href'],
+ ['link[rel~="icon" i][href]', 'icon', 'href'],
+ ['iframe[src]', 'iframe', 'src'],
+ ['frame[src]', 'iframe', 'src'],
+ ['object[data]', 'object', 'data'],
+ ['embed[src]', 'embed', 'src'],
+ ['img', 'img', 'currentSrc'],
+ ['img[src]', 'img', 'src'],
+ ['audio[src], video[src]', 'media', 'src'],
+ ['source[src]', 'source', 'src'],
+ ['track[src]', 'track', 'src'],
+ ];
+ for (const [selector, kind, prop] of refs) {
+ for (const el of document.querySelectorAll(selector)) add(el[prop], kind);
+ }
+
+ const resolve = (value) => {
+ try {
+ return new URL(value || '', document.baseURI).href;
+ } catch {
+ return null;
+ }
+ };
+
+ const forms = [];
+ for (const form of document.querySelectorAll('form')) {
+ // getAttribute, not form.action: a field named "action" shadows the property.
+ const hasPassword = !!form.querySelector('input[type="password" i]');
+ forms.push({ action: resolve(form.getAttribute('action')), hasPassword });
+ for (const button of form.querySelectorAll('[formaction]')) {
+ forms.push({ action: resolve(button.getAttribute('formaction')), hasPassword });
+ }
+ }
+ const passwordOutsideForm = [...document.querySelectorAll('input[type="password" i]')].some(
+ (input) => !input.form
+ );
+
+ const metaCsp = [...document.querySelectorAll('meta[http-equiv]')]
+ .filter((m) => m.getAttribute('http-equiv').toLowerCase() === 'content-security-policy')
+ .map((m) => m.getAttribute('content') || '');
+ const metaReferrer =
+ [...document.querySelectorAll('meta[name]')].find((m) => m.getAttribute('name').toLowerCase() === 'referrer')?.getAttribute('content') ||
+ null;
+
+ return { resources, forms, passwordOutsideForm, metaCsp, metaReferrer };
+}
diff --git a/apps/extension/src/lib/page-checks.js b/apps/extension/src/lib/page-checks.js
new file mode 100644
index 00000000..86f067b3
--- /dev/null
+++ b/apps/extension/src/lib/page-checks.js
@@ -0,0 +1,610 @@
+/**
+ * Security checks for the page in the active tab.
+ *
+ * Everything here is pure: the background worker collects response headers,
+ * cookie flags and DOM signals and passes plain data in. Nothing in this module
+ * touches the network or a browser API, which is what keeps the checks local
+ * and testable.
+ *
+ * A check is `{ id, title, status, detail, items? }` where status is one of
+ * STATUS. `na` means the check does not apply (HSTS on plain HTTP); `unknown`
+ * means the data could not be collected (no site access, restricted page).
+ */
+
+export const STATUS = Object.freeze({
+ PASS: 'pass',
+ WARN: 'warn',
+ FAIL: 'fail',
+ NA: 'na',
+ UNKNOWN: 'unknown',
+});
+
+/** Host permissions requested (optionally, at first use) for headers and cookies. */
+export const PAGE_ORIGINS = Object.freeze(['http://*/*', 'https://*/*']);
+
+/** Response headers the background keeps. Everything else is dropped. */
+export const SECURITY_HEADER_NAMES = Object.freeze([
+ 'strict-transport-security',
+ 'content-security-policy',
+ 'content-security-policy-report-only',
+ 'x-frame-options',
+ 'x-content-type-options',
+ 'referrer-policy',
+ 'permissions-policy',
+ 'feature-policy',
+]);
+
+/** 180 days. Shorter HSTS lifetimes lapse between visits for many users. */
+export const HSTS_MIN_MAX_AGE = 15552000;
+
+const LOOPBACK_HOST = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/i;
+const MAX_ITEMS = 20;
+
+function check(id, title, status, detail, items) {
+ return items && items.length ? { id, title, status, detail, items } : { id, title, status, detail };
+}
+
+/**
+ * Normalise webRequest-style headers (`[{ name, value }]`) into a map of
+ * lower-cased name → list of values, preserving order.
+ */
+export function headerMap(headers) {
+ const map = new Map();
+ for (const { name, value } of headers || []) {
+ if (!name || value == null) continue;
+ const key = name.toLowerCase();
+ if (!map.has(key)) map.set(key, []);
+ map.get(key).push(String(value));
+ }
+ return map;
+}
+
+function first(map, name) {
+ const values = map.get(name);
+ return values && values.length ? values[0].trim() : null;
+}
+
+// ─── HTTPS / HSTS ───
+
+export function checkHttps(pageUrl) {
+ const url = new URL(pageUrl);
+ if (url.protocol === 'https:') {
+ return check('https', 'HTTPS', STATUS.PASS, 'Page is served over HTTPS.');
+ }
+ if (LOOPBACK_HOST.test(url.hostname)) {
+ return check(
+ 'https',
+ 'HTTPS',
+ STATUS.WARN,
+ 'Plain HTTP on a loopback address. Fine for local development, never for a public site.'
+ );
+ }
+ return check(
+ 'https',
+ 'HTTPS',
+ STATUS.FAIL,
+ 'Page is served over plain HTTP. Anyone on the network path can read or change it.'
+ );
+}
+
+export function checkHsts(pageUrl, headers) {
+ const title = 'Strict-Transport-Security';
+ if (new URL(pageUrl).protocol !== 'https:') {
+ return check('hsts', title, STATUS.NA, 'Only applies to HTTPS pages; browsers ignore it over HTTP.');
+ }
+ // Browsers honour the first HSTS header only.
+ const value = first(headers, 'strict-transport-security');
+ if (!value) {
+ return check(
+ 'hsts',
+ title,
+ STATUS.FAIL,
+ 'Missing. The first visit, and any http:// link, can be downgraded to plain HTTP.'
+ );
+ }
+ const match = /(?:^|;)\s*max-age\s*=\s*"?(\d+)"?/i.exec(value);
+ if (!match) {
+ return check('hsts', title, STATUS.FAIL, 'Header has no valid max-age, so browsers ignore it.');
+ }
+ const maxAge = Number(match[1]);
+ if (maxAge === 0) {
+ return check('hsts', title, STATUS.FAIL, 'max-age=0 tells browsers to forget HSTS for this host.');
+ }
+ const days = Math.floor(maxAge / 86400);
+ if (maxAge < HSTS_MIN_MAX_AGE) {
+ const lifetime = days >= 1 ? `${days} day${days === 1 ? '' : 's'}` : `${maxAge} seconds`;
+ return check(
+ 'hsts',
+ title,
+ STATUS.WARN,
+ `max-age is ${lifetime}. Use at least 180 days; one year is the usual value.`
+ );
+ }
+ const extras = [
+ /(?:^|;)\s*includesubdomains\s*(?:;|$)/i.test(value) && 'includeSubDomains',
+ /(?:^|;)\s*preload\s*(?:;|$)/i.test(value) && 'preload',
+ ].filter(Boolean);
+ return check(
+ 'hsts',
+ title,
+ STATUS.PASS,
+ `max-age ${days} days${extras.length ? `, ${extras.join(', ')}` : ''}.`
+ );
+}
+
+// ─── CSP ───
+
+/** Parse one serialized policy into directive name → lower-cased source list. */
+export function parseCsp(policy) {
+ const directives = new Map();
+ for (const part of String(policy).split(';')) {
+ const tokens = part.trim().split(/\s+/).filter(Boolean);
+ if (!tokens.length) continue;
+ const name = tokens[0].toLowerCase();
+ // Per spec, a repeated directive is ignored; the first one wins.
+ if (!directives.has(name)) directives.set(name, tokens.slice(1).map((t) => t.toLowerCase()));
+ }
+ return directives;
+}
+
+const NONCE_OR_HASH = /^'(nonce-|sha256-|sha384-|sha512-)/;
+const ANY_ORIGIN = new Set(['*', 'http:', 'https:', 'http://*', 'https://*']);
+
+/**
+ * Weaknesses in one policy's script controls. `restricted: false` means the
+ * policy has neither script-src nor default-src and so says nothing about
+ * scripts.
+ */
+export function analyzeCsp(policy) {
+ const directives = parseCsp(policy);
+ const directive = directives.has('script-src') ? 'script-src' : directives.has('default-src') ? 'default-src' : null;
+ if (!directive) return { restricted: false, issues: [], directives };
+
+ const sources = directives.get(directive);
+ const strictDynamic = sources.includes("'strict-dynamic'");
+ const issues = [];
+
+ // CSP2+ browsers ignore 'unsafe-inline' when a nonce or hash is present.
+ if (sources.includes("'unsafe-inline'") && !sources.some((s) => NONCE_OR_HASH.test(s))) {
+ issues.push({
+ code: 'unsafe-inline',
+ severity: STATUS.FAIL,
+ message: `${directive} allows 'unsafe-inline', so injected inline scripts run.`,
+ });
+ }
+ if (sources.includes("'unsafe-eval'")) {
+ issues.push({
+ code: 'unsafe-eval',
+ severity: STATUS.WARN,
+ message: `${directive} allows 'unsafe-eval' (eval, new Function, string timers).`,
+ });
+ }
+ // With 'strict-dynamic', CSP3 browsers ignore host and scheme sources; they
+ // are only there as a fallback for old browsers.
+ if (!strictDynamic) {
+ const wide = sources.filter((s) => ANY_ORIGIN.has(s));
+ if (wide.length) {
+ issues.push({
+ code: 'any-origin',
+ severity: STATUS.FAIL,
+ message: `${directive} allows scripts from any origin (${wide.join(' ')}).`,
+ });
+ }
+ if (sources.includes('data:')) {
+ issues.push({
+ code: 'data-scripts',
+ severity: STATUS.FAIL,
+ message: `${directive} allows data: URLs as scripts.`,
+ });
+ }
+ }
+ return { restricted: true, issues, directives };
+}
+
+/** Split header values into individual policies (a comma joins policies). */
+function policiesOf(values) {
+ return (values || [])
+ .flatMap((v) => v.split(','))
+ .map((p) => p.trim())
+ .filter(Boolean);
+}
+
+/**
+ * Every enforced policy applies independently, so a weakness is real only when
+ * every policy that restricts scripts has it. Policies that don't mention
+ * scripts at all (e.g. only frame-ancestors) don't loosen anything.
+ */
+function combineCsp(policies) {
+ const analyses = policies.map(analyzeCsp).filter((a) => a.restricted);
+ if (!analyses.length) return null;
+ const [head, ...rest] = analyses;
+ return head.issues.filter((issue) => rest.every((a) => a.issues.some((i) => i.code === issue.code)));
+}
+
+export function checkCsp(headers, metaPolicies = []) {
+ const title = 'Content-Security-Policy';
+ let policies = policiesOf(headers.get('content-security-policy'));
+ let source = 'header';
+ if (!policies.length && metaPolicies.length) {
+ policies = metaPolicies.map((p) => p.trim()).filter(Boolean);
+ source = ' tag';
+ }
+
+ if (!policies.length) {
+ if (policiesOf(headers.get('content-security-policy-report-only')).length) {
+ return check('csp', title, STATUS.WARN, 'Only Content-Security-Policy-Report-Only is set; nothing is enforced.');
+ }
+ return check('csp', title, STATUS.WARN, 'Missing. Nothing limits where scripts can load from if XSS lands.');
+ }
+
+ const issues = combineCsp(policies);
+ if (issues === null) {
+ return check(
+ 'csp',
+ title,
+ STATUS.WARN,
+ `Policy (${source}) has no script-src or default-src, so scripts are unrestricted.`
+ );
+ }
+ if (!issues.length) {
+ return check('csp', title, STATUS.PASS, `Enforced via ${source}; script sources are restricted.`);
+ }
+ const status = issues.some((i) => i.severity === STATUS.FAIL) ? STATUS.FAIL : STATUS.WARN;
+ return check(
+ 'csp',
+ title,
+ status,
+ `Enforced via ${source}, but weak.`,
+ issues.map((i) => i.message)
+ );
+}
+
+// ─── Framing ───
+
+export function checkFraming(headers) {
+ const title = 'Clickjacking protection';
+ // frame-ancestors is ignored in policies, so only headers count.
+ const ancestors = policiesOf(headers.get('content-security-policy'))
+ .map((p) => parseCsp(p).get('frame-ancestors'))
+ .filter(Boolean);
+ if (ancestors.length) {
+ if (ancestors.some((list) => !list.some((s) => ANY_ORIGIN.has(s)))) {
+ return check('framing', title, STATUS.PASS, 'CSP frame-ancestors restricts who can frame this page.');
+ }
+ return check('framing', title, STATUS.FAIL, 'CSP frame-ancestors lets any site frame this page.');
+ }
+
+ const xfo = first(headers, 'x-frame-options');
+ if (!xfo) {
+ return check(
+ 'framing',
+ title,
+ STATUS.WARN,
+ 'No X-Frame-Options or CSP frame-ancestors. Any site can frame this page.'
+ );
+ }
+ const value = xfo.toUpperCase();
+ if (value === 'DENY' || value === 'SAMEORIGIN') {
+ return check('framing', title, STATUS.PASS, `X-Frame-Options: ${value}.`);
+ }
+ if (value.startsWith('ALLOW-FROM')) {
+ return check(
+ 'framing',
+ title,
+ STATUS.WARN,
+ 'X-Frame-Options ALLOW-FROM is obsolete and ignored by current browsers. Use CSP frame-ancestors.'
+ );
+ }
+ return check('framing', title, STATUS.WARN, `X-Frame-Options has an invalid value (${xfo}).`);
+}
+
+// ─── Simple headers ───
+
+export function checkNosniff(headers) {
+ const title = 'X-Content-Type-Options';
+ const value = first(headers, 'x-content-type-options');
+ if (!value) return check('nosniff', title, STATUS.WARN, 'Missing. Browsers may MIME-sniff responses.');
+ if (value.toLowerCase() === 'nosniff') return check('nosniff', title, STATUS.PASS, 'nosniff.');
+ return check('nosniff', title, STATUS.WARN, `Invalid value (${value}); only "nosniff" is recognised.`);
+}
+
+const REFERRER_POLICIES = new Set([
+ 'no-referrer',
+ 'no-referrer-when-downgrade',
+ 'origin',
+ 'origin-when-cross-origin',
+ 'same-origin',
+ 'strict-origin',
+ 'strict-origin-when-cross-origin',
+ 'unsafe-url',
+]);
+
+export function checkReferrerPolicy(headers, metaReferrer = null) {
+ const title = 'Referrer-Policy';
+ const raw = [...(headers.get('referrer-policy') || []), ...(metaReferrer ? [metaReferrer] : [])];
+ // The last recognised token wins; unknown tokens are fallbacks for old browsers.
+ const policy = raw
+ .flatMap((v) => v.split(','))
+ .map((t) => t.trim().toLowerCase())
+ .filter((t) => REFERRER_POLICIES.has(t))
+ .pop();
+ if (!policy) {
+ return check(
+ 'referrer',
+ title,
+ STATUS.WARN,
+ 'Not set. Current browsers default to strict-origin-when-cross-origin; older ones leak full URLs.'
+ );
+ }
+ if (policy === 'unsafe-url') {
+ return check('referrer', title, STATUS.FAIL, 'unsafe-url sends the full URL, query string included, to every site.');
+ }
+ if (policy === 'no-referrer-when-downgrade') {
+ return check('referrer', title, STATUS.WARN, 'no-referrer-when-downgrade sends the full URL to other HTTPS sites.');
+ }
+ return check('referrer', title, STATUS.PASS, `${policy}.`);
+}
+
+export function checkPermissionsPolicy(headers) {
+ const title = 'Permissions-Policy';
+ if (first(headers, 'permissions-policy')) {
+ return check('permissions', title, STATUS.PASS, 'Set.');
+ }
+ if (first(headers, 'feature-policy')) {
+ return check('permissions', title, STATUS.WARN, 'Only the legacy Feature-Policy header is set.');
+ }
+ return check(
+ 'permissions',
+ title,
+ STATUS.WARN,
+ 'Missing. Embedded content can ask for camera, microphone, geolocation and similar features.'
+ );
+}
+
+// ─── Mixed content ───
+
+/** Kinds the browser treats as passive (display) content. */
+const PASSIVE_KINDS = new Set(['img', 'image', 'audio', 'video', 'media', 'source', 'track', 'icon']);
+
+/**
+ * `resources` is `[{ url, kind }]` from the page: Resource Timing entries
+ * (kind = initiatorType) and DOM references (kind = element type).
+ */
+export function checkMixedContent(pageUrl, resources) {
+ const title = 'Mixed content';
+ if (new URL(pageUrl).protocol !== 'https:') {
+ return check('mixed', title, STATUS.NA, 'Only applies to HTTPS pages.');
+ }
+ const seen = new Map();
+ for (const { url, kind } of resources || []) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ continue;
+ }
+ if (parsed.protocol !== 'http:') continue;
+ const key = parsed.href;
+ const passive = PASSIVE_KINDS.has(String(kind).toLowerCase());
+ const prior = seen.get(key);
+ // If any reference to the URL is active, count it as active.
+ if (!prior || (prior.passive && !passive)) seen.set(key, { url: key, kind, passive });
+ }
+ if (!seen.size) {
+ return check('mixed', title, STATUS.PASS, 'No http:// subresources found.');
+ }
+ const found = [...seen.values()];
+ const active = found.filter((r) => !r.passive);
+ const items = [...active, ...found.filter((r) => r.passive)]
+ .slice(0, MAX_ITEMS)
+ .map((r) => `${r.kind}: ${r.url}`);
+ if (active.length) {
+ return check(
+ 'mixed',
+ title,
+ STATUS.FAIL,
+ `${active.length} active http:// resource${active.length === 1 ? '' : 's'} (scripts, frames, styles or requests). Browsers block these; if not, they can rewrite the page.`,
+ items
+ );
+ }
+ return check(
+ 'mixed',
+ title,
+ STATUS.WARN,
+ `${found.length} passive http:// resource${found.length === 1 ? '' : 's'} (images or media).`,
+ items
+ );
+}
+
+// ─── Forms ───
+
+function safeUrl(value, base) {
+ try {
+ return new URL(value, base);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * `forms` is `[{ action, hasPassword }]` with `action` resolved against the
+ * document; `passwordOutsideForm` covers password inputs not in any form.
+ */
+export function checkForms(pageUrl, { forms = [], passwordOutsideForm = false } = {}) {
+ const title = 'Forms';
+ const page = new URL(pageUrl);
+ const pageIsHttps = page.protocol === 'https:';
+ const hasPassword = passwordOutsideForm || forms.some((f) => f.hasPassword);
+
+ if (!pageIsHttps && hasPassword) {
+ return check('forms', title, STATUS.FAIL, 'Password field on a page served over plain HTTP.');
+ }
+
+ const insecure = forms
+ .map((f) => ({ ...f, target: safeUrl(f.action || pageUrl, pageUrl) }))
+ .filter((f) => f.target && f.target.protocol === 'http:');
+
+ if (pageIsHttps && insecure.length) {
+ const items = [...new Set(insecure.map((f) => `${f.target.origin}${f.target.pathname}`))].slice(0, MAX_ITEMS);
+ const withPassword = insecure.some((f) => f.hasPassword);
+ return check(
+ 'forms',
+ title,
+ STATUS.FAIL,
+ `${insecure.length} form${insecure.length === 1 ? '' : 's'} submit${insecure.length === 1 ? 's' : ''} to http://${withPassword ? ', including a password' : ''}.`,
+ items
+ );
+ }
+ if (!pageIsHttps && insecure.length) {
+ return check(
+ 'forms',
+ title,
+ STATUS.WARN,
+ `${insecure.length} form${insecure.length === 1 ? '' : 's'} submit${insecure.length === 1 ? 's' : ''} over plain HTTP.`
+ );
+ }
+ if (!forms.length && !hasPassword) {
+ return check('forms', title, STATUS.PASS, 'No forms on this page.');
+ }
+ return check('forms', title, STATUS.PASS, 'Forms submit over HTTPS.');
+}
+
+// ─── Cookies ───
+
+const SESSION_NAME = /sess|sid|auth|token|jwt|login|remember|identity|account|user|^id$/i;
+const CSRF_NAME = /csrf|xsrf/i;
+
+/** Heuristic: does the cookie name look like it carries a login session? */
+export function isLikelySessionCookie(name) {
+ const bare = String(name).replace(/^__(Host|Secure)-/i, '');
+ return SESSION_NAME.test(bare) || CSRF_NAME.test(bare);
+}
+
+/**
+ * `cookies` carries flags only: `[{ name, secure, httpOnly, sameSite }]` with
+ * sameSite in chrome.cookies terms (no_restriction | lax | strict |
+ * unspecified). Values never reach this function.
+ */
+export function checkCookies(pageUrl, cookies) {
+ const title = 'Cookies';
+ const list = cookies || [];
+ if (!list.length) return check('cookies', title, STATUS.PASS, 'No cookies for this site.');
+
+ const pageIsHttps = new URL(pageUrl).protocol === 'https:';
+ const sessionLike = list.filter((c) => isLikelySessionCookie(c.name));
+ let failed = false;
+ const items = [];
+
+ for (const c of sessionLike) {
+ const problems = [];
+ if (!c.secure) {
+ problems.push('no Secure');
+ if (pageIsHttps) failed = true;
+ }
+ // CSRF tokens are meant to be read by page scripts.
+ if (!c.httpOnly && !CSRF_NAME.test(c.name)) problems.push('no HttpOnly');
+ if (c.sameSite === 'no_restriction') problems.push('SameSite=None');
+ else if (!c.sameSite || c.sameSite === 'unspecified') problems.push('no SameSite');
+ if (problems.length) items.push(`${c.name}: ${problems.join(', ')}`);
+ }
+
+ const summary = `${list.length} cookie${list.length === 1 ? '' : 's'}, ${sessionLike.length} look${sessionLike.length === 1 ? 's' : ''} like session cookies`;
+ if (!items.length) return check('cookies', title, STATUS.PASS, `${summary}; all flagged Secure, HttpOnly and SameSite.`);
+ return check(
+ 'cookies',
+ title,
+ failed ? STATUS.FAIL : STATUS.WARN,
+ `${summary}; ${items.length} missing protections.`,
+ items.slice(0, MAX_ITEMS)
+ );
+}
+
+// ─── Report ───
+
+const HEADER_CHECKS = [
+ ['hsts', 'Strict-Transport-Security'],
+ ['csp', 'Content-Security-Policy'],
+ ['framing', 'Clickjacking protection'],
+ ['nosniff', 'X-Content-Type-Options'],
+ ['referrer', 'Referrer-Policy'],
+ ['permissions', 'Permissions-Policy'],
+];
+
+export function summarize(checks) {
+ const summary = { pass: 0, warn: 0, fail: 0 };
+ for (const c of checks) if (c.status in summary) summary[c.status] += 1;
+ summary.status = summary.fail ? STATUS.FAIL : summary.warn ? STATUS.WARN : STATUS.PASS;
+ return summary;
+}
+
+/** Badge text/colour for a summary: failures first, then warnings. */
+export function badgeFor(summary) {
+ if (summary.fail) return { text: String(summary.fail), color: '#ef4444' };
+ if (summary.warn) return { text: String(summary.warn), color: '#f59e0b' };
+ return { text: '✓', color: '#00c853' };
+}
+
+export function isCheckableUrl(url) {
+ try {
+ const { protocol } = new URL(url);
+ return protocol === 'http:' || protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * What "Scan with ThreatCrush" sends: origin and path only. Query strings and
+ * fragments often carry tokens, and the header scan doesn't need them.
+ */
+export function scanTargetUrl(url) {
+ const u = new URL(url);
+ return `${u.origin}${u.pathname}`;
+}
+
+/**
+ * Build the full report. Pass `null` for any input that couldn't be collected
+ * and say why in `unavailable` ({ headers, cookies, page } → reason).
+ *
+ * @param {object} input
+ * @param {string} input.url
+ * @param {Array<{name: string, value: string}>|null} input.headers
+ * @param {Array