Skip to content

feat(extension): local security checks for the active page - #240

Merged
ralyodio merged 1 commit into
masterfrom
feat/extension-page-checks
Sep 25, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/extension-page-checks

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

The browser extension now runs real security checks on the page you're viewing, in the browser:

Check Source
HTTPS tab URL
Strict-Transport-Security (missing / max-age=0 / < 180 days; n/a on http) main-frame response headers
Content-Security-Policy: missing, report-only, script 'unsafe-inline' (unless nonce/hash), 'unsafe-eval', * / https: / data: (ignored under 'strict-dynamic'); multiple policies intersected; <meta> fallback headers, DOM
Clickjacking: CSP frame-ancestors, else X-Frame-Options headers
X-Content-Type-Options, Referrer-Policy (incl. <meta name=referrer>), Permissions-Policy headers
Mixed content: active http:// (script/style/frame/fetch) = fail, passive (img/media) = warn Resource Timing + DOM
Insecure forms: password field on http, or https page posting to http:// (incl. formaction) DOM
Cookies: likely session cookies without Secure (fail on https), HttpOnly (CSRF cookies exempt) or SameSite. Names only, never values chrome.cookies
  • Popup: new This page tab (default) with per-check pass/warn/fail rows and a summary; the existing account/usage UI moved unchanged to an Account tab.
  • Badge: set per tab (fail count red, else warn count amber, else ✓) when each page finishes loading and when the popup opens. Reset to the global account badge on navigation.
  • Options: Page checks (grant/revoke site access, auto-check toggle) and a Privacy statement. The unused autoScan setting is gone.
  • Scan with ThreatCrush (the existing /api/scan server scan) moved into the page view. It is the only thing that sends page data anywhere, runs only on click, sends origin + path only (query string and fragment dropped), and the popup shows the exact URL first. No sign-in required (the endpoint is public). apps/web/.../api/scan/route.ts is unchanged.

Code: pure checks in src/lib/page-checks.js, in-page collector in src/lib/collect-page.js (injected with scripting.executeScript), orchestration in src/background/page-checks.js.

Permissions (and why)

  • Added to permissions: activeTab, scripting, webRequest, cookies. None of these adds a Chrome install warning on its own.
  • optional_host_permissions: ["http://*/*", "https://*/*"], requested at first use from the popup's "Enable page checks" button or from Options, and revocable there. webRequest and cookies only see hosts we have access to. Response headers have to be observed while the page loads, and activeTab is only granted after that (on the toolbar click), so per-click access can't capture them. Making the access optional keeps the install prompt clean and leaves the choice to the user. Without it, the HTTPS/forms/mixed-content checks still run via activeTab when the popup opens; header and cookie rows show "Unknown" with the reason.
  • Removed the unused optional_permissions: ["tabs", "activeTab"]. activeTab only works when it's a required permission.
  • webRequest is used observationally only (onHeadersReceived, types: ["main_frame"], ["responseHeaders"]). Only the 8 security headers are kept, in storage.session per tab, and they're removed when the tab closes.

Firefox MV3 differences (handled / documented in the README)

Host permissions are always user-controlled; optional_host_permissions needs Fx 128+ (the manifest already requires 142+). The background is an event page, so listeners are registered at top level. permissions.request() is called synchronously in the click handler. cookies.getAll retries with firstPartyDomain: null under first-party isolation. data_collection_permissions stays none: the scan button is user-initiated, clearly labelled, and sends only the current page URL, which is implied consent under AMO policy §6.2.2.2.

Fixes found along the way

  • Account polling and GET_STATS read an authToken key that nothing ever wrote, and api.js read a session key only the unused api.login() wrote. Signed-in users therefore always got zero stats. Requests now carry the Supabase session's access token (which /api/usage verifies). Removed the dead api.login/api.logout.
  • createClient('') throws at import when a build has no Supabase env, which crashed the whole popup. The client is now null in that case: sign-in says it isn't configured and page checks still work.
  • PR checks now run the extension's tests and build (they weren't in CI).

How verified

  • pnpm --filter @profullstack/threatcrush-extension test: 5 files, 53 tests. page-checks.test.js has pass and fail cases for every check: headers→findings, CSP analysis, framing precedence, cookie flags, mixed content, forms, report assembly, badge, and scan URL stripping. collect-page.test.js runs the DOM collector in jsdom. auth-token.test.js holds regression tests that fail on master ("expected undefined to be 'Bearer …'", "supabaseUrl is required.") and pass here.
  • pnpm --filter @profullstack/threatcrush-extension build (chrome/firefox/safari). web-ext lint on the Firefox build: 0 errors, the same 6 warnings as master (1×1 placeholder icons, react-dom innerHTML).
  • Real Chromium smoke (Chrome for Testing 152, headless, --load-extension). Local http and self-signed https servers, reached as insecure.test / weak.test / secure.test via --host-resolver-rules. Popup opened with chrome.action.openPopup() and read over CDP:
    • http://insecure.test/insecure (password form, sessionid cookie, no headers): badge 2 (red). HTTPS fail, forms fail ("Password field on a page served over plain HTTP"), cookies warn sessionid: no Secure, no HttpOnly, no SameSite, HSTS/mixed n/a.
    • https://weak.test/weak (HSTS 3600s, CSP with 'unsafe-inline' 'unsafe-eval', XFO ALLOW-FROM, Referrer-Policy: unsafe-url, http script+img, form → http, auth_token cookie without flags): badge 5. 5 fail / 4 warn / 1 pass, each row as expected.
    • https://secure.test/strong: badge ✓, 10/10 pass.
    • https://example.com/?token=abc#frag: 1 fail (HSTS) / 5 warn / 4 pass. Clicking Scan hit production /api/scan and showed "Grade F · Score 25/100". The caption showed https://example.com/.
    • Privacy: with the API URL pointed at a local request logger, visiting 3 pages and opening the popup 3 times made zero requests. Each Scan click made exactly one POST /api/scan {"url":"https://weak.test:18443/weak"} for …/weak?session=abc#x.
    • Real manifest without site access: popup shows the gate, and clicking "Enable page checks" calls chrome.permissions.request({origins:["http://*/*","https://*/*"]}). Options shows "Site access not granted/granted" per build. The Account tab shows the login form (or "not configured" without Supabase env), and signed-out GET_STATS returns zeros.

Not exercised: accepting the native permission prompt (headless can't click it; the smoke used a copy of the build with the origins moved to host_permissions), the activeTab-only DOM path (needs a real toolbar click), a signed-in account, and Firefox and Safari at runtime (no Firefox or Safari on the test machine).

Blocked on accounts (not code)

  • Chrome Web Store developer account (one-time fee) for listing and review.
  • Firefox AMO account plus API key/secret (WEB_EXT_API_KEY / WEB_EXT_API_SECRET) for web-ext sign.
  • Apple Developer membership and an Xcode Safari Web Extension wrapper for Safari.

…mixed content locally

The popup gets a "This page" view with pass/warn/fail rows and the toolbar
badge is set per tab. Checks run in the browser: main-frame response headers
observed with webRequest (HSTS, CSP incl. unsafe-inline/unsafe-eval/wildcard
analysis, framing, nosniff, Referrer-Policy, Permissions-Policy), cookie flags
via chrome.cookies (names only), and a DOM/Resource Timing collector for
mixed content and insecure forms. Site access is an optional host permission
requested at first use; nothing is sent to a server unless the user clicks
"Scan with ThreatCrush", which sends origin+path only.

Also fixes account calls that read a never-written `authToken`/`session`
key (now the Supabase session token), a popup crash when the build has no
Supabase config, and runs the extension tests and build in PR checks.
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

12 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 6 | LOW: 5

Severity Rule Location
HIGH secret-aws-access-key prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md:126
MEDIUM js-open-redirect apps/web/src/app/auth/login/page.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:104
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:108
MEDIUM js-open-redirect apps/web/src/components/funding/FundingClient.tsx:97
MEDIUM js-unescaped-html-sink apps/web/src/components/GuideReader.tsx:265
MEDIUM js-uninitialized-buffer packages/scan/src/node-rules.ts:456
LOW secret-generic-credential PRD.md:269
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:121
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:122
LOW sh-remote-script-execution scripts/smoke-test.sh:47
LOW secret-aws-access-key scripts/smoke-test.sh:112

Snippets are redacted; ThreatCrush never prints matched credential material.

ralyodio added a commit that referenced this pull request Sep 25, 2026
… wording

SURFACES.md picks up the browser-extension (#240) and libinjection/WASM (#239)
status lines and the extension-store blockers; RELEASE_STATUS.md lists the
store accounts. The homepage now says unsigned macOS/Windows builds may be
blocked, not only warned about, on first launch.
@ralyodio
ralyodio merged commit f1f4a04 into master Sep 25, 2026
11 checks passed
ralyodio added a commit that referenced this pull request Sep 25, 2026
… wording

SURFACES.md picks up the browser-extension (#240) and libinjection/WASM (#239)
status lines and the extension-store blockers; RELEASE_STATUS.md lists the
store accounts. The homepage now says unsigned macOS/Windows builds may be
blocked, not only warned about, on first launch.
ralyodio added a commit that referenced this pull request Sep 25, 2026
ralyodio added a commit that referenced this pull request Sep 25, 2026
…refresh release docs (#238)

* ci(desktop): sign and notarize when secrets exist; declare libgbm1/ALSA in the deb

- scripts/desktop-signing-env.sh exports electron-builder's signing variables
  only for secrets that are set (APPLE_CERTIFICATE[_PASSWORD], APPLE_API_KEY/
  _KEY_ID/_ISSUER or APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID,
  WINDOWS_CERTIFICATE[_PASSWORD]); without them builds stay unsigned
- drop notarize: false so notarization follows the credentials
- package (never publish) the desktop matrix on PRs touching desktop packaging
- deb.depends adds libgbm1 and libasound2t64 | libasound2: the published deb
  failed on minimal installs with libgbm.so.1 missing
- docs: desktop packaging has been green since v0.13.2; refresh release,
  surface and mobile status from gh evidence
- web: Download Desktop links pointed at a 404 /releases; state that
  macOS/Windows builds are unsigned

* ci(desktop): leave pull-request packaging runs unsigned

electron-builder already skips macOS signing on PRs (CSC_FOR_PULL_REQUEST);
treat Windows the same so PR runs never decode the certificates, and say so
in the job summary instead of claiming signing is on.

* docs: record extension and libinjection status; soften unsigned-build wording

SURFACES.md picks up the browser-extension (#240) and libinjection/WASM (#239)
status lines and the extension-store blockers; RELEASE_STATUS.md lists the
store accounts. The homepage now says unsigned macOS/Windows builds may be
blocked, not only warned about, on first launch.

* docs: mark the extension page checks (#240) as merged in surface and release status
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant