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|null} input.cookies + * @param {object|null} input.page Output of collectPageSignals() + * @param {Record} [input.unavailable] + */ +export function runPageChecks({ url, headers, cookies, page, unavailable = {} }) { + if (!isCheckableUrl(url)) { + return { url, supported: false, checks: [], summary: summarize([]) }; + } + const unknown = (id, title, reason) => check(id, title, STATUS.UNKNOWN, reason); + const checks = [checkHttps(url)]; + + if (headers) { + const map = headerMap(headers); + checks.push( + checkHsts(url, map), + checkCsp(map, page?.metaCsp || []), + checkFraming(map), + checkNosniff(map), + checkReferrerPolicy(map, page?.metaReferrer || null), + checkPermissionsPolicy(map) + ); + } else { + const reason = unavailable.headers || 'Response headers were not captured.'; + for (const [id, title] of HEADER_CHECKS) checks.push(unknown(id, title, reason)); + } + + if (page) { + checks.push(checkMixedContent(url, page.resources), checkForms(url, page)); + } else { + const reason = unavailable.page || 'The page could not be inspected.'; + checks.push(unknown('mixed', 'Mixed content', reason), unknown('forms', 'Forms', reason)); + } + + checks.push( + cookies ? checkCookies(url, cookies) : unknown('cookies', 'Cookies', unavailable.cookies || 'Cookies could not be read.') + ); + + return { url, supported: true, checks, summary: summarize(checks) }; +} diff --git a/apps/extension/src/lib/supabase.js b/apps/extension/src/lib/supabase.js index e9d11da6..c093c208 100644 --- a/apps/extension/src/lib/supabase.js +++ b/apps/extension/src/lib/supabase.js @@ -3,33 +3,37 @@ import { createClient } from '@supabase/supabase-js'; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || ''; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || ''; -export const supabase = createClient(supabaseUrl, supabaseAnonKey, { - auth: { - // Use chrome.storage for session persistence in extensions - storage: { - getItem: async (key) => { - if (typeof chrome !== 'undefined' && chrome.storage) { - const result = await chrome.storage.local.get(key); - return result[key] || null; - } - return localStorage.getItem(key); - }, - setItem: async (key, value) => { - if (typeof chrome !== 'undefined' && chrome.storage) { - await chrome.storage.local.set({ [key]: value }); - } else { - localStorage.setItem(key, value); - } - }, - removeItem: async (key) => { - if (typeof chrome !== 'undefined' && chrome.storage) { - await chrome.storage.local.remove(key); - } else { - localStorage.removeItem(key); - } - }, - }, - autoRefreshToken: true, - persistSession: true, +// Session persistence in chrome.storage so the popup and the background worker +// see the same sign-in. +const chromeStorage = { + getItem: async (key) => { + if (typeof chrome !== 'undefined' && chrome.storage) { + const result = await chrome.storage.local.get(key); + return result[key] || null; + } + return localStorage.getItem(key); }, -}); + setItem: async (key, value) => { + if (typeof chrome !== 'undefined' && chrome.storage) { + await chrome.storage.local.set({ [key]: value }); + } else { + localStorage.setItem(key, value); + } + }, + removeItem: async (key) => { + if (typeof chrome !== 'undefined' && chrome.storage) { + await chrome.storage.local.remove(key); + } else { + localStorage.removeItem(key); + } + }, +}; + +// null when the build had no Supabase config (createClient throws on an empty +// URL). Page checks keep working; sign-in reports that it isn't configured. +export const supabase = + supabaseUrl && supabaseAnonKey + ? createClient(supabaseUrl, supabaseAnonKey, { + auth: { storage: chromeStorage, autoRefreshToken: true, persistSession: true }, + }) + : null; diff --git a/apps/extension/src/manifest.chrome.json b/apps/extension/src/manifest.chrome.json index 059b3c6d..bf3205bc 100644 --- a/apps/extension/src/manifest.chrome.json +++ b/apps/extension/src/manifest.chrome.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "ThreatCrush", "version": "0.1.10", - "description": "Real-time security monitoring, threat detection, and site scanning from your browser", + "description": "Checks the page you are on for weak security headers, cookies, forms and mixed content, locally. Plus ThreatCrush account alerts.", "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", @@ -23,8 +23,16 @@ "type": "module" }, "options_page": "options/index.html", - "permissions": ["storage", "alarms", "notifications"], - "optional_permissions": ["tabs", "activeTab"], + "permissions": [ + "storage", + "alarms", + "notifications", + "activeTab", + "scripting", + "webRequest", + "cookies" + ], + "optional_host_permissions": ["http://*/*", "https://*/*"], "host_permissions": [ "https://threatcrush.com/*", "https://*.supabase.co/*", diff --git a/apps/extension/src/manifest.firefox.json b/apps/extension/src/manifest.firefox.json index b94d54b5..47a817b8 100644 --- a/apps/extension/src/manifest.firefox.json +++ b/apps/extension/src/manifest.firefox.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "ThreatCrush", "version": "0.1.10", - "description": "Real-time security monitoring, threat detection, and site scanning from your browser", + "description": "Checks the page you are on for weak security headers, cookies, forms and mixed content, locally. Plus ThreatCrush account alerts.", "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", @@ -26,8 +26,16 @@ "page": "options/index.html", "open_in_tab": true }, - "permissions": ["storage", "alarms", "notifications"], - "optional_permissions": ["tabs", "activeTab"], + "permissions": [ + "storage", + "alarms", + "notifications", + "activeTab", + "scripting", + "webRequest", + "cookies" + ], + "optional_host_permissions": ["http://*/*", "https://*/*"], "host_permissions": [ "https://threatcrush.com/*", "https://*.supabase.co/*", diff --git a/apps/extension/src/manifest.safari.json b/apps/extension/src/manifest.safari.json index 2549ebe7..3cb37d65 100644 --- a/apps/extension/src/manifest.safari.json +++ b/apps/extension/src/manifest.safari.json @@ -23,8 +23,16 @@ "type": "module" }, "options_page": "options/index.html", - "permissions": ["storage", "alarms", "notifications"], - "optional_permissions": ["tabs", "activeTab"], + "permissions": [ + "storage", + "alarms", + "notifications", + "activeTab", + "scripting", + "webRequest", + "cookies" + ], + "optional_host_permissions": ["http://*/*", "https://*/*"], "host_permissions": [ "https://threatcrush.com/*", "https://*.supabase.co/*" diff --git a/apps/extension/src/options/App.jsx b/apps/extension/src/options/App.jsx index 04cb9259..d9cae7a9 100644 --- a/apps/extension/src/options/App.jsx +++ b/apps/extension/src/options/App.jsx @@ -1,10 +1,14 @@ import React, { useState, useEffect } from 'react'; +import { PAGE_ORIGINS } from '../lib/page-checks.js'; + +// The scan button posts to the URL baked in at build time (see lib/api.js). +const SCAN_API_URL = import.meta.env.VITE_APP_URL || 'https://threatcrush.com'; const DEFAULT_SETTINGS = { serverUrl: 'https://threatcrush.com', licenseKey: '', notificationsEnabled: true, - autoScan: false, + autoCheck: true, scanInterval: 5, }; @@ -25,14 +29,24 @@ function normalizeScanInterval(value) { export default function App() { const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [saved, setSaved] = useState(false); + const [siteAccess, setSiteAccess] = useState(false); useEffect(() => { // Load settings from storage chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS), (stored) => { setSettings({ ...DEFAULT_SETTINGS, ...stored }); }); + chrome.permissions.contains({ origins: [...PAGE_ORIGINS] }).then(setSiteAccess); }, []); + function toggleSiteAccess() { + // Permission requests must happen directly in the click handler (Firefox). + const change = siteAccess + ? chrome.permissions.remove({ origins: [...PAGE_ORIGINS] }).then((removed) => !removed) + : chrome.permissions.request({ origins: [...PAGE_ORIGINS] }); + change.then(setSiteAccess); + } + async function handleSave(e) { e.preventDefault(); const nextSettings = { @@ -95,37 +109,76 @@ export default function App() { - {/* Notification Settings */} + {/* Page checks */}
-

Notifications

- - +

Page checks

+ +
+
+
+ Site access{' '} + + {siteAccess ? 'granted' : 'not granted'} + +

+ Needed to read a page's response headers and cookie flags. Without it, only the HTTPS, form and + mixed-content checks run, and only when you open the popup. +

+
+ +
+ + +
- {/* Auto-Scan Settings */} + {/* Privacy */} +
+

Privacy

+
+

+ Page checks run entirely in your browser. The extension reads the page's security headers, cookie + names and flags (never cookie values), form targets and subresource URLs, and keeps them only for the + open tab. Nothing about the pages you visit is sent to ThreatCrush or anyone else. +

+

+ The one exception is the Scan with ThreatCrush button in the + popup. When you click it, the page's address without its query string or fragment is sent to{' '} + {SCAN_API_URL}, whose servers fetch that page and grade its headers. +

+
+
+ + {/* Notification Settings */}
-

Auto-Scan

+

Account alerts

diff --git a/apps/extension/src/popup/App.jsx b/apps/extension/src/popup/App.jsx index ede55fc2..206e6d68 100644 --- a/apps/extension/src/popup/App.jsx +++ b/apps/extension/src/popup/App.jsx @@ -1,19 +1,16 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useAuthStore } from '../store/auth'; import { useEventsStore } from '../store/events'; import StatusBadge from './components/StatusBadge'; import QuickActions from './components/QuickActions'; import EventFeed from './components/EventFeed'; import LoginForm from './components/LoginForm'; +import PageChecks from './components/PageChecks'; -export default function App() { - const { user, loading: authLoading, initialize } = useAuthStore(); +function Account() { + const { user, loading: authLoading } = useAuthStore(); const { stats, fetchStats } = useEventsStore(); - useEffect(() => { - initialize(); - }, [initialize]); - useEffect(() => { if (user) { fetchStats(); @@ -22,7 +19,7 @@ export default function App() { if (authLoading) { return ( -
+
); @@ -33,22 +30,15 @@ export default function App() { } return ( -
- {/* Header */} -
-
- ⛨ - ThreatCrush -
-
- {user.email} - -
+
+
+ {user.email} +
{/* Status Badge */} @@ -78,3 +68,46 @@ export default function App() {
); } + +const VIEWS = [ + ['page', 'This page'], + ['account', 'Account'], +]; + +export default function App() { + const [view, setView] = useState('page'); + const initialize = useAuthStore((state) => state.initialize); + + useEffect(() => { + initialize(); + }, [initialize]); + + return ( +
+ {/* Header */} +
+
+ ⛨ + ThreatCrush +
+
+ {VIEWS.map(([id, label]) => ( + + ))} +
+
+ + {view === 'page' ? : } +
+ ); +} diff --git a/apps/extension/src/popup/components/LoginForm.jsx b/apps/extension/src/popup/components/LoginForm.jsx index bb1de024..9f35f475 100644 --- a/apps/extension/src/popup/components/LoginForm.jsx +++ b/apps/extension/src/popup/components/LoginForm.jsx @@ -14,7 +14,7 @@ export default function LoginForm() { } return ( -
+
{/* Logo */}
⛨
diff --git a/apps/extension/src/popup/components/PageChecks.jsx b/apps/extension/src/popup/components/PageChecks.jsx new file mode 100644 index 00000000..824866d5 --- /dev/null +++ b/apps/extension/src/popup/components/PageChecks.jsx @@ -0,0 +1,230 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { PAGE_ORIGINS, scanTargetUrl } from '../../lib/page-checks.js'; + +const STATUS_STYLE = { + pass: { icon: '✓', label: 'Pass', className: 'text-[#00ff41] border-[#00ff41]/40' }, + warn: { icon: '!', label: 'Warn', className: 'text-yellow-400 border-yellow-400/40' }, + fail: { icon: '✗', label: 'Fail', className: 'text-red-400 border-red-400/40' }, + na: { icon: '–', label: 'N/A', className: 'text-gray-500 border-gray-600' }, + unknown: { icon: '?', label: 'Unknown', className: 'text-gray-500 border-gray-600' }, +}; + +function hostOf(url) { + try { + return new URL(url).host; + } catch { + return url || ''; + } +} + +const API_HOST = hostOf(import.meta.env.VITE_APP_URL || 'https://threatcrush.com'); + +function CheckRow({ check }) { + const style = STATUS_STYLE[check.status] || STATUS_STYLE.unknown; + return ( +
  • + + {style.icon} + +
    +
    + {check.title} + {style.label} +
    +
    {check.detail}
    + {check.items && ( +
      + {check.items.map((item) => ( +
    • + {item} +
    • + ))} +
    + )} +
    +
  • + ); +} + +function ServerScanResult({ result }) { + if (result.error) return
    {result.error}
    ; + return ( +
    +
    + Grade {result.grade} · Score{' '} + {result.score}/100 +
    + {(result.headers || []).map((h) => ( +
    + {h.name} + {h.present ? 'present' : 'missing'} +
    + ))} + {result.checks && ( +
    + security.txt {result.checks.security_txt ? 'found' : 'missing'} · robots.txt{' '} + {result.checks.robots_txt ? 'found' : 'missing'} +
    + )} +
    + ); +} + +export default function PageChecks() { + const [tabId, setTabId] = useState(null); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [scan, setScan] = useState(null); + + const run = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [active] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!active) throw new Error('No active tab.'); + setTabId(active.id); + setReport(await chrome.runtime.sendMessage({ type: 'PAGE_CHECKS', tabId: active.id })); + } catch (err) { + setError(err?.message || String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + run(); + }, [run]); + + // Re-check once the tab finishes (re)loading, e.g. after "Reload page". + useEffect(() => { + if (tabId == null) return undefined; + const onUpdated = (id, info) => { + if (id === tabId && info.status === 'complete') run(); + }; + chrome.tabs.onUpdated.addListener(onUpdated); + return () => chrome.tabs.onUpdated.removeListener(onUpdated); + }, [tabId, run]); + + function enableSiteAccess() { + // Firefox only allows permission requests synchronously inside a user + // action, so this must be the first call in the click handler. + chrome.permissions + .request({ origins: [...PAGE_ORIGINS] }) + .then((granted) => granted && run()) + .catch((err) => setError(err?.message || String(err))); + } + + async function scanWithThreatCrush() { + setScan({ running: true }); + try { + const result = await chrome.runtime.sendMessage({ type: 'SCAN_URL', url: report.url }); + setScan({ running: false, result }); + } catch (err) { + setScan({ running: false, result: { error: err?.message || String(err) } }); + } + } + + if (loading && !report) { + return
    Checking this page…
    ; + } + if (error || report?.error) { + return
    {error || report.error}
    ; + } + if (report && !report.url) { + // No activeTab grant and no host access, so the tab's URL is hidden from us. + return ( +
    +

    ThreatCrush can't see this tab. Open the popup from the toolbar button, or allow site access.

    + +
    + ); + } + if (!report?.supported) { + return ( +
    + ThreatCrush checks http:// and https:// pages. Open a website and click the toolbar button again. +
    + ); + } + + const { summary } = report; + const target = scanTargetUrl(report.url); + + return ( +
    +
    +
    +
    This page
    +
    {hostOf(report.url)}
    +
    +
    + {summary.fail} fail + {summary.warn} warn + {summary.pass} pass +
    +
    + + {!report.siteAccess && ( +
    +

    + Header and cookie checks need access to the sites you visit. The checks run in your browser; nothing + about the page is sent anywhere. +

    + +
    + )} + + {report.siteAccess && !report.headersCaptured && ( +
    +

    Response headers weren't captured for this page load.

    + +
    + )} + +
      + {report.checks.map((check) => ( + + ))} +
    + +
    + +

    + Sends {target} (no query string) to {API_HOST}, + which fetches it and grades the response headers. +

    + {scan?.result && ( +
    + +
    + )} +
    +
    + ); +} diff --git a/apps/extension/src/popup/components/QuickActions.jsx b/apps/extension/src/popup/components/QuickActions.jsx index d93b79b7..a60d80c6 100644 --- a/apps/extension/src/popup/components/QuickActions.jsx +++ b/apps/extension/src/popup/components/QuickActions.jsx @@ -1,91 +1,14 @@ -import React, { useState } from 'react'; +import React from 'react'; const APP_URL = import.meta.env.VITE_APP_URL || 'https://threatcrush.com'; -const SEVERITY_COLORS = { - critical: 'text-red-400', - high: 'text-red-300', - medium: 'text-yellow-300', - low: 'text-cyan-300', - info: 'text-green-300', -}; - -const STATUS_COLORS = { - secure: 'text-green-400', - warning: 'text-yellow-400', - threat: 'text-red-400', - error: 'text-red-300', - unauthenticated: 'text-gray-400', -}; - export default function QuickActions() { - const [scanning, setScanning] = useState(false); - const [scanResult, setScanResult] = useState(null); - - async function handleScanSite() { - setScanning(true); - setScanResult(null); - try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab?.url) { - const response = await chrome.runtime.sendMessage({ - type: 'SCAN_URL', - url: tab.url, - }); - setScanResult(response); - } - } catch (error) { - console.error('[ThreatCrush] Scan failed:', error); - setScanResult({ status: 'error', error: error.message }); - } finally { - setScanning(false); - } - } - function openUrl(path) { chrome.tabs.create({ url: `${APP_URL}${path}` }); } return ( -
    - - - {scanResult && ( -
    -
    - Status: {scanResult.status} -
    - {scanResult.grade && ( -
    Grade: {scanResult.grade}
    - )} - {scanResult.score !== undefined && ( -
    Score: {scanResult.score}/100
    - )} - {scanResult.checks && Object.entries(scanResult.checks).map(([key, check]) => ( -
    - {key} - {check.status} -
    - ))} - {scanResult.error && ( -
    {scanResult.error}
    - )} -
    - )} - +