Message Passing and Security Considerations in Chrome Extensions
Nick Mooney June 19th, 2019 (Last Updated: June 19th, 2019)00. Introduction
In the process of writing a Chrome extension you may find yourself needing to communicate between different components, such as scripts running on webpages and the long-running extension backend. The Chrome Extension developer documentation gives a great breakdown of the message passing tools you have available. This article is written to give you a little insight into why you would use a particular method, some tips and tricks for common use cases, and pitfalls to avoid.
01. Components of a Chrome Extension
There are two main components of a Chrome extension: content scripts, which run in the context of webpages you want to interact with or modify, and background scripts, which are long-running services that can maintain global state. Simple extensions only need to rely on a content script, but extensions requiring storage or longer-term / more complex processing may benefit from a background script.
Occasionally, you will also need to inject a script into a webpage to break out of the “isolated world” that content scripts run in. We will cover that more later, but I will refer to these scripts as “injected scripts.”
02. If you want X, do Y
Messaging between content scripts and the backend
Use chrome.runtime.sendMessage in your content script and chrome.runtime.onMessage.addListener
in your backend script. Each message is a one-off, with a single optional response that can be sent via a callback.
If you want to open a long-lived channel (perhaps because you want on-demand messaging from the backend to the content script), use chrome.runtime.connect instead. You can even open multiple named channels to send different types of data.
Messaging and Injected Scripts (with DOM access)
Chrome extension content scripts run in an “isolated world.” Here’s what Google has to say:
"Content scripts live in an isolated world, allowing a content script to makes changes to its JavaScript environment without conflicting with the page or additional content scripts."
…
"Isolated worlds do not allow for content scripts, the extension, and the web page to access any variables or functions created by the others. This also gives content scripts the ability to enable functionality that should not be accessible to the web page."
This is a great feature, but can be restrictive at times. If you’re looking to override certain functionality (like hijacking JavaScript APIs provided by the browser), the isolated world will not let you do this. You will need to programatically load a plain old <script>
element – but now you have a problem: the script you’ve loaded is no longer running in the “isolated world,” and therefore has no access to the Chrome message passing APIs.
The documentation has a solution: use window.postMessage to communicate between code running on the page and the content script, and optionally use the content script to proxy messages back to the backend.
The content script might look something like this:
// Listen for any messages window.addEventListener("message", (evt) => { // Validate the message origin if (evt.origin === window.origin) { const message = evt.data // Check the type to avoid noise from other scripts if (messageIsCorrectType(message)) { // If you need to know the origin of the message in the backend, // set it from within the content script -- do not allow it to be // controlled via the DOM message.origin = window.location.origin chrome.runtime.sendMessage(message, (response) => { // The callback for this message will call `window.postMessage` window.postMessage(response, message.origin) }) } } })
and you would inject a script directly into the DOM by putting something like this in your content script:
const newScript = document.createElement("script"); newScript.src = chrome.runtime.getURL(PATH_TO_SCRIPT_FILE); (document.head || document.documentElement).appendChild(newScript);
A Word of Caution
Note that your injected script runs in exactly the same context as the DOM, so code served natively on a web page can communicate with your extension in the same way. Using injected scripts this way pulls you out of the “isolated world,” and you cannot trust that messages received this way didn’t come from other malicious JavaScript code on the page.
Origin and source validation are a necessity, but even still, messages could come from JavaScript that was inserted into a page via XSS, via another malicious Chrome extension, etc. Place as little trust in messages from the DOM as possible.
03. Common Pitfalls
Permissions vs. Content Script Directives
An extension’s manifest.json defines a set of permissions available to the extension as part of the permissions
directive. There are capabilities such as tabs
, background
etc that authorize the use of APIs, as well as match patterns that allow access to specific hosts.
When you put a host in permissions
, your extension is authorized certain functionality on those URLs. For example, if your extension has the permissions cookies and https://www.google.com, your extension will be able to access the cookies on https://www.google.com.
This is entirely separate from the host matches in the content_scripts
directive! If you have a permissions section that looks like this:
"permissions": [ "*://*.mywebsite.com/*" ],
and a content_scripts directive that looks like this:
"content_scripts": [ { "js": [ "content_script.js" ], "matches": [ "https://*/*/mypage.html" ], "run_at": "document_start" } ],
It is important to understand that your content script will run on all domains, not just mywebsite.com
. It is important to understand that these sets of permissions do not interact with each other in order to avoid unintentionally exposing content script functionality to more domains than you expect.
Origin Validation
If you are using an injected script that needs to communicate with a content script via window.postMessage
, it is up to you to validate the origin of that message. Mathias Karlsson’s “The pitfalls of postMessage” details the issues that can occur without proper origin validation.
message.origin
must be validated before treating the message as having originated from a legitimate sender. Without this validation, messages can be sent from an attacker origin to the receiver window in a malicious manner.
Rendering Controls in the DOM
If your Chrome extension provides functionality by rendering buttons, links, etc. on the webpage, it’s important to remember that these buttons can also be “clicked” by the webpage itself. Buttons rendered in the target webpage should not immediately provoke undesired behavior. In addition, input provided to text areas that are rendered on a page by an extension is readable by other JavaScript running on that domain.
1Password X handles this quite well – when you navigate to a site that you might like to log in to, you are prompted to hit a keyboard shortcut and provide your password to the extension via the extension popup, which is entirely separate from the DOM of the page you’re viewing. Only when you authenticate to the popup – out of band of the webpage you’re viewing – are you able to perform “privileged” actions like filling in passwords.
Relying on Variables Defined in the Content Script
While content scripts do run in the “Isolated World” mentioned earlier, this isolated world has access to the contents of the DOM, and should treat the contents of the DOM as untrusted. Tavis Ormandy gives a clear example in his writeup of the LastPass vulnerability reported in 2017:
var trusted = falsedocument.body.addEventListener("click", function() { if (trusted) { eval(window.location.hash.substr(1)) } });
This above function is secure because trusted
is explicitly defined by the content script. This trusted
variable exists in the isolated world of the content script and cannot be overridden by the DOM.
document.body.addEventListener("click", function() { if (typeof trusted != "undefined") { eval(window.location.hash.substr(1)) } });
This function is insecure. The trusted
variable is undefined by default, but a malicious webpage can execute the following:
el = document.createElement("exploit") el.setAttribute("id", "trusted"); document.body.appendChild(el);
and now the eval
line will run.
This is because DOM element IDs automatically become properties of window
, and window
is within the default namespace of global JavaScript variables.
04. Summary
Chrome extensions often provide access to powerful functionality from webpage contexts. The messaging APIs are great, but it is important not to blindly pass messages from the DOM into the privileged context of extension messaging. Treat the DOM as untrusted, and perform validation within the “isolated world” of content scripts before passing messages to the backend.