← Blog
How it works

Blocking a request does not need to read the page

“An extension wants permission to read and change my data on every website I visit, and I want to know what it actually spends that permission on.”

A content blocker is the clearest case of a permission that looks wrong. The prompt says the extension may read and change your data on all websites. The job, as described, is to stop things from loading. Stopping something from loading is a refusal, and a refusal does not obviously require reading anything.

The suspicion is reasonable and the answer is specific: blocking does not need it. Chromium's rule engine will block, allow and upgrade requests for an extension that holds no host permissions whatsoever. What needs host access is everything else a blocker does, and the list is short enough to name.

The clearest way to see that line is an extension that installed on the far side of it until April 2025, and then stopped.

The install that asked for no host access, and the week it stopped

uBO Lite is the declarative sibling of uBlock Origin, written against Chrome's declarativeNetRequest API. In version 2025.4.6.1238 its Chromium manifest asked for no host permissions at install, offering them as optional instead. (That is not the same as asking for nothing: Chrome's reference says the declarativeNetRequest permission itself "triggers a permission warning at install time".)

"permissions": [ "activeTab", "declarativeNetRequest", "scripting", "storage" ],
"optional_host_permissions": [ "<all_urls>" ]

One week later, in 2025.4.13.1188, optional_host_permissions was gone and the same value had moved into the mandatory key:

"host_permissions": [ "<all_urls>" ]

That is still where it is at 2026.1012.1545, the current version, where the permissions array has also grown alarms, offscreen, unlimitedStorage and userScripts.

The commit that made the change, in uBlock Origin's repository on 9 April 2025, is titled "Remove "permission-less" status at install time". Its message first describes what a user gives up by narrowing access again. Quoted as written, including the grammar:

One can set "Site access" to "On click" to revoke broad host permissions, and grant host permissions to only specific site.

In such mode, uBOL will still block through the DNR API, but no cosmetic or scriptlet filtering will occurs, as these requires permission to "read and change data" on websites for which higher filtering mode is desired.

Then it gives the reason for the change:

Some browsers do not automatically grant broad host permissions even when an extension asks for broad permissions at install time, and going forward all browsers will likely adopt this approach, and thus it no longer made sense for uBOL to default to no broad hosts permissions at install time, especially given this leads to issues with no solution -- issues solved with the new approach (e.g. like the ability to deploy uBOL in Optimal mode by default).

Read the first part for what it concedes. Without host access, uBO Lite was not failing to block. It blocked. What it could not do was the other half, and the project's own example of an unsolvable issue is a deployment that wants that other half switched on from the start.

Where the browser draws the line

Chromium decides this per extension, from a single question, and the answer is a two-valued enum. An extension can hold declarativeNetRequest, or the narrower declarativeNetRequestWithHostAccess, and that choice is read once when its rules are loaded:

HostPermissionsAlwaysRequired GetHostPermissionsAlwaysRequired(
    const Extension& extension) {
  DCHECK(HasAnyDNRPermission(extension));
  const PermissionsData* permissions = extension.permissions_data();

  if (permissions->HasAPIPermission(
          mojom::APIPermissionID::kDeclarativeNetRequest)) {
    return HostPermissionsAlwaysRequired::kFalse;
  }

  // Else the extension only has the kDeclarativeNetRequestWithHostAccess
  // permission.
  return HostPermissionsAlwaysRequired::kTrue;
}

The enum itself is where the line is actually written down, and it is written down once, in a comment:

// Specifies whether and how extensions require host permissions to modify the
// request.
enum class HostPermissionsAlwaysRequired {
  // In this case, all actions require host permissions to the request url and
  // initiator.
  kTrue,
  // In this case, only redirecting (excluding upgrading) requests and modifying
  // headers require host permissions to the request url and initiator.
  kFalse,
};

So for an extension holding plain declarativeNetRequest, which is what uBO Lite's manifest declares, two action types need host access to the request URL and its initiator: redirecting a request, and modifying its headers. Blocking does not. Allowing does not. Nor does allowAllRequests, and nor does upgrading a request's scheme, which the comment excludes by name.

Chrome's own reference agrees, in fewer words: declarativeNetRequest "provides implicit access to allow, allowAllRequests and block rules", while with declarativeNetRequestWithHostAccess "you must request host permissions before you can perform any action on a host". The reference does not list upgradeScheme among the implicit ones; the source does.

The enforcement matches the comment. When the matchers have agreed on an action, one expression decides whether permission is needed:

  bool requires_host_permission =
      always_require_host_permissions ||
      final_action->type == RequestAction::Type::REDIRECT;
  if (!requires_host_permission || page_access == PageAccess::kAllowed) {
    return ActionInfo(std::move(final_action),
                      /*notify_request_withheld=*/false);
  }

  // `requires_host_permission` is true and `page_access` is withheld or denied.
  bool notify_request_withheld = page_access == PageAccess::kWithheld &&
                                 !final_action->IsAllowOrAllowAllRequests();
  return ActionInfo(std::nullopt, notify_request_withheld);

Only REDIRECT is named by type. Everything else passes on the strength of the permission the extension already has. Header rules are handled in a second pass with the same rule stated as a comment, and it is the strongest wording in any of these files:

    // Evaluate modifyHeaders rules for this extension if and only if it has
    // host permissions for the request url and initiator.

One detail is worth keeping, because it is the exception to a pattern that holds elsewhere. When an action is withheld for want of permission, the browser does not silently drop it. It sets a flag whose declaration says what it is for:

    // Whether the extension should be notified that the request was unable to
    // be redirected as the extension lacks the appropriate host permission for
    // the request. Can only be true for redirect actions.
    bool notify_request_withheld = false;

The header pass has the same notification for the same reason. Where it goes is worth checking, because it is not the extension. Chrome hands it to the toolbar's extension-action machinery, the part that offers to run an extension on the current site, and only for the top-level frame: requests from subframes are ignored, in the code's words, "since there's no way to surface this to the user". So a redirect lost to a missing permission is surfaced to the person who can grant it, for the page they are on. A cosmetic filter that never ran leaves no such trace in this code.

The half that is not a rule at all

The other half of what a blocker does is not expressed as rules, and that is why no amount of reading the rule engine finds it. Hiding an element that was already delivered, and running a scriptlet that neutralises something on the page, are both code running in the page. uBO Lite registers them as content scripts: unregisterContentScripts to clear, then registerContentScripts with directives built per filtering mode, loading /js/scripting/css-specific.js, /js/scripting/css-generic.js, and per-world scriptlet bundles for both the ISOLATED and MAIN worlds.

The match patterns on those directives are assembled from the permission state directly:

    const hasBroadHostPermission =
        filteringModeDetails.optimal.has('all-urls') ||
        filteringModeDetails.complete.has('all-urls');

With broad access, the directive matches everywhere and the sites set to no filtering or basic become excludeMatches. Without it, the directive's matches are built from the individual hostnames in the optimal and complete sets, which are the hostnames access was granted for. The extension is not asking permission to inject; it is declaring injection only where injection is possible.

uBO Lite states the resulting split in its own interface, and the wording is the clearest summary of the whole mechanism that exists anywhere in these sources. Its four modes are no filtering, basic, optimal and complete, and the descriptions read:

basic. Basic network filtering from selected filter lists. Does not require permission to read and modify data on websites.

optimal. Advanced network filtering plus specific extended filtering from selected filter lists. Requires broad permission to read and modify data on all websites.

complete. Advanced network filtering plus specific and generic extended filtering from selected filter lists. Requires broad permission to read and modify data on all websites. Generic extended filtering may cause higher web page resources usage.

"Extended filtering" is the term for the half that touches the page, and it is the only thing the two higher modes add. There is also a second, independent gate for one case: filters imported from a custom list need the browser's user-scripts toggle as well, which the interface explains in its own string, "To enforce cosmetic or scriptlet filters from imported lists, you must grant uBO Lite permission to run user scripts."

What happens the moment you revoke it

A permission taken away in the browser's own interface does not arrive as an error. uBO Lite reconciles against it on its own, by asking the browser what it currently holds:

    const afterAllowedHostnames = new Set(hostnamesFromMatches(afterPermissions.origins || []));
    await persistHostPermissions(afterAllowedHostnames);
    const hasBroadHostPermissions = afterAllowedHostnames.has('all-urls');

and then moving its own configuration to match:

    if ( beforeMode > MODE_BASIC && hasBroadHostPermissions === false ) {
        await setDefaultFilteringMode(MODE_BASIC);
        modified = true;
    }

Per-site settings are reconciled the same way: any hostname set to optimal or complete that is not covered by a granted origin is put back to the default mode. One further capability goes with the permission rather than with a mode, and the comment on it is blunt:

    // Strict-block rules can only be enforced with omnipotence
    if ( hasOmnipotence === false ) {

So revoking is not breakage. The extension lands in a defined, named configuration, which is basic, and the capability it loses is the one its own interface says that mode does not have.

What it looks like with the permission withheld

Reading two codebases says what should happen. To see it happen, uBO Lite 2026.1012.1545 was loaded unpacked into headless Chromium 140 twice, on 18 October 2026: once as shipped, and once from a copy whose manifest moves <all_urls> back from host_permissions to optional_host_permissions, which is the April 2025 shape, with nothing then granted. Every hostname resolved to a local server, so no request left the machine. The test page was served as dw.com, a site with a specific EasyList hiding rule for .advertisement, and loaded a Google Analytics script and pixel.

Host access granted No host access
Origins granted, per chrome.permissions.getAll() <all_urls> none
Rulesets enabled the same six the same six
www.google-analytics.com/collect pixel blocked blocked
www.google-analytics.com/analytics.js redirected to uBO Lite's stand-in; the server never saw the request fetched from the server and run
div.advertisement on dw.com hidden shown

The pixel is a block rule, and it held without any host access, as the enum says it should. The script is the interesting row. uBO Lite does not block analytics.js; it redirects it to a harmless stand-in that keeps pages working, the subject of an earlier post here. A redirect needs host access. Without it the rule engine does not fall back to blocking: the redirect is withheld and the original request proceeds. So the request uBO Lite treats most carefully is the one that gets through.

What the asymmetry is actually good for

The useful consequence is that "this extension wants to read every page" and "this extension cannot work without reading every page" are different claims, and the second one is testable against a specific list. For a declarative blocker on Chromium, what the broad permission buys is: element hiding, scriptlets, strict blocking, request redirection, and header rewriting. What survives without it is the block and allow rules.

That is not a small remainder, and it is not most of the work either. uBO Lite's own build records count what its six default rulesets compile to: 19,009 network rules, and 26,696 element-hiding selectors that only run with host access. Within those network rules, the three largest lists carry 873 redirects and 51 header modifications, which also need it.

It cuts the other way too. An extension that asks for no host access at install has not thereby shown that it does everything its description implies. uBO Lite without host access blocked requests and did not hide anything, and the project decided that was the wrong default, for reasons its commit gives in full above.

The boundary of what was checked

Every quotation above is verbatim from a file fetched in full, and each was re-fetched and compared byte for byte at review on 18 October 2026. The commit message was compared against GitHub's API copy of it.

The Chromium quotations are from files under extensions/browser/api/declarative_net_request/ at commit b4c79236b66d0903b92eb14454cf42caf50415a9: constants.h, composite_matcher.cc, composite_matcher.h, rules_monitor_service.cc and ruleset_manager.cc, plus chrome/browser/extensions/api/chrome_extensions_api_client.cc for where a withheld notification goes. The uBO Lite quotations and counts are from the chromium/ tree of its own repository at version 2026.1012.1545, and the two older manifests are at the two commits a week apart in April 2025.

Four limits are worth stating plainly.

One engine. The measurement is one run each way in one browser, Chromium 140, headless. Chrome, Edge and Brave share this rule engine, but none of them was run, and Firefox, which implements declarativeNetRequest with a different permission model, was neither run nor read. Safari appears in uBO Lite's code with its own exceptions, which were not investigated. Nothing here is a claim about blockers in general.

Not granted is not quite "On click". The test removed host access by never granting it, which Chromium treats as denied. Setting "Site access" to "On click" withholds it instead, which is the case that raises the toolbar notification described above. The rule engine skips the redirect in both cases; only the notification differs, and the "On click" setting itself was not exercised.

This is mostly Chromium's implementation, not its documentation. Chrome's reference was read and agrees on block and allow; everything finer, including upgradeScheme and where notifications go, comes from the source that enforces it, which is better evidence about behaviour and worse evidence about intent.

The counts are uBO Lite's own. The 19,009 and 26,696 figures are read from its ruleset-details.json, and the redirect and header counts from its compiled rulesets; they were not recomputed from the original filter lists.