DEV Community

Cover image for 7 of My Extensions May Read Every Site, Withholding Defaults to Off
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

7 of My Extensions May Read Every Site, Withholding Defaults to Off

I have written about browser extension permissions on this blog before. I walked
through how to audit them
step by step, and I covered the risk of an extension changing hands
after you install it. Both pieces were advice. This time I took my own advice on my
own machine, and instead of looking where the panel points, I looked at the file
where Chrome actually writes permissions down.

Panels show summaries; files keep books. When I counted my notification permissions
from the file rather than the panel
I saw how far apart those two numbers can drift. My curiosity about extensions was
the same: what does each one ask for, what did Chrome actually give it, and is
there a gap between the two that I never noticed?

The short answer: in host permissions there is no gap — everything asked for was
granted. In API permissions I found exactly one difference across nine extensions:
one permission, on one extension. The mechanism where a gap could live is there; on
my machine it has barely been used.

The measurement was taken on 29 October 2026, on macOS, with Chrome 156.0.8078.48.

The ledger lives in the profile folder, not the panel

Chrome keeps extension records in a file called Secure Preferences inside the
profile folder — plain JSON, signed but not encrypted:

import json, os
P = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Secure Preferences")
st = json.load(open(P, encoding="utf-8"))["extensions"]["settings"]
print("records:", len(st))
print("with a manifest:", sum(1 for v in st.values() if v.get("manifest")))
Enter fullscreen mode Exit fullscreen mode

My number came back as 22. Not all of these records are listed in the extension
panel — Chromium's own rule keeps some of them out of the management UI, and we will
look at that rule shortly. Seventeen of the 22 have a manifest embedded,
meaning Chrome knows what that extension asks for. The remaining five records have
no manifest at all
. I opened them to see what was inside:

{"app_launcher_ordinal": "w", "lastpingday": "13410432000888874", "page_ordinal": "n"}
{"app_launcher_ordinal": "n", "incognito": true, "lastpingday": "13410432000888874", "page_ordinal": "n"}
{"active_bit": false, "allowlist": 1, "lastpingday": "13437644400646797"}
{"active_bit": false, "allowlist": 2, "disable_reasons": [1], "lastpingday": "13437644400646797"}
{}
Enter fullscreen mode Exit fullscreen mode

The fifth is an entirely empty dictionary. The second still remembers an incognito
permission, and the last still carries the record that says "the user disabled this" —
neither has a manifest.

Since I called these "no longer installed", let me show it. I compared the five IDs
against the extension directories on disk:

import json, os
B = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default")
st = json.load(open(os.path.join(B, "Secure Preferences"), encoding="utf-8"))["extensions"]["settings"]
on_disk = set(os.listdir(os.path.join(B, "Extensions")))
no_manifest = [k for k, v in st.items() if not v.get("manifest")]
print("records without a manifest:", len(no_manifest))
print("of those, present on disk:", [k for k in no_manifest if k in on_disk])
Enter fullscreen mode Exit fullscreen mode
records without a manifest: 5
of those, present on disk: []
Enter fullscreen mode Exit fullscreen mode

All five are gone from disk. The ledger keeps what it remembers about things that are
no longer installed — the same pattern I found when I counted notification
permissions: these files behave like append-only books. Lines get added, never removed.

Which ones are mine, and which ones are Chrome itself

Not all 17 records with a manifest are software I installed. The location field
makes the distinction, and that field is an enum in Chromium: in
extensions/common/mojom/manifest.mojom it is defined as kInternal = 1,
kExternalPref = 2, kComponent = 5, kExternalPrefDownload = 6,
kExternalComponent = 10. The enum's own comment describes kComponent as an
integral component of Chrome itself that happens to be implemented as an extension —
and says these are not shown in the management UI.

My distribution: 1 → 7 records, 2 → 1, 5 → 7, 6 → 1, 10 → 1.

So 8 of the 17 records are Chrome's own internals (the PDF viewer, text-to-speech,
the Web Store and so on). That leaves 9 extensions installed on the user side.
Per the enum's comment, this is also the group the panel lists; components do not
appear there.

