Skip to content

Highlighting entities on any page without a content script

The original activeTab design, chrome.scripting, the CSS Custom Highlight API and a shadow-DOM chip — plus what changed before 2.0 shipped.

7 min readCoeus — Maintainersengineeringprivacy

The obvious way to highlight entities on a web page is a content script with "matches": ["<all_urls>"]. It is also the way that puts "Read and change all your data on all websites" on the install dialog, and that sentence is the reason many security-conscious people will not approve an extension.

Coeus's manifest has no content_scripts key and no host_permissions. The page scan still works on any page you ask it to. This is how.

activeTab is the trick, and the title still holds

Written when activeTab was the only access Coeus had. 2.0 declares host_permissions as well, because asking per site turned every new advisory into a prompt — but the half this post is actually about is unchanged, and it is the half that matters: there is still no content script. Nothing runs on a page until you ask, and the injection below is still how it happens. The final toolbar action opens the side panel, so read the prerelease "popup" references below as "side panel"; the page-scan doc is the current operating guide.

activeTab is a permission with no install-time warning. It grants nothing until the user performs a gesture that implies intent — clicking the toolbar icon, choosing an item from the extension's context menu, or pressing one of its registered keyboard shortcuts. At that moment, and only for the tab that had focus, the extension is granted the ability to inject into and read that page. The grant expires when you navigate away.

Coeus takes it from three places, all of them explicit:

  • the scan-page command, Ctrl+Shift+K (⌘⇧K on a Mac);
  • the Scan this page for indicators item on the page context menu;
  • the Scan this page button in the popup.

The injection itself is one call from the service worker:

await chrome.scripting.executeScript({
  target: { tabId },
  files: ["content/pageScan.js"],
});

No manifest entry, no persistent script, nothing running on pages you never asked about. If you want a site scanned automatically — an internal threat-intel portal, say — you allowlist that origin, Coeus requests the host permission for it with chrome.permissions.request, and only then registers a script for that one match pattern with chrome.scripting.registerContentScripts. You can revoke it from Chrome's own extension settings, and the registration goes with it.

Three gestures, one injection, no standing access

Walking the page without wrecking it

The scanner walks text nodes with a TreeWalker, skipping script, style, noscript, textarea, input, select, anything [contenteditable] or [hidden], and nodes whose ancestors compute to display: none — checked lazily, because getComputedStyle on every node of a large document is the fastest way to make a page feel broken.

Nodes are processed in chunks of 200 through requestIdleCallback, with a setTimeout fallback, so a long article yields to the page's own work between chunks. Each chunk runs the same detectAll used everywhere else in the extension: unanchored per-type scan patterns produce candidates, and every candidate is validated by the full detect before it counts. A 1.2.3 never becomes an IP, and svchost.exe stays a process rather than a .exe domain.

There is a hard cap of 20,000 text nodes. Past that the scan stops and reports itself as partial rather than freezing a tab that turned out to be an infinite log viewer.

Highlighting without rewriting the DOM

The traditional approach wraps each match in a <span>. That mutates the page: it breaks :nth-child selectors, it can retrigger the site's own MutationObserver, and on a React-rendered page it gets blown away on the next render — sometimes taking the site's own state with it.

The CSS Custom Highlight API avoids all of that. You build Range objects, register them under a name, and style the name:

const ranges = matches.map((m) => {
  const range = new Range();
  range.setStart(m.node, m.start);
  range.setEnd(m.node, m.end);
  return range;
});
 
CSS.highlights.set("coeus-ioc", new Highlight(...ranges));
::highlight(coeus-ioc) {
  text-decoration: underline dotted #d4a64a;
  text-underline-offset: 3px;
}
::highlight(coeus-ioc-malicious) {
  background-color: rgba(255, 62, 138, 0.18);
}

Nothing in the page's markup changes. The page cannot see the highlight through the DOM, the site's own styles are untouched, and clearing a scan is one CSS.highlights.clear().

Coeus registers four names: coeus-ioc at rest, coeus-ioc-hover, and coeus-ioc-malicious / coeus-ioc-suspicious for after tracing. When verdicts come back from the service worker, ranges are moved between the sets — no re-scan, no reflow.

The chip, and why it lives in a shadow root

Hovering a highlight shows a chip: the type badge, the value, and buttons for Open links, Enrich and Copy defanged. It is the one thing the scan does add to the page, and it is a single element: one host node with a closed-ish shadow root containing both the chip and the right-edge minimap. Styling it inside a shadow root means the page's CSS cannot reach it and its CSS cannot reach the page — no !important arms race, no chip inheriting a 3 rem line height from an article stylesheet.

Positioning comes from range.getBoundingClientRect(), and finding out which range you are hovering uses document.caretPositionFromPoint, which is far cheaper than hit-testing every range on every mouse move.

Where it does not work

Honesty is part of the design here.

  • Closed shadow roots are invisible. A component rendered into attachShadow({ mode: "closed" }) cannot be walked from outside, by anyone. Entities inside one are not found.
  • Cross-origin iframes are separate documents. The scan covers the frame you invoked it in.
  • Canvas and images are pixels. There is no OCR, and there will not be.
  • PDF viewers render in a plugin document that the scan cannot walk.
  • Very long pages report partial at the 20,000-node cap.

A tool that quietly finds 80% of a page is worse than one that tells you it stopped. The badge count is always the number of unique entities actually found in what was actually scanned.

What the icon is telling you

The toolbar icon carries the scan's state, which is the only feedback available when the page is not yours to draw on. It shows a neon scan line while a scan is running, a pink inset ring when the page contains something known malicious, an amber ring for suspicious, and a glow when keep-awake is on. The badge shows the unique entity count in bronze on obsidian, capped at 99+, and clears on navigation.

The behaviour, the highlight styles and the allowlist are documented in page scan; the reasoning about permissions sits with the rest of it in privacy design.

Current tool pointer (22 September 2026): For copied text rather than a live page, use the local IOC Extractor. Email headers and links have their own local Email Analyzer and URL Parser.