Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
77 changes: 64 additions & 13 deletions apps/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta>` | 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, `<meta name=referrer>` | 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.
52 changes: 52 additions & 0 deletions apps/extension/__tests__/auth-token.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
74 changes: 74 additions & 0 deletions apps/extension/__tests__/collect-page.test.js
Original file line number Diff line number Diff line change
@@ -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(
`<script src="http://cdn.example/app.js"></script>
<script src="https://cdn.example/safe.js"></script>
<link rel="Stylesheet" href="http://cdn.example/site.css">
<iframe src="http://widgets.example/frame"></iframe>
<img src="http://img.example/logo.png">`,
[
{ 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(
`<form action="http://collector.example/post"><input name="action" value="x"><input type="password"></form>
<form><input name="q"><button formaction="http://other.example/go">Go</button></form>`
);

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('<input type="PASSWORD">');
document.head.innerHTML = `<meta http-equiv="Content-Security-Policy" content="script-src 'self'">
<meta name="Referrer" content="no-referrer">`;

const signals = collectPageSignals();

expect(signals.passwordOutsideForm).toBe(true);
expect(signals.metaCsp).toEqual(["script-src 'self'"]);
expect(signals.metaReferrer).toBe('no-referrer');
});
});
3 changes: 3 additions & 0 deletions apps/extension/__tests__/options-app.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({})),
Expand Down
Loading
Loading