Two of them do not have location 1: one is 2, the other is 6. The enum does not
treat these as the same thing: kExternalPref = 2 is "a crx file from an external
directory (via prefs)", while kExternalPrefDownload = 6 is the same thing but
"installed from an update URL". The difference is visible on disk:

ls ~/Library/Application\ Support/Google/Chrome/External\ Extensions/
cat ~/Library/Application\ Support/Google/Chrome/External\ Extensions/*.json
Enter fullscreen mode Exit fullscreen mode
fcoeoabgfenejglbffodgkkbkcdhcgfn.json
fldmhceldgbpfpkbgopacenieobmligc.crx
fldmhceldgbpfpkbgopacenieobmligc.json

{"external_update_url": "https://clients2.google.com/service/update2/crx"}
{"external_crx": ".../External Extensions/fldmhceldgbpfpkbgopacenieobmligc.crx",
 "external_version": "2.0.23"}
Enter fullscreen mode Exit fullscreen mode

Both arrived because a desktop application registered itself with Chrome. But one told
Chrome "download it from this URL", and the other dropped a 2.9 MB CRX straight onto
the disk
for Chrome to load from the file.

There is a reflex of mine that needs correcting here. Both records say
from_webstore: true. Reading that flag as "so it came from the store" would be
wrong: the extension loaded from a local CRX carries the same true. The origin is
not in the flag, it is in those two-line JSON files in External Extensions.

When you install a desktop application, a quiet housemate moves into your browser too.
The lease is not in the panel either; it is in that folder.

Declared permissions and granted permissions are not the same thing

Chrome keeps three separate permission sets per extension. The constants in
extensions/browser/extension_prefs.cc, and the comments above them, separate the
three clearly:

  • active_permissions — curiously, the name in the code is kPrefDesiredActivePermissions. Its comment: the set of permissions the extension desires to have active, which may include more than the manifest requires if the extension has optional permissions.
  • granted_permissions — its comment: the set the user has approved either at install time or through an optional permissions request. Chromium also states why it tracks this: in order to alert the user of permissions escalation.
  • runtime_granted_permissions — permissions granted at runtime rather than at install time.

Alongside these sits a single boolean: withholding_permissions. The code's comment
explains it as a preference indicating whether requested host permissions are being
withheld from the extension, requiring them to be granted through the permissions
API or runtime host permissions.

That boolean, sitting between declaration and consent, was the thing I wanted to
measure.

Nine extensions, seven "all sites"

Here is the script that produces the table. It takes the records whose location is
1, 2 or 6, counts the broad patterns the manifest asks for (<all_urls>, *://*/*,
http://*/*, https://*/*), and checks whether those same patterns also appear in
granted_permissions:

import json, os
P = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Secure Preferences")
st = json.load(open(P, encoding="utf-8"))["extensions"]["settings"]
BROAD = {"<all_urls>", "*://*/*", "http://*/*", "https://*/*"}
def has(d, k): return bool(BROAD & set((d or {}).get(k) or []))
n = b = g = w = dbg = 0
for eid, v in sorted(st.items(), key=lambda x: (x[1].get("manifest") or {}).get("name", "")):
    m = v.get("manifest")
    if not m or v.get("location") not in (1, 2, 6):
        continue
    decl = has(m, "host_permissions") or bool(BROAD & {
        x for cs in (m.get("content_scripts") or []) for x in (cs.get("matches") or [])})
    grant = has(v.get("granted_permissions"), "explicit_host") or \
            has(v.get("granted_permissions"), "scriptable_host")
    kis = v.get("withholding_permissions")
    d = "debugger" in (m.get("permissions") or [])
    n, b, g, w, dbg = n + 1, b + decl, g + grant, w + bool(kis), dbg + d
    print(f"{m.get('name','?')[:34]:34s} loc={v.get('location')} declared={decl} "
          f"granted={grant} withheld={kis} debugger={d}")
