Handling Injection Errors on Restricted Pages
Handle chrome.scripting failures on pages extensions cannot access: chrome:// and edge:// URLs, the Web Store, PDFs, file URLs, view-source and error pages, with pre-checks, clear errors and useful UI fallbacks.
Table of Contents
The user clicks the toolbar button on the new tab page, on chrome://settings, on the Chrome Web Store, or on a PDF, and nothing happens — or the worker logs “Cannot access a chrome:// URL”, “The extensions gallery cannot be scripted”, or “Cannot access contents of the page. Extension manifest must request permission to access the respective host.” Some pages are off-limits to every extension by design; others need a permission the user has not granted. An extension that treats every injection failure as a bug spams error reports and leaves users staring at a dead button. This guide classifies the failures and turns each into the right response. It belongs to scripting API and dynamic injection.
Why some pages can never be scripted
Browsers protect pages whose integrity matters more than any extension’s feature: their own internal UI (chrome://, edge://, about:), other extensions’ pages, the extension store itself (so an extension cannot manipulate reviews or install buttons), and certain built-in viewers. The rules are hard-coded and cannot be overridden by any permission. A second group can be scripted only with an explicit user opt-in: file:// URLs require “Allow access to file URLs” in the extension’s details page. A third group is ordinary web pages the extension lacks host access to — because the user restricted site access, because the host was never requested, or because a page in a cross-origin iframe is not covered. Each group calls for a different message to the user.
Step-by-step: classify and respond
1. Pre-check URLs that can never work
1// shared/restricted.js
2const NEVER = [
3 /^(chrome|edge|brave|opera|vivaldi|about|chrome-search|devtools|view-source):/i,
4 /^chrome-extension:\/\/(?!__MSG_@@extension_id__)/i,
5 /^moz-extension:|^safari-web-extension:/i,
6 /^https:\/\/chromewebstore\.google\.com\//i,
7 /^https:\/\/chrome\.google\.com\/webstore/i,
8 /^https:\/\/microsoftedge\.microsoft\.com\/addons/i,
9 /^https:\/\/addons\.mozilla\.org\//i,
10];
11
12export function restrictionFor(url) {
13 if (!url) return "unknown";
14 if (NEVER.some((re) => re.test(url))) return "browser-protected";
15 if (url.startsWith("file:")) return "file";
16 return null;
17}
Execution context: a shared module for the worker and popup. Checking before injecting avoids a pointless call and lets the UI explain immediately. The list covers the common cases; the injection error remains the final authority, because vendors add protected pages and Chromium forks have their own internal schemes. tab.url may be undefined without the tabs permission or a grant, which is itself a signal (step 3).
2. Wrap injection and classify the error
1export async function inject(tabId, opts) {
2 try {
3 return { ok: true, results: await chrome.scripting.executeScript({ target: { tabId }, ...opts }) };
4 } catch (err) {
5 const m = String(err?.message ?? err);
6 let reason = "unknown";
7 if (/Cannot access a (chrome|edge|about)|extensions gallery|chrome-extension:\/\//i.test(m)) reason = "browser-protected";
8 else if (/file:\/\/|access to file URLs/i.test(m)) reason = "file";
9 else if (/permission to access|Cannot access contents of/i.test(m)) reason = "no-host-access";
10 else if (/No tab with id|Frame with ID/i.test(m)) reason = "gone";
11 else if (/error page/i.test(m)) reason = "error-page";
12 return { ok: false, reason, message: m };
13 }
14}
Execution context: the service worker. The error strings are not a formal API and vary slightly between versions and engines, so match on stable fragments and fall back to unknown. Returning a classified result instead of throwing lets every caller handle restrictions the same way, and lets you exclude expected reasons from error reporting.
3. Respond in the UI by reason
1// popup.js
2const MESSAGES = {
3 "browser-protected": "Browsers don't let extensions run on this page. Open a regular website to use Readable.",
4 "file": "To use Readable on local files, turn on “Allow access to file URLs” in the extension’s details.",
5 "no-host-access": "Readable doesn't have access to this site yet.",
6 "error-page": "This page didn't load. Reload it and try again.",
7 "unknown": "Something went wrong on this page.",
8};
9
10function renderUnavailable(reason, tab) {
11 status.textContent = MESSAGES[reason] ?? MESSAGES.unknown;
12 if (reason === "no-host-access") showGrantButton(new URL(tab.url).origin);
13 if (reason === "file") showLinkToDetails();
14}
Execution context: the popup. A clear sentence for each reason prevents users from concluding the extension is broken. Offer an action only where one exists: a grant button for missing host access (inside the popup’s click handler, so permissions.request is allowed) and a link to the extension’s details page for file access.
4. Open the details page for file access
1function showLinkToDetails() {
2 link.textContent = "Open extension settings";
3 link.addEventListener("click", () =>
4 chrome.tabs.create({ url: `chrome://extensions/?id=${chrome.runtime.id}` }));
5}
6
7const fileAllowed = await chrome.extension.isAllowedFileSchemeAccess();
Execution context: the popup. Extensions can open chrome://extensions with tabs.create, even though they cannot script it. isAllowedFileSchemeAccess tells you whether the toggle is already on, so you only show the instruction when needed. In Firefox, local files are accessible to extensions with the right host permissions without a separate toggle in many cases.
5. Keep expected failures out of error reports
1const EXPECTED = new Set(["browser-protected", "file", "no-host-access", "gone", "error-page"]);
2const r = await inject(tabId, opts);
3if (!r.ok && !EXPECTED.has(r.reason)) reportError(new Error(r.message), { feature: "inject", reason: r.reason });
Execution context: the service worker. Without this filter, a large share of reported “errors” are users clicking the button on the new tab page, and real failures drown in noise. Count expected reasons in aggregate if you want to know how often users try the feature on protected pages — that may point to a discoverability problem.
6. Handle the PDF viewer
1const isPdf = tab.url?.toLowerCase().split("?")[0].endsWith(".pdf");
2if (isPdf) status.textContent = "Readable can't read PDFs open in the browser's viewer. Download the file and open it in Readable's PDF reader.";
Execution context: the popup. Chrome’s built-in PDF viewer runs in an internal extension that other extensions cannot script; the tab’s URL still looks like an ordinary https://…/file.pdf. Detecting PDFs by URL (or by tab.title heuristics) lets you explain rather than fail. Some extensions offer their own PDF viewer page as an alternative.
7. Test against the restricted list
Build an end-to-end test that clicks the action on a set of URLs — chrome://newtab, the Web Store, a file:// page, a PDF, an http error page, an ungranted site — and asserts the popup shows the expected message and no error is reported. The list rarely changes, and the test catches regressions in classification immediately.
Common mistakes
- Reporting every injection failure. Most are expected restrictions.
- Silent failure. A button that does nothing looks broken; explain.
- Offering a grant on protected pages. No permission can unlock them.
- Relying on
tab.urlalone. It may be undefined; handle “unknown”. - Matching full error strings. Wording changes between versions; match fragments.
Cross-browser variation
- Chrome / Edge: internal schemes, the Web Store and the PDF viewer are protected;
file://needs the details-page toggle. - Firefox:
about:pages,addons.mozilla.org, andmoz-extension://pages of other add-ons are protected; Firefox’s PDF viewer (PDF.js) is a privileged page that extensions cannot script either. - Safari: Safari’s own pages and the App Store are protected; per-site permission prompts mean “no-host-access” is common until the user allows the site.
Verification
- Click the action on
chrome://extensions: the popup explains, no error is reported. - Click on a
file://page with file access off: the popup offers to open settings. - Click on an ungranted site: the popup offers a grant; after granting, the feature works.
- Inject into a tab and close it during the call: the result is classified as
gone, not reported.
FAQ
Can enterprise policy allow injection into protected pages?
Not into browser-internal pages or the store. Policy can grant host access to ordinary sites and block hosts.
Why does injection fail on a site I have access to?
Check frames: the main frame may be accessible while an iframe is from another origin. Also check for an error page — injection fails if the page failed to load.
Can I detect protected pages without the tabs permission?
Not reliably; without access, tab.url is undefined. Treat undefined as “unknown” and let the injection result decide.
Related
- Handling restricted URLs and tab permissions — the tabs side of restrictions.
- Handling user-restricted site access — the grantable case.
- Running content scripts on file URLs and special pages — the file opt-in in depth.
- Scripting API and dynamic injection — the parent topic.