An employee can spend the whole workday in a browser. Email, shared documents, the admin console for half the company. A phishing link arrives in that same workspace, opens a convincing login page, and asks for credentials. Nothing needs to download or execute outside the browser for the attacker to get what they came for.
Traditional endpoint detection and response (EDR) centers on processes, files, and host activity. Some products also block malicious sites. But knowing that a browser made a connection leaves a lot unanswered: which redirects led there, what was in the tab, and whether that login form has shown up somewhere else.
I built BrowserDefense to make more of that context available. It’s a browser extension I’m making public, with local URL blocking and telemetry you can use to build browser detection and response. Block events can include the redirect chain and tab context, so an analyst has something to investigate beyond a destination address.
Shadow Detection adds another signal: the structure of the page itself. Move a cloned login page to a new domain and a URL blocklist needs another entry. The form can stay exactly the same. I wanted a way to follow that reuse without collecting the page’s text.
The URL check happens during navigation. Structural detection gets its turn once there’s a page to inspect:
A blocked URL produces a block event. Otherwise, once the DOM is available, BrowserDefense normalizes and hashes its structure, checks local signatures, and can query a configured fingerprint service. Known local matches and other verdicts follow their configured action; uncertain verdicts covered by shadow mode are logged for review without blocking.
Strip the page down
The extractor walks the DOM, retaining element nesting and selected attributes such as input types and form methods. It skips text, CSS, images, and script bodies. It also sorts the serialized children, so rearranging siblings doesn’t change the resulting tree.
In the active cached fingerprint path, that normalized tree goes through SHA-256. The hostname isn’t in it. Neither are the words on the submit button or the values someone typed into the form.
Try changing the example below. The first change moves the page to another domain. The next changes its heading and CSS. Both keep the same fingerprint. Adding a wrapper element changes the tree and produces a different hash.
Compare four synthetic login pages evaluated by BrowserDefense's pinned extractor. The domain and text/CSS variants have the reference hash. Adding a div around the inputs changes it. The control selects a variant; all four results remain visible without JavaScript.
View source values
| Variant | Host | Normalized tag tree | Full SHA-256 | Exact comparison |
|---|---|---|---|---|
| Original page | portal-a.example | html(body(form[method=post](button[type=submit],h2,input[type=email],input[type=password])),head) | a932db0f5055f054b4aa56b77bb83d12409d9086d87c5fd4623cdb89649ae67e | Same as reference |
| Different domain | portal-b.example | html(body(form[method=post](button[type=submit],h2,input[type=email],input[type=password])),head) | a932db0f5055f054b4aa56b77bb83d12409d9086d87c5fd4623cdb89649ae67e | Same as reference |
| Different text + CSS | portal-b.example | html(body(form[method=post](button[type=submit],h2,input[type=email],input[type=password])),head) | a932db0f5055f054b4aa56b77bb83d12409d9086d87c5fd4623cdb89649ae67e | Same as reference |
| Extra DOM wrapper | portal-b.example | html(body(form[method=post](button[type=submit],div(input[type=email],input[type=password]),h2)),head) | 296c90feb1f92d2450614bb0ee9078ad534540fb5e5352f755f3891efeace8b3 | Different from reference |
| Variant | Host | Normalized tag tree | Full SHA-256 | Exact comparison |
|---|---|---|---|---|
| Original page | portal-a.example | html(body(form[method=post](button[type=submit],h2,input[type=email],input[type=password])),head) | a932db0f5055f054b4aa56b77bb83d12409d9086d87c5fd4623cdb89649ae67e | Same as reference |
| Different domain | portal-b.example | html(body(form[method=post](button[type=submit],h2,input[type=email],input[type=password])),head) | a932db0f5055f054b4aa56b77bb83d12409d9086d87c5fd4623cdb89649ae67e | Same as reference |
| Different text + CSS | portal-b.example | html(body(form[method=post](button[type=submit],h2,input[type=email],input[type=password])),head) | a932db0f5055f054b4aa56b77bb83d12409d9086d87c5fd4623cdb89649ae67e | Same as reference |
| Extra DOM wrapper | portal-b.example | html(body(form[method=post](button[type=submit],div(input[type=email],input[type=password]),h2)),head) | 296c90feb1f92d2450614bb0ee9078ad534540fb5e5352f755f3891efeace8b3 | Different from reference |
These are small test pages, not captured phishing sites. I like them for showing exactly what the comparison throws away. You can change the address and wording without changing the form underneath.
Adding the wrapper changes this exact hash. BrowserDefense also has a separate MinHash/LSH similarity path for variants, but the figure only tests exact equality. It doesn’t establish how well similarity matching handles a modified kit in the wild.
Recognize first, decide whether to block
Matching structure doesn’t prove that a page is malicious. Legitimate sites reuse login templates, too. Blocking every unfamiliar fingerprint would make a browser fairly unpleasant to use.
Shadow mode lets uncertain detections be logged for analyst review without blocking the page. The analyst can promote a detection or dismiss it. Known signatures can carry their own enforcement action; the point of shadow mode is to inspect uncertain matches before turning them into a rule.
The hash-only uplink sends a fingerprint and timestamp rather than a URL or HTML. That keeps the comparison payload small and avoids uploading page contents through that path. It isn’t a promise that a hash reveals nothing: someone who has a candidate template can fingerprint it and compare. Encrypted block-event reports are a separate path, with their own capture settings and the organization’s decryption key.
Structural detection also starts after the page’s DOM is available. It can’t prevent the first request to a previously unseen site. The URL filter still has work to do.
Get a known bad link into browsers
For a domain you already know about, there’s a shorter workflow. Select indicators in a threat report, right-click, and choose “Broadcast IOCs to Team.” An authenticated analyst can submit them to the team’s feed without copying them into another tool. Managed browsers receive signed updates and check navigations against the local list.
Installation mode matters. Managed policy installation enables the enterprise blocking path. An ordinary installation uses a different combination of browser rules and reactive redirects, where navigation can begin before the redirect intervenes.
Block reports travel back encrypted. The organization decrypts them with its own key; the analyst investigates and can submit confirmed URL indicators to the feed. Browsers verify the signed update before applying it.
The managed browser captures a block event, encrypts the report, and uploads it. The security team retrieves the ciphertext and decrypts it with the organization's key to investigate. An analyst explicitly submits confirmed URL indicators, and browsers receive signed feed updates by push or pull, verifying the signature before updating local rules.
The repository includes the extension, CLI, and deployment instructions. To try Shadow Detection, start with two copies of a harmless login form on domains you control. Change the heading, change the styling, then add a wrapper around the inputs. Give each variant a fresh path: the extension caches fingerprints by URL, ignoring query strings and fragments. Watch which fingerprints stay equal. Reviewing those matches is where the experiment becomes useful.