Handling Restricted URLs and Tab Permissions
Detect the tabs an MV3 extension may never touch — chrome://, the Web Store, PDF viewers and other extensions' pages — and degrade gracefully instead of throwing.
Table of Contents
Every extension eventually gets a bug report that reads “it does nothing on some pages”. The pages in question are the ones the browser protects: chrome:// internals, the extension gallery, the new tab page, another extension’s pages, and in some builds the built-in PDF viewer. No host permission grants access to them, and an injection attempt rejects with a message most users will never see. Detecting them first is the difference between a broken feature and a clear one. This guide is part of tabs API and window management.
What is off limits, and why
Step-by-step
1. Write one predicate and use it everywhere
1const BLOCKED_SCHEMES = ["chrome:", "chrome-untrusted:", "about:", "edge:", "moz-extension:",
2 "chrome-extension:", "devtools:", "view-source:"];
3const BLOCKED_HOSTS = ["chromewebstore.google.com", "chrome.google.com", "addons.mozilla.org"];
4
5export function isScriptable(url) {
6 if (!url) return false; // no tabs permission, or a tab still loading
7 let u;
8 try { u = new URL(url); } catch { return false; }
9 if (BLOCKED_SCHEMES.includes(u.protocol)) return false;
10 if (BLOCKED_HOSTS.includes(u.hostname)) return false;
11 if (u.protocol === "file:") return false; // needs explicit file-access opt-in
12 return u.protocol === "http:" || u.protocol === "https:";
13}
Execution context: a shared module, usable from the service worker and from extension pages alike. Note chrome-extension: is blocked here even for your own pages — inject into those with chrome.scripting only when you specifically mean to, since they already share your origin.
2. Check before you inject, not after
1async function runOnActiveTab() {
2 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
3 if (!tab) return;
4
5 if (!isScriptable(tab.url)) {
6 await chrome.action.setBadgeText({ tabId: tab.id, text: "—" });
7 await chrome.action.setTitle({ tabId: tab.id, text: "Not available on this page" });
8 return;
9 }
10
11 await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content/run.js"] });
12}
Execution context: the service worker. Setting a per-tab title and badge is the cheapest honest feedback available — it explains the absence without a notification the user did not ask for.
3. Handle the rejection anyway
A URL can change between the query and the injection, and activeTab grants can expire. Treat the rejection as expected rather than exceptional.
1async function tryInject(tabId, files) {
2 try {
3 return await chrome.scripting.executeScript({ target: { tabId }, files });
4 } catch (err) {
5 if (/Cannot access|Extension manifest must request permission|chrome:\/\//i.test(err.message)) {
6 return null; // restricted or not granted — a normal outcome
7 }
8 throw err; // anything else is a real bug
9 }
10}
Execution context: the service worker. Chrome, Firefox and Safari all word this rejection differently, which is why the check matches loosely; the important part is that a restricted page returns null rather than propagating.
4. Know when tab.url will be empty
Without the tabs permission or a matching host permission, tab.url is undefined — and your predicate will correctly say “not scriptable” for pages you could have handled. Under activeTab, the URL becomes readable only after the user invokes the extension.
1chrome.action.onClicked.addListener(async (tab) => {
2 // activeTab is granted for this tab right now, so tab.url is populated here.
3 if (!isScriptable(tab.url)) return;
4 await tryInject(tab.id, ["content/run.js"]);
5});
Execution context: the service worker, inside the click handler where the activeTab grant is live. The grant lasts until the tab navigates or closes — the lifecycle covered in injecting only after a user gesture with activeTab.
5. Offer file access rather than failing silently
file:// URLs are scriptable, but only if the user ticked “Allow access to file URLs”. You can detect the setting and ask.
1const allowed = await chrome.extension.isAllowedFileSchemeAccess();
2if (!allowed) {
3 await chrome.tabs.create({ url: `chrome://extensions/?id=${chrome.runtime.id}` });
4}
Execution context: the service worker. You cannot grant the setting yourself; opening the details page is as close as an extension may get, and it is far better than a feature that appears broken on local files.
Three reasons a tab looks unavailable, and telling them apart
“Cannot run here” covers three quite different situations, and an extension that words them identically will be judged as broken in two of the three. The user-facing difference is whether there is anything they can do about it.
Permanently restricted. A chrome:// page, the Web Store, another extension. Nothing the user does will help. The right response is a disabled action with an explanatory tooltip and no prompt of any kind.
Not yet permitted. An ordinary site the extension has no host permission for. The user can fix this in one click, and should be offered the chance:
1async function offerAccess(tab) {
2 const origin = new URL(tab.url).origin + "/*";
3 const has = await chrome.permissions.contains({ origins: [origin] });
4 if (has) return true;
5 return chrome.permissions.request({ origins: [origin] }); // needs a gesture
6}
Execution context: an extension page such as the popup, inside a click handler. Calling this from the worker outside a gesture rejects, which is the most common reason a “grant access” button silently fails.
Unknown. The URL is not readable, because there is neither a host permission nor an active activeTab grant. This is the case most often misreported: the extension says “not supported on this page” about a page it supports perfectly well. The honest response is to say nothing until the user invokes the extension, at which point activeTab fills in the URL and the real answer becomes available.
1function classify(tab) {
2 if (!tab?.url) return "unknown";
3 return isScriptable(tab.url) ? "ok" : "restricted";
4}
Execution context: a shared module. Three states, three different pieces of copy, and only one of them worth a prompt — that distinction is the entire difference between an extension that feels considered and one that feels broken.
Cross-browser variation
- Chrome / Edge: blocks
chrome://,chrome-untrusted://, the Web Store origins and other extensions’ pages. Enterprise policy can addruntime_blocked_hosts, which produces the same rejection on ordinary sites — worth mentioning in your error copy. - Firefox: blocks
about:pages,addons.mozilla.organd its own reader view. Firefox is stricter aboutfile:access and exposes no equivalent ofisAllowedFileSchemeAccess; the user grants it per-extension in the add-on settings. - Safari: blocks Safari’s own settings and start pages. Safari’s tab records are also more likely to omit
urlfor tabs the extension has not been granted, so treat an empty URL as “unknown” rather than “restricted”. - All three: the extension gallery for each browser is protected on every engine, not only on its own. A Chrome extension cannot script
addons.mozilla.orgeither.
Verification
- Sweep the currently open tabs and see what your predicate says:
1(await chrome.tabs.query({})).map((t) => [t.id, t.url ?? "(hidden)", isScriptable(t.url)]);
2// [[12, "https://example.com/", true], [13, "chrome://extensions/", false], [14, "(hidden)", false]]
Execution context: the service worker console. A (hidden) entry means no permission for that tab, not that the tab is restricted — the two look the same to the predicate and should be worded differently to the user.
- Open
chrome://extensionsand click your action; confirm the badge reads as unavailable and nothing throws in the console. - Open a local
.htmlfile, confirm the file-access prompt path, then enable the setting and confirm injection succeeds.
FAQ
Can I ask for permission to script chrome:// pages?
No. There is no permission that grants it, and any workaround that appears to is a policy violation. DevTools extensions reach a narrow, sanctioned slice of this through chrome.devtools, covered in building a custom DevTools panel.
Why does my extension work on PDFs in one browser and not another?
The built-in PDF viewer is itself an extension-like surface. Chrome exposes the embedding page but not the viewer’s internal document; Firefox’s pdf.js is a regular page in some configurations. Test explicitly rather than assuming.
Should I disable the toolbar action entirely on restricted pages?
Disabling with chrome.action.disable(tabId) is clearer than a button that opens a popup saying “not here”. Keep the tooltip informative so the user knows why — the per-tab pattern in enabling and disabling the toolbar action per tab.
Related
- Querying the active tab safely — getting a tab record you can trust.
- Opening and tracking extension pages in tabs — the tabs you own rather than borrow.
- Injecting only after a user gesture with activeTab — the grant that fills in a hidden URL.
- Tabs API and window management — the parent guide to tab control in MV3.