Script Injection and Permission Design in Chrome Extensions — Risks of content_scripts and Solutions with the scripting API

Introduction

When developing a bookmark tool that retrieves metadata from arbitrary web pages using a Chrome extension, I specified "https://*/*" in the matches field of content_scripts. I then received feedback that the permissions were too broad (“broad permissions”).

I initially thought that allowing all sites was reasonable because the tool retrieves information from arbitrary pages. However, after researching further, I learned that the mechanism of always-on injection used by content_scripts itself can become a security concern when the target is an unspecified set of websites.

This article summarizes the risks of content_scripts and the solution using the scripting API recommended in Manifest V3.


Summary

There are three ways to retrieve page information in a Chrome extension.

  1. content_scripts — automatically inject a script into the page and retrieve data from the DOM
  2. scripting API — temporarily inject a function and retrieve data from the DOM
  3. chrome.tabs API — directly retrieve browser-managed information (URL, title, etc.) without script injection

Risk of content_scripts:

If matches (the URL pattern of pages where scripts are injected) is configured too broadly, the design ends up injecting scripts indiscriminately.

Solution:

If the target sites are unspecified, avoid using content_scripts. Instead, combine the activeTab permission with the scripting API so that scripts are injected only when the user performs an action.


1. Basic Design of Chrome Extensions: Process Separation

Chrome extensions divide responsibilities across mainly two different processes. Understanding this assumption is necessary for discussing permission design.

Service Worker (background.js)
A process that runs in the background of the browser. It performs API communication and monitors events, but it cannot directly access the DOM of a web page (the document object does not exist in this environment).

Content Scripts (content.js)
A process that runs inside the web page. Because it runs in the page context, it can directly read and modify the DOM.

Reference:
https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts


2. The Injection Mechanism and Risks of content_scripts

content_scripts automatically inject scripts at the moment a page whose URL matches the pattern specified in matches in manifest.json is opened.

When the target is specific

If the injection target is limited, such as a tool dedicated to a specific blog service, this design is reasonable.

"content_scripts": [
  {
    "matches": ["https://specific-blog.com/*"],
    "js": ["content.js"]
  }
]

When the target is unspecified

If the tool targets all websites, such as a bookmark tool, the pattern must be written broadly as follows.

"content_scripts": [
  {
    "matches": ["http://*/*", "https://*/*"],
    "js": ["content.js"]
  }
]

However, with this configuration, scripts are injected unconditionally even when opening online banking sites or internal systems that handle sensitive information. If the extension were compromised, all sites could become targets of attack.

In the Chrome Web Store, such settings are flagged as “broad permissions,” and the review process becomes stricter.

Reference:
https://groups.google.com/a/chromium.org/g/chromium-extensions/c/FtHQ8e9V7ag/m/szKkfW6cEgAJ


3. Solution: Using the scripting API and activeTab

scripting API

In Manifest V3, the scripting API was introduced to address the security weaknesses of content_scripts.

Although the Service Worker itself cannot access the DOM, it can send a function to the page and execute it using chrome.scripting.executeScript().

activeTab

activeTab is a permission that grants temporary access to the current tab only when the user performs an explicit action, such as clicking the extension icon. It does not respond to automatic events such as tab switching.

Combining activeTab and the scripting API

By combining these two, scripts can be injected only into the tab where the user performed an action. This creates a design that retrieves DOM information with minimal permissions.

// background.js (Service Worker)
chrome.action.onClicked.addListener((tab) => {
  // Inject a function into the target tab only when the user clicks
  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: getMetaData
  }, (results) => {
    const metadata = results[0].result;
    console.log("Retrieved metadata:", metadata);
  });
});

// Function executed on the page side (same position as content.js)
function getMetaData() {
  return {
    title: document.title,
    url: window.location.href,
    description: document.querySelector('meta[name="description"]')?.content || ""
  };
}

This allows content_scripts to be completely removed from manifest.json. The broad matches problem itself disappears.

Reference:
https://developer.chrome.com/docs/extensions/reference/api/scripting

Note: Conflict with default_popup

chrome.action.onClicked fires only when no popup is configured. If a popup is used, call chrome.scripting.executeScript() directly from the popup JavaScript, or delegate the process to the Service Worker using chrome.runtime.sendMessage().

Reference:
https://developer.chrome.com/docs/extensions/get-started/tutorial/scripts-activetab


4. Cases Where Script Injection Is Not Needed: chrome.tabs API

So far, the discussion assumed that access to the DOM is necessary. However, there are cases where information can be retrieved without injecting scripts at all.

chrome.tabs.get() and chrome.tabs.query() are APIs that read attributes of tabs managed internally by the browser (URL, title, favicon). They do not inject scripts into the page, and the page itself cannot detect the access.

// background.js (runs entirely inside the Service Worker, no script injection)
chrome.tabs.onActivated.addListener(async (activeInfo) => {
  const tab = await chrome.tabs.get(activeInfo.tabId);
  // tab.url and tab.title are browser-managed information
  // The page DOM is not accessed at all
  console.log(tab.url, tab.title);
});

If the URL or title is sufficient, script injection is unnecessary. Use the scripting API or content_scripts only when you need to read page-specific information such as OGP tags or article:published_time.

Reference:
https://developer.chrome.com/docs/extensions/reference/api/tabs


5. Difference Between tabs Permission and activeTab Permission

The tabs and activeTab permissions have similar names but clearly different roles.

tabs permission:

Allows reading the URL and title of all tabs at all times, regardless of user action. It is required when using automatically fired events such as chrome.tabs.onActivated.

activeTab permission:

A temporary permission granted only when the user invokes the extension. Because its scope is smaller, it is preferable to first check whether activeTab is sufficient, and use tabs only when automatic events are necessary.

Reference:
https://developer.chrome.com/docs/extensions/reference/api/tabs


6. Guidelines for Choosing the Approach

content_scripts and the scripting API are not mutually exclusive and can be used together within the same extension.

When content_scripts is appropriate:

When the target site is clear and matches can be narrowly restricted. For example, an extension dedicated to a specific service such as matches: ["https://specific-service.com/*"].

When the scripting API is appropriate:

When the target sites are unspecified and matches would need to be broad, such as a bookmark tool.

When the chrome.tabs API is sufficient:

When only browser-managed information such as URLs or titles is needed. Script injection itself is unnecessary.


Appendix: Example manifest.json

Example configuration for a bookmark tool that has two features: “checking whether the page is already registered in the DB when switching tabs” and “retrieving metadata when the user performs an action.”

{
  "name": "Bookmark Extension",
  "version": "1.0",
  "manifest_version": 3,
  "action": {
    "default_title": "Bookmark this page",
    "default_popup": "popup.html"
  },
  "permissions": [
    "tabs",
    "activeTab",
    "scripting"
  ],
  "host_permissions": [
    "http://localhost:3000/*",
    "https://bxxxxxx.jp/*"
  ],
  "background": {
    "service_worker": "background.js"
  }
}
  • tabs — required to retrieve the URL when the tab switches (onActivated)
  • activeTab — grants temporary access to the tab only when the user clicks
  • scripting — required to inject scripts into the page using executeScript()
  • host_permissions — limited only to the API endpoints used for data transmission

The key point is that content_scripts does not exist in this configuration. The broad matches problem does not occur.


References

Photo by Firmbee.com on Unsplash

End of the article

No comments yet.