Skip to content

feat(extension): Shield — blocked-request view, phishing/scam blocking, and green security jobs - #17

Merged
ralyodio merged 2 commits into
masterfrom
shield-blocked-urls
Aug 29, 2026
Merged

feat(extension): Shield — blocked-request view, phishing/scam blocking, and green security jobs#17
ralyodio merged 2 commits into
masterfrom
shield-blocked-urls

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

What

The Shield could only report a count on the toolbar badge, never what it stopped. This adds a "Blocked on this page" section to the Shield panel listing each blocked ad/tracker domain with its list badge and hit count.

The MV3 constraint

Blocking is done natively by declarativeNetRequest static rulesets. The only match telemetry a packaged extension can read is getMatchedRules(), which returns the matched rule id and ruleset — never the request URL. The URL-bearing onRuleMatchedDebug event requires the declarativeNetRequestFeedback permission and only fires for unpacked extensions, so it is not shippable.

So the approach is to resolve rule ids back to the filter that matched:

  • scripts/build-filters.js now emits a label index next to each ruleset — rules/<list>.labels.txt, one label per line, where line N is rule id startId + N — plus a self-describing rules/index.json so the runtime never hard-codes the start ids the build chose. The build throws rather than silently misaligning if rule ids are ever non-dense.
  • Every rule in the shipped lists is a whole-domain ||domain^ block, so the label is the ad/tracker domain that was blocked — exactly what a user wants to see.
  • Cost: 283KB per list against an existing 2MB ruleset. public/rules/ is gitignored and generated at build time; copyRules() copies the directory unfiltered, so the new files ship automatically.

Changes

  • src/background/blocked-log.js (new) — resolves rule ids to labels (lazily loaded, cached per service-worker lifetime), aggregates hits per domain with counts and last-seen time, and skips matches from the dynamic allowlist ruleset since those are allow actions, not blocks. Where onRuleMatchedDebug is available (dev builds) it records exact URLs and prefers them.
  • AdblockPanel.jsx — new BlockedList section: per-domain rows with list badge + count, Show all toggle past 6 rows, expandable real URLs in dev builds, explicit empty state, and a graceful unsupported state for browsers that cannot report matches.
  • background/index.jsGET_BLOCKED_REQUESTS / CLEAR_BLOCKED_REQUESTS handlers; initBlockedLog() registered synchronously at top level alongside the other listeners (required for Firefox MV3).

No new permissionsactiveTab already covers getMatchedRules for the tab whose popup is open.

Testing

  • 31 new tests (__tests__/blocked-log.test.js, plus label-index coverage in build-filters.test.js), including id→label alignment spot-checked across the full 15k range against what buildRuleset actually produced.
  • Full suite: 667 → 698 passing. The 2 unhandled-rejection errors that make the run exit non-zero are pre-existing — verified identical on a clean origin/master worktree (24 files pass, same 2 errors, exit 1).
  • pnpm lint clean.
  • BROWSER=chrome pnpm build:chrome verified end to end: popup bundle contains the new UI, background bundle contains the handler, and dist/chrome/rules/ ships index.json + both .labels.txt files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D


Second commit: security jobs + real ThreatCrush findings

Separable from the feature above — split it out if you'd rather land it on its own.

The security workflow's gitleaks and npm audit jobs had been failing on master, not just here. Both are continue-on-error: true, so the run still reported success and the red jobs went unnoticed. Both now pass.

  • npm audit exited 127 with pnpm: not found. The cause wasn't postinstall (it already ends in || true) but the sibling script literally named dependencies — an npm lifecycle name, so npm runs it on install. It shelled straight into pnpm dlx with no guard. Renamed to patch:socket: still a manual entry point, no longer an install hook, and installs stop applying the same patches twice. postinstall now checks for pnpm first. Verified: the script exits 0 with pnpm off PATH.
  • gitleaks reported 3 findings, all verified false positives by reading the flagged commits, and all in history so unreachable by editing the tree. Two are the dropbox-api-token rule matching the response header name in response.headers.get('dropbox-api-result'). The third is the Ahrefs Analytics data-key, which ships in the HTML of every page view and is public by design. Recorded in .gitleaksignore with that evidence. Verified locally with gitleaks 8.21.2: 327 commits scanned, no leaks, exit 0.

ThreatCrush: 15 → 9 findings, the only HIGH cleared. All were pre-existing and repo-wide — the workflow scans . rather than the diff, and runs with FAIL_ON="" so it never gates. Fixed the genuine ones:

  • .githooks/pre-commit ran checks through eval "$cmd" (the HIGH). run_check now takes argv and runs "$@".
  • The release hooks handed off through /tmp/.marksyncr-release-trigger — a fixed name in a world-writable directory another user on a shared box can pre-create or symlink. Moved into the repo's git dir, which also stops two checkouts colliding. Verified end to end: commit-msg writes the trigger, post-commit detects and consumes it.
  • scripts/bump-version.ts interpolated a version string read from package.json into shell strings for git add/commit/tag. Now execFileSync with an argument list, so no shell parses it.

The 9 remaining are false positives, deliberately left alone: static JSON-LD passed to dangerouslySetInnerHTML (×2), the public Supabase anon key sent as a request header and never logged, a console.error template over a hardcoded table list, redirects to our own API's Stripe URL and to a hardcoded connectUrl, a fake password in a test fixture, and a test-only regex whose quantifiers are over disjoint classes. manifest-install-lifecycle-script still flags postinstall fetching at install time — that's the intended Socket-patch design, now merely non-fatal.


Third commit: phishing, malware & scam blocking

Known-bad domains are blocked, and a top-level navigation to one lands on a warning page naming the site instead of a bare network error. Toggle lives on the Shield panel, on by default.

The list. Licence was the binding constraint — most well-known blocklists can't ship inside a commercial extension (hagezi and AdGuard's are GPL-3.0-only, Cloudflare Radar is CC BY-NC, OpenPhish's community feed is non-commercial, abuse.ch now needs an auth key). Three sources clear it:

Source Domains Licence
CERT-PL Warning List 18,210 Unrestricted — "without restriction, by all entities, manually or automatically"
durablenapkin/scamblocklist 2,216 MIT
Phishing.Database (NEW-today) daily delta MIT

17,990 after collapsing subdomains a listed parent already covers. Refresh with node scripts/update-phishing-list.js.

Two rules, not 18,000. requestDomains takes a whole array and matches subdomains implicitly, so a 5,000-domain chunk costs one block rule plus one main_frame redirect rule. This matters: the ~30k static budget is already fully spent by the ads and privacy rulesets, so a per-domain ruleset would not have fit at all. uBlock Origin Lite ships exactly this shape — I checked its published JSON, and its urlhaus ruleset is literally one rule holding 5,312 domains.

Dynamic rules, not a static ruleset. Phishing domains have a median lifetime of hours; a static ruleset only changes when a new version clears store review. So: bundled seed installs on first run (protection is never absent while waiting on a fetch), then a 6-hour refresh alarm. A refresh where every feed fails keeps what's installed rather than clearing it.

The warning page gets the blocked URL from the redirect's regexSubstitution (#\0 is the whole match). That value comes from a hostile page, so it's validated to http(s) before use and set with textContent; a javascript: or data: fragment is refused, and "proceed anyway" is disabled when no hostname parses. The fragment never leaves the browser — nothing about browsing is sent anywhere, which is a store-policy position as much as a privacy one (Avast Online Security was de-listed in 2019 for sending URLs).

A collision this would have hit

applyAllowlist removed every dynamic rule — so allowlisting a site for ads would have silently deleted the user's phishing protection. Dynamic ids are now banded per owner (src/background/rule-ids.js) and priorities ordered so security blocking outranks the ad allowlist (turning off ad blocking on a site no longer turns off scam protection), while a user-granted bypass outranks both. Covered by a test that runs the real allowlist path and asserts the security rules survive.

New permissions

https://hole.cert.pl/* and https://raw.githubusercontent.com/* (list downloads only), plus blocked.html as a web-accessible resource so it can be a redirect target.

Testing

  • 744 tests passing, and the suite now exits 0 — 46 new tests here. Four storage reads indexed a possibly-undefined result; from a floating promise at module load that surfaced as an unhandled rejection and failed the run regardless of assertions. That was the pre-existing "2 errors" noted above; it's fixed.
  • Chrome and Firefox both build clean, with blocked.html at the package root and the 17,990-domain seed in rules/.

Not done / worth a look

requestDomains: ["example.com"] also matching sub.example.com is documented only by example. uBOL depends on it and the collapsing step assumes it, so it's near-certain — but it deserves one manual smoke test in a real browser before release, since if it were wrong the list would under-block.

The Shield could only report a count on the toolbar badge, never what it
stopped. Under MV3 that is a real constraint: blocking is done natively by
declarativeNetRequest static rulesets, and the only telemetry a packaged
extension can read is getMatchedRules(), which returns the matched *rule
id* and ruleset but never the request URL. (onRuleMatchedDebug, which does
carry the URL, needs declarativeNetRequestFeedback and only fires for
unpacked extensions -- not something we want to ship.)

So resolve rule ids back to the filter that matched. build-filters.js now
emits a label index next to each ruleset (rules/<list>.labels.txt, one
label per line, line N == rule id startId + N) plus a self-describing
rules/index.json so the runtime never hard-codes the start ids the build
chose. Every rule in the shipped lists is a whole-domain block, so the
label is the ad/tracker domain that was blocked -- exactly what a user
wants to see. The index costs 283KB per list against a 2MB ruleset.

Adds src/background/blocked-log.js, which resolves ids to labels (lazily
loaded and cached), aggregates hits per domain with a count, and skips
matches from our dynamic allowlist ruleset since those are allows rather
than blocks. Where onRuleMatchedDebug *is* available it records exact
URLs and prefers them, shown as an expandable detail per row.

The popup gains a "Blocked on this page" section listing each domain with
its list badge and hit count, behind a Show all toggle, with an explicit
empty state and a graceful unsupported state for browsers that cannot
report matches. No new permissions: activeTab already covers
getMatchedRules for the tab whose popup is open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

9 finding(s)

MEDIUM: 7 | LOW: 2

Severity Rule Location
MEDIUM sql-template-interpolation apps/web/app/api/account/delete/route.js:104
MEDIUM js-credential-logged apps/web/app/api/health/route.js:27
MEDIUM js-open-redirect apps/web/app/dashboard/sync-sources-client.jsx:88
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:86
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:110
MEDIUM js-open-redirect apps/web/app/pricing/page.jsx:178
MEDIUM manifest-install-lifecycle-script package.json:17
LOW secret-generic-credential apps/web/__tests__/auth-api.test.js:541
LOW redos-nested-quantifier packages/sources/__tests__/dropbox-oauth.test.ts:72

Snippets are redacted; ThreatCrush never prints matched credential material.

…indings

The security workflow's gitleaks and npm audit jobs have been failing on
master for a while (both are continue-on-error, so the run still went
green and nobody had to look). Neither failure came from any PR.

npm audit: `npm install` exited 127 with "pnpm: not found". The cause was
not postinstall, which already ends in `|| true`, but the sibling script
literally named "dependencies" -- an npm *lifecycle* name, so npm runs it
on install. It shelled straight into `pnpm dlx` with no guard, and pnpm
does not exist in that job. Renamed to "patch:socket", which keeps it as
a manual entry point while removing the accidental install hook (it only
duplicated postinstall anyway, so installs also stop applying the same
patches twice). postinstall now checks for pnpm before calling it.

gitleaks: 3 findings, all verified false positives by reading the flagged
commits, all in history so unreachable by editing the tree. Two are the
`dropbox-api-token` rule matching the *response header name* in
`response.headers.get('dropbox-api-result')`. The third is the Ahrefs
Analytics `data-key`, which ships in the HTML of every page view and is
public by design. Recorded in .gitleaksignore with the evidence.

Also fixes the genuine ThreatCrush findings, all pre-existing:

- .githooks/pre-commit ran its checks through `eval "$cmd"` (their only
  HIGH). run_check now takes argv and runs "$@" directly.
- The release hooks handed off through /tmp/.marksyncr-release-trigger, a
  fixed name in a world-writable directory that another user on a shared
  box can pre-create or symlink. Moved into the repo's git dir, which also
  stops two checkouts colliding.
- scripts/bump-version.ts interpolated a version string read from
  package.json into shell strings for git add/commit/tag. Now uses
  execFileSync with an argument list, so no shell parses it.

The remaining ThreatCrush findings are false positives and deliberately
left alone: static JSON-LD passed to dangerouslySetInnerHTML, the public
Supabase anon key sent as a request header (not logged), a console.error
template over a hardcoded table list, redirects to our own API's Stripe
URL and to a hardcoded connectUrl, a fake password in a test fixture, and
a test-only regex whose quantifiers are over disjoint classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D
Comment thread package.json
"setup:hooks": "git config core.hooksPath .githooks && git config push.followTags true && echo 'Git hooks and push.followTags configured'",
"postinstall": "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm && git config core.hooksPath .githooks && git config push.followTags true 2>/dev/null || true",
"dependencies": "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm"
"postinstall": "command -v pnpm >/dev/null 2>&1 && pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm; git config core.hooksPath .githooks && git config push.followTags true 2>/dev/null || true",
@ralyodio
ralyodio merged commit ecc4d92 into master Aug 29, 2026
9 checks passed
@ralyodio
ralyodio deleted the shield-blocked-urls branch August 29, 2026 07:20
@ralyodio ralyodio changed the title feat(extension): show blocked ad/tracker domains on the Shield panel feat(extension): Shield — blocked-request view, phishing/scam blocking, and green security jobs Aug 29, 2026
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.

2 participants