DEV Community

Vladimir Elchinov for Session Replay

Posted on

A Web Page Can Tell Which Extensions You Have Installed. Here Is How.

Open a page and it can start guessing which browser extensions you run before you click a thing. Not "extensions in general" - which ones. Your password manager, your ad blocker, the wallet, the internal tool your employer ships, the accessibility extension you depend on. The page never asks and you never see it happen.

This is not a bug in Chrome. It is the sum of a few features working exactly as designed, and the people best placed to close it are extension authors who mostly do not know they left it open. I maintain an extension and a library that talks to it, so I have spent real time on the detectable side of this. Here is how a page does it, what the answer is worth to whoever is asking, and what actually stops it.

Technique one: ask the extension directly

Some extensions accept messages from web pages on purpose - our own does, so a customer's "report a bug" button can tell whether the extension is there. The API is chrome.runtime.sendMessage:

chrome.runtime.sendMessage(EXTENSION_ID, { type: 'ping' }, (reply) => {
  if (reply) {
    // it is installed, and it answered
  }
});
Enter fullscreen mode Exit fullscreen mode

For a page to be allowed to send that message, the extension has to list the page's origin in its manifest, under externally_connectable. Authors who want their extension to work with any site reach for the wildcard:

"externally_connectable": { "matches": ["<all_urls>"] }
Enter fullscreen mode Exit fullscreen mode

And that one line is the door. <all_urls> does not mean "my customers' sites". It means every site on the internet may now open a channel to this extension - which means every site may ping it and learn whether you have it. The convenience the author wanted for their own pages, they handed to everybody's.

This technique is narrow, because it only finds extensions that chose to talk to pages. The next one is not narrow.

Technique two: knock on the extension's own files

Extensions ship assets - icons, injected stylesheets, images. Any asset marked web-accessible is reachable at a fixed URL built from the extension's id:

chrome-extension://<extension-id>/icon-128.png
Enter fullscreen mode Exit fullscreen mode

Fixed. Which means a page does not have to be given permission to look. It just tries to load the file and watches what happens:

function hasExtension(id, path) {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => resolve(true);
    img.onerror = () => resolve(false);
    img.src = `chrome-extension://${id}/${path}`;
  });
}
Enter fullscreen mode Exit fullscreen mode

onload fires, the extension is installed. onerror, it is not. No message, no cooperation, nothing the extension author has to have agreed to beyond publishing a web-accessible file - which almost all of them do. Extension ids are public and stable; you read them off the store. So a page carries a list of ids and the resource path each one exposes, loops over it, and comes out the other side with an inventory. This is the workhorse, and it is old - security researchers demonstrated it at scale years ago and it still works today.

Technique three: do not look for the extension, look for its fingerprints

The two above need the extension's id. This one does not, and it catches the extensions that never talk to pages and ship no web-accessible files.

An extension that changes a page leaves marks, and a page can bait them out. An ad blocker hides elements that look like ads, so plant one and see if it vanishes:

const bait = document.createElement('div');
bait.className = 'ad-banner ad-slot sponsored';
bait.style.cssText = 'position:absolute;height:10px;left:-9999px';
document.body.appendChild(bait);

requestAnimationFrame(() => {
  const blocked = bait.offsetHeight === 0 ||
                  getComputedStyle(bait).display === 'none';
  // blocked === true means something is hiding ad-like elements
});
Enter fullscreen mode Exit fullscreen mode

A password manager injects an icon into password fields, so put a hidden password input on the page and watch for the DOM to change around it. You do not learn which ad blocker or which password manager, but you learn the visitor runs one - and often that is the fact that mattered.

So a page has a list of your extensions. What is that worth?

This is the part that turns a curiosity into a reason to care.

It is a near-unique fingerprint. The particular set of extensions you run is close to an identifier, and unlike a cookie you cannot clear it. It rides along with every other fingerprint signal and sharpens all of them.

It deanonymises you by inference. This is the sharp end. Extensions are not neutral - many of them say something about the person:

  • an internal SSO or admin extension that only one company ships → the visitor works there
  • a specific screen reader or accessibility extension → an accessibility need, which is about as sensitive as attributes get
  • a particular crypto wallet → the visitor holds crypto, which is exactly what a phishing page wants to know before it decides whether you are worth the effort
  • a competitor's extension → this landing page can quietly greet their customer differently from everybody else

It happens before you do anything. Every technique above runs on page load. By the time you have read the headline, the page has tailored itself - or decided what you are - off a signal you did not know you were sending.

None of this requires a breach or a trick. It is the platform behaving as documented, aimed at a question you were never asked whether you wanted answered.

What actually stops it

The good news is that the fixes exist and most of them belong to the extension author, not to you.

For technique two, Manifest V3 gave authors the answer: use_dynamic_url.

"web_accessible_resources": [{
  "resources": ["icon-128.png"],
  "matches": ["https://your-real-site.com/*"],
  "use_dynamic_url": true
}]
Enter fullscreen mode Exit fullscreen mode

Two things there. use_dynamic_url makes the resource's URL a per-session random token instead of the fixed path, so the <img> probe has nothing stable to request. And matches narrows who may load the resource at all, instead of leaving it open to every origin. Ship a web- accessible file with neither and you are the reason technique two still works.

For technique one, do not write <all_urls>. List the origins that genuinely need to talk to your extension. If that list cannot be known ahead of time - it is our exact case, a library any customer can install - then do not use externally_connectable at all. We do detection the other way around: the extension injects a content script that listens for a CustomEvent the page dispatches, and answers with another. A page that has not deliberately loaded our library and fired the event learns nothing, because the channel only carries a reply to a page that asked. The page can only find the extension by cooperating with it, which is the opposite of a silent probe.

For technique three there is no clean fix, because the detection is of the extension's effect, not the extension. Injecting into the page is the whole job. The best an author can do is be less trivially baitable - scope styles tightly, avoid marker classes and ids a page can guess. It is mitigation, not a cure.

And as a person who just wants to be probed less: the extensions you install are a fingerprint, so run fewer of them, and keep the ones that matter in a separate browser profile from the one you do sensitive things in. A profile with three extensions is a much larger crowd to hide in than one with thirty.


The theme under all of it is the one that runs through most browser-privacy problems: a feature added for a good reason, used at its most convenient setting, adds up to a capability nobody decided to grant. <all_urls> and a fixed resource URL are each perfectly reasonable in isolation. Together they mean the page you just opened knows things about you that you would not have told it.

If you write extensions, the ten minutes it takes to set use_dynamic_url and a real matches list is the cheapest privacy win you will ship this year. Your users cannot do it for you, and they will never know you did.

Top comments (0)