Skip to content

feat(extension): block phishing, malware and scam sites - #20

Merged
ralyodio merged 1 commit into
masterfrom
scam-protection
Aug 29, 2026
Merged

feat(extension): block phishing, malware and scam sites#20
ralyodio merged 1 commit into
masterfrom
scam-protection

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

This was written for #17 but missed the merge

#17 was squash-merged at 07:20:45; this commit was pushed at ~07:37. It never reached master, so the scam protection described in #17 is not currently shipped. Re-opening it on its own branch.

The two commits already in master (the blocked-request view and the security-job fixes) are shared history and collapse away on merge — only the scam-protection commit is new.

What it does

Blocks known phishing, malware and scam domains, and sends a top-level navigation to one to a warning page naming the site instead of a bare network error. Toggle on the Shield panel, on by default.

The list

Licence was the binding constraint — most well-known blocklists cannot 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 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. I verified uBlock Origin Lite's shipped JSON uses exactly this shape — 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. Bundled seed installs on first run, then a 6-hour refresh alarm. A refresh where every feed fails keeps what is 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 is 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, while a user-granted bypass outranks both. Covered by a test that runs the real allowlist path and asserts the security rules survive.

Also fixes the suite exiting non-zero

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. This is the fix for the 2 errors currently on master, which is why #19 still reports them.

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 passing, suite exits 0. Chrome and Firefox both build clean, with blocked.html at the package root and the 17,990-domain seed in rules/.

Worth one manual check before release

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D

Adds scam protection to the Shield: 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.

The list. Licence was the binding constraint — most well-known blocklists
cannot 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:
CERT-PL's Warning List, whose API spec grants use "without restriction, by
all entities, manually or automatically", plus durablenapkin/scamblocklist
and Phishing.Database (both MIT) for coverage outside Poland. 17,990
domains after collapsing subdomains a listed parent already covers.

Two rules, not 18,000. `requestDomains` takes a whole array and matches
subdomains implicitly, so a chunk of 5,000 domains costs one block rule
plus one redirect rule. That matters because the ~30k *static* budget is
already fully spent by the ads and privacy rulesets — this would not have
fit. uBlock Origin Lite ships the same shape; 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, while a static ruleset only changes when a new version
clears store review. Dynamic rules persist across restarts and updates and
refresh on a 6-hour alarm; the bundled seed installs on first run so
protection is never absent while waiting on a fetch, and a refresh where
every feed fails keeps what is already installed rather than clearing it.

The warning page gets the blocked URL from the redirect's
`regexSubstitution` (`#\0` is the whole match), so it can name the site.
That value comes from a hostile page, so it is validated to http(s) before
use and set with textContent — a javascript: or data: fragment is refused
outright, and "proceed anyway" is disabled when no hostname parses.
The fragment never leaves the browser: nothing about browsing is sent
anywhere, which is both a privacy position and a store-policy one.

Also fixes a latent collision this feature would have hit: applyAllowlist
removed *every* dynamic rule, so allowlisting a site for ads would have
deleted the user's phishing protection. Dynamic ids are now banded per
owner (src/background/rule-ids.js), and priorities are ordered so
security blocking outranks the ad allowlist — turning off ad blocking on
a site no longer turns off scam protection — while a bypass the user
granted from the warning page outranks both.

Incidentally makes the suite exit 0. 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 whatever the assertions said.
That was the pre-existing "2 errors" — 744 tests, exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D
});
// The exemption is a dynamic rule; give the browser a moment to install
// it before navigating back into the site, or the redirect fires again.
setTimeout(() => window.location.replace(blockedUrl), 300);
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

12 finding(s)

MEDIUM: 8 | LOW: 4

Severity Rule Location
MEDIUM js-open-redirect apps/extension/src/blocked/main.js:84
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
LOW secret-generic-credential packages/vault/__tests__/items.test.js:201
LOW secret-generic-credential packages/vault/__tests__/items.test.js:216

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 9782d09 into master Aug 29, 2026
9 checks passed
@ralyodio
ralyodio deleted the scam-protection branch August 29, 2026 08:56
ralyodio added a commit that referenced this pull request Aug 29, 2026
Resolves the alarm-handler conflict in the background service worker:
#20 added the security-list refresh alarm in the same spot phase 2 adds
the vault auto-lock alarm. Both handlers are kept, each with its own
early return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc77feRQws35YGu9BADW1a
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