print(f"\nextensions={n} broad_declared={b} broad_granted={g} withholding_on={w} debugger={dbg}")
Enter fullscreen mode Exit fullscreen mode

The output:

AdGuard Reklam Engelleyici         loc=1 declared=True granted=True withheld=False debugger=False
ChatGPT                            loc=1 declared=True granted=True withheld=False debugger=True
Claude                             loc=6 declared=True granted=True withheld=False debugger=True
DEPRECATED Secure Shell App        loc=1 declared=False granted=False withheld=False debugger=False
IP Address and Domain Information  loc=1 declared=False granted=False withheld=False debugger=False
Kimi                               loc=2 declared=True granted=True withheld=False debugger=True
React Developer Tools              loc=1 declared=True granted=True withheld=False debugger=False
[password manager — redacted]      loc=1 declared=True granted=True withheld=False debugger=False
User-Agent Switcher for Chrome     loc=1 declared=True granted=True withheld=False debugger=False

extensions=9 broad_declared=7 broad_granted=7 withholding_on=0 debugger=3
Enter fullscreen mode Exit fullscreen mode

(The output is verbatim with exactly one change: I redacted my password manager's
product name while pasting, and left the redaction visible in the row. The other
extensions request the same permissions on every machine they are installed on, so
there is nothing to hide there; which vault product I use is nobody's business.)

The totals: 9 user extensions, 7 declare broad host access, 7 were granted it, and
0 have withholding enabled.
withholding_permissions is false in all nine records.

One caveat: seven having the permission is not the same as seven running. I will
come back to the User-Agent Switcher row below — that extension is disabled, but its
grant is still in the ledger. The seven in the title counts permissions, not processes.

One thing deserves to be said plainly: the declared and granted columns matching is
not a bug, it is the design. I was the one who saw the permission screen at install
time, and I was the one who clicked "Add". What makes me think is the third column.
Chrome has a setting that lets me say "only run this extension when I click it", and
I have not used it on a single one of the nine.

Two separate doors: explicit_host and scriptable_host

When the script above says "broad access" it checks two separate fields, because
Chrome keeps two kinds of host permission apart. The field names in
extension_prefs.cc are kPrefExplicitHosts → explicit_host and
kPrefScriptableHosts → scriptable_host. Roughly: the first is the extension being
able to reach that address over the network, the second is being able to inject
scripts into it.

To make the distinction concrete, I counted the approved patterns in each field:

AdGuard Reklam Engelleyici     explicit=1 scriptable=8
ChatGPT                        explicit=1 scriptable=1
Claude                         explicit=1 scriptable=3
DEPRECATED Secure Shell App    explicit=0 scriptable=0
IP Address and Domain Informat explicit=1 scriptable=0
Kimi                           explicit=1 scriptable=1
React Developer Tools          explicit=1 scriptable=1
[password manager — redacted]  explicit=1 scriptable=1
User-Agent Switcher for Chrome explicit=1 scriptable=0
Enter fullscreen mode Exit fullscreen mode

The ad blocker has 8 scriptable patterns: http://*/* and https://*/* are both
there, and the remaining six are specific pages on its own domains. The User-Agent
Switcher is explicit=1, scriptable=0 — it reaches every address but puts no script
on the page, because its job is to rewrite requests, not to touch the document.

This distinction is worth paying attention to. "It runs on every site" can describe
two different things, and the two do not carry the same risk. If you are deciding
based on a single label, you do not know which of these two columns you are reading.

The default is written in the code, not the documentation

So why is withholding_permissions false? Because I never touched it, or because
that is what gets written at install time? Those are not the same thing: the first is
"no setting", the second is "there is a setting and it is off".

I checked the official documentation. Neither the permission-declaration page nor the
permissions-list page on developer.chrome.com states the default for a plain
host_permissions entry. The only explicit statement about timing is on the
optional_host_permissions line: granted by the user at runtime, instead of at
install time. MDN's host_permissions page says that most browsers treat
host_permissions as optional and that users can grant or revoke them on an ad hoc
basis — but that sentence is about browsers in general, not Chrome specifically.

The answer is in the code. The install-time path in extension_prefs.cc does this:
if permissions can be withheld from the extension and no special withholding creation
flag was given at install, it writes the default value into the preference. That
default is one line in the same file:

// The default value to use for permission withholding when setting the pref on
// installation or for extensions where the pref has not been set.
constexpr bool kDefaultWithholdingBehavior = false;
Enter fullscreen mode Exit fullscreen mode

So the false values on my machine are not a preference of mine. They are a default
written to disk at install time. The documentation does not say this; the source
does. That is not a contradiction — the docs say nothing wrong, they simply never
write this line down.

Diagram

Permissions cannot be withheld from Chrome's own parts

The left branch of that diagram is not a guess; my own data confirms it.
CanWithholdPermissionsFromExtension in extension_util.cc combines four
conditions: the extension must be displayed in extension settings, must not come
from a policy location, must not come from a component location, and must not be
allowlisted to script everywhere. The comment above the function gives the reason
outright: some extensions must retain privilege to all requested host permissions,
because withheld permissions could not be granted for an extension that does not
show up in chrome://extensions.

The definition of "component" is one line in manifest.h: IsComponentLocation
means kComponent (5) or kExternalComponent (10).

Now go back to the measurement. None of my location 5 and 10 records has a
withholding_permissions key at all. Not present and false — simply never
written. The file shows exactly what the code says. I like these one-to-one matches;
they are the cheapest proof that I understood a mechanism correctly.

Looking at the consent ledger for those same records produces a second result:

Chrome PDF Viewer                    loc= 5 granted_api=0 active_api=7
Chrome Web Mağazası Ödemeleri        loc=10 granted_api=2 active_api=1
Chrome built-in text-to-speech exten loc= 5 granted_api=0 active_api=5
Gemini in Chrome                     loc= 5 granted_api=0 active_api=2
Google Hangouts                      loc= 5 granted_api=0 active_api=4
Google Network Speech                loc= 5 granted_api=0 active_api=4
Google Network Speech                loc= 5 granted_api=0 active_api=4
Web Mağazası                         loc= 5 granted_api=0 active_api=0
Enter fullscreen mode Exit fullscreen mode

Seven of the eight rows have granted_api at zero while active_api goes up to
seven. The PDF viewer keeps seven API permissions active and not one of them is
written in the "the user approved this" ledger.

The single exception explains the rule: Chrome Web Mağazası Ödemeleri, at
location = 10. IsComponentLocation counts it as a component, but
kExternalComponent means "like a component, except installed from an update URL" —
and it does have a consent record: granted_api=2, active_api=1. Whatever goes
through an install gets a ledger entry too.

That is not a leak; it follows from the definition. The consent ledger exists to
track user consent, and components have no user-consent step. But it does clarify
something: trusting the browser and trusting what you installed into the browser are
not the same sentence. For the first one, there is no ledger you can audit.

The extension I disabled kept its grant

The User-Agent Switcher row has one property the table does not show: it carries
disable_reasons: [1]. In extensions/browser/disable_reason.h,
DISABLE_USER_ACTION = 1 << 0, which is 1. I turned it off myself.

But its granted_permissions record is still in place: *://*/* is still written
there.

That makes sense. The code's own comment told us what granted_permissions is for:
noticing permission escalation and alerting the user. If the consent record were
deleted, Chrome would ask for approval again on every disable-and-enable cycle. From
here I derive the following — I did not measure it, I am reasoning from the
mechanism: when you re-enable a disabled extension whose manifest still asks for the
same permissions, there is no difference between the approved set and the requested
set, so no escalation warning arises. The extension picks up where it left off.

The practical consequence: "I turned it off for now" is not a security measure, it is
a postponement. A disabled extension is not running, but its consent is in its pocket.

Approved but not active: one difference out of nine

I made a separate pass looking for differences between granted_permissions and
active_permissions. Per the code's comments, the first is the set the user approved
and the second is the set the extension desires to have active. In theory these can
diverge: an extension asks for a permission, gets approval, then stops asking for it,
and the consent record stays behind.

import json, os
P = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Secure Preferences")
st = json.load(open(P, encoding="utf-8"))["extensions"]["settings"]
diff = 0
for eid, v in st.items():
    m = v.get("manifest")
    if not m or v.get("location") not in (1, 2, 6):
        continue
    g = set((v.get("granted_permissions") or {}).get("api") or [])
    a = set((v.get("active_permissions") or {}).get("api") or [])
    if g != a:
        diff += 1
        print(f"{m.get('name')}: approved_not_active={sorted(g - a)} "
              f"active_not_approved={sorted(a - g)}")
print("differing records:", diff, "/ 9")
Enter fullscreen mode Exit fullscreen mode

Across nine extensions, one line came back:

Claude: approved_not_active=['system.display'] active_not_approved=[]
differing records: 1 / 9
Enter fullscreen mode Exit fullscreen mode

One permission. system.display — in Chrome's own documentation, the API used "to
query display metadata". (It does have setter methods, but the docs mark every one of
them as available only to ChromeOS Kiosk apps.) It is not in the extension's current
manifest, neither under permissions nor optional_permissions, yet it sits in the
granted set — so it looks like it was requested in an earlier version, approved, and
later dropped, with the consent record left behind.

Two things are worth separating here. First: the reverse direction is empty, so no
extension is "active but not approved". That matters, because the opposite would be a
sign of inconsistency. Second: this difference is not a risk, it is a residue.
system.display is not being used right now. But the residue itself shows something —
the consent ledger accumulates. Even when the set an extension asks for shrinks, the
approval you gave stays on file, and if the extension puts that permission back into
its manifest, no escalation warning will appear. You returned the book to the library,
but your card still says you may borrow it.

Exactly one permission granted at runtime

The runtime_granted_permissions field holds permissions granted after install
rather than at install time — anything requested through the permissions API or
handed over manually from the site-access setting. I counted how many of the 22
records have it filled in:

{"api": ["clipboardWrite"], "explicit_host": [], "manifest_permissions": [], "scriptable_host": []}
records with a runtime grant: 1 / 22
Enter fullscreen mode Exit fullscreen mode

One. One in the entire profile. And it is my password manager's clipboard-write
permission — something it asked for at some point so it could copy a password, and I
granted. The explicit_host and scriptable_host fields are empty: through that
path I have never given any extension a single host permission.

I think this is the most revealing number in the whole exercise. Chrome has a second
path designed as "withhold first, grant if the user wants it"; it is written cleanly
in the code, it has its own field in the ledger, and in my profile it has fired
once — not for a host permission, but to write to the clipboard. The mechanism
works. As a user, I have almost never walked through that door.

Three extensions want debugger

The part of this measurement that gave me the most pause was not a host pattern. It
was a single API permission: three of the nine ask for debugger.

The permissions list on developer.chrome.com spells out the warning this permission
shows the user: "Read and change all your data on all websites." The same warning is
produced by pageCapture, proxy, tabCapture and webAuthenticationProxy. So
even if you never looked at the host pattern list, this one permission opens the same
door.

All three are AI assistant extensions. Reading the page and acting on it for me is
exactly what I want from them, so seeing debugger was not a shock. Still, the
distance between "can read the page" and "can drive the browser over the debugging
protocol" is longer than the single line on the permission screen suggests. I want to
keep the difference between installing that knowingly and installing it without
noticing — and that is what this exercise gave me.

There is also declarativeNetRequestWithHostAccess, whose definition in the list is
blunt: it requires host permissions for all actions. Permissions lean on each other.
A list that looks innocent read line by line collapses into a single sentence once you
read it together.

What stayed on disk

Finally I looked at the file side. The Extensions/ folder holds 10 extension
directories plus a Temp. Nine of them are the extensions in the table above; the
tenth is Chrome Web Mağazası Ödemeleri, the only component with a directory on disk —
precisely the location = 10 record installed from an update URL. The remaining
components ship inside Chrome's own bundle, so they do not appear here. But the version subdirectories inside them add up to
16. The cause is a single extension: my ad blocker keeps 7 of its versions on
disk at once.

cd ~/Library/Application\ Support/Google/Chrome/Default/Extensions
for d in */; do printf "%s %s\n" "$(ls -1 "$d" | wc -l | tr -d ' ')" "$d"; done | sort -rn | head -3
echo "version directories in total: $(ls -1 */*/ -d | wc -l | tr -d ' ')"
Enter fullscreen mode Exit fullscreen mode
7 bgnkhhnnamicmpeenaelnjfhikgbkllg/
1 pnlccmojcmeohlpggmfnbbiapkmbliob/
1 pnhechapfaindjhompbnflcldabbghjo/
version directories in total: 16
Enter fullscreen mode Exit fullscreen mode

All seven are consecutive patch releases of the same extension. The disk footprint is
not dramatic, but it shows something: an update is not obliged to delete the old one.
The manifest in the ledger points at the new version while the disk carries seven.

There is one more thing: the DEPRECATED Secure Shell App row in the table. Its
manifest carries an app key and a manifest_version of 2 — so it is not an
extension but an old Chrome app. Despite "DEPRECATED" being in its name it is still in
the ledger, seven API permissions still show as approved, and its disable_reasons
field was never written. The "is it disabled" question is answered in the same
dictionary: active_bit: false. Chrome does not consider it disabled, it simply is
not active — the record just sits there. The consent record for abandoned software
outlives the software.

A checklist

Here is the half-hour version you can run on your own machine:

  1. Read extensions.settings out of Secure Preferences and compare the record count with the number you see in the panel. A difference is normal — not knowing the reason for the difference is not. Go find it.
  2. Separate the records whose location is 1, 2 or 6. Those are the extensions you installed. (On Windows, add 3 = kExternalRegistry — that is the most common sideload path there; also 4 for unpacked and 8 for command-line loads.)
  3. Look at the 2 and 6 ones specifically: a desktop application registered those with your browser. They may all be legitimate; you should still recognise the list.
  4. Search granted_permissions for <all_urls> or *://*/*. How many of them genuinely need every site?
  5. Count the withholding_permissions values. If they are all false, you have never used that setting.
  6. Search the permissions lists for debugger, pageCapture, proxy and tabCapture. Independently of host patterns, these are an "everything on every site" door.
  7. Compare the granted_permissions and active_permissions sets. "Approved but not active" rows are residue from old permissions; if anything comes back "active but not approved", stop — that is not an expected state.
  8. Count the records with a non-empty runtime_granted_permissions. If the number is close to zero, you have never used Chrome's second permission path.
  9. If you want to restrict one, the place is the extension's Details page, next to "Allow this extension to read and change all your data on websites you visit". The option names hold a small trap: Chrome's Help page says On select, while Chrome's own UI strings (chrome/app/extensions_strings.grdp) define that option as On click — and the same file also carries a newer set (Ask on every visit, Allow on specific sites, Allow on all sites). Whichever your screen shows, that is the row you want. This is the second place where the docs and the code diverge; the first was the default never being written down.

My own decisions: the ad blocker and the password manager stay on all sites, because
that is their job. React Developer Tools would work fine with "On specific sites" —
right now the ledger says "all sites" for it too, and that is the first row I will
change after this exercise. I am keeping the debugger permission on the AI
extensions — but now I am keeping it knowingly, and before this exercise I did not
know I was keeping it at all.

Conclusion

This measurement did not uncover a hole. What it uncovered is duller and, I think,
more instructive: the system behaves exactly as designed, and the design treats the
broadest permission as the default. The restriction mechanism exists, it works, it is
written cleanly in the code — nobody just turns it on for me.

In security the real question is usually not "which setting is wrong". It is "which
default decided on my behalf, and which decision did I never see". That answer is not
in the panel; panels show you the state after the decision. The decision itself sits
in a one-line boolean written to disk at install time.

Official Sources

Top comments (0)