1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

Pm 27900 add additional hardening in extension frame validation (#17265)

* PM-27900 harden iframe, origin route tightening and test updates

* reduce comments to make more legible

* Removes referrer check in favor of PM-27822 #17313 bitwarden/clients@4206447cfe

* nake token optional since it is later set

* whitelist -> allowlist

* improve notes on unsafe

* improve content handler notes

* order allowlist

* improve jsdoc on ismessagefromextension method

* cover additional test cases

* rename verifytoken and document more clear, update referrer

---------

Co-authored-by: Miles Blackwood <mrobinson@bitwarden.com>
This commit is contained in:
Daniel Riera
2025-11-18 12:22:13 -05:00
committed by GitHub
parent 3b84da60ca
commit b1acff7f5c
5 changed files with 233 additions and 34 deletions

View File

@@ -86,17 +86,30 @@ function handleOpenBrowserExtensionToUrlMessage({ url }: { url?: ExtensionPageUr
}
/**
* Handles the window message event.
* Handles window message events, validating source and extracting referrer for security.
*
* @param event - The window message event
*/
function handleWindowMessageEvent(event: MessageEvent) {
const { source, data } = event;
const { source, data, origin } = event;
if (source !== window || !data?.command) {
return;
}
const referrer = source.location.hostname;
// Extract hostname from event.origin for secure referrer validation in background script
let referrer: string;
// Sandboxed iframe or opaque origin support
if (origin === "null") {
referrer = "null";
} else {
try {
const originUrl = new URL(origin);
referrer = originUrl.hostname;
} catch {
return;
}
}
const handler = windowMessageHandlers[data.command];
if (handler) {
handler({ data, referrer });

View File

@@ -5,6 +5,7 @@ import { InlineMenuCipherData } from "../../../background/abstractions/overlay.b
export type AutofillInlineMenuContainerMessage = {
command: string;
portKey: string;
token?: string;
};
export type InitAutofillInlineMenuElementMessage = AutofillInlineMenuContainerMessage & {

View File

@@ -6,11 +6,13 @@ import { AutofillInlineMenuContainer } from "./autofill-inline-menu-container";
describe("AutofillInlineMenuContainer", () => {
const portKey = "testPortKey";
const iframeUrl = "https://example.com";
const extensionOrigin = "chrome-extension://test-extension-id";
const iframeUrl = `${extensionOrigin}/overlay/menu-list.html`;
const pageTitle = "Example";
let autofillInlineMenuContainer: AutofillInlineMenuContainer;
beforeEach(() => {
jest.spyOn(chrome.runtime, "getURL").mockReturnValue(`${extensionOrigin}/`);
autofillInlineMenuContainer = new AutofillInlineMenuContainer();
});
@@ -28,7 +30,7 @@ describe("AutofillInlineMenuContainer", () => {
portName: AutofillOverlayPort.List,
};
postWindowMessage(message);
postWindowMessage(message, extensionOrigin);
expect(autofillInlineMenuContainer["defaultIframeAttributes"].src).toBe(message.iframeUrl);
expect(autofillInlineMenuContainer["defaultIframeAttributes"].title).toBe(message.pageTitle);
@@ -44,15 +46,48 @@ describe("AutofillInlineMenuContainer", () => {
portName: AutofillOverlayPort.Button,
};
postWindowMessage(message);
postWindowMessage(message, extensionOrigin);
jest.spyOn(autofillInlineMenuContainer["inlineMenuPageIframe"].contentWindow, "postMessage");
autofillInlineMenuContainer["inlineMenuPageIframe"].dispatchEvent(new Event("load"));
expect(chrome.runtime.connect).toHaveBeenCalledWith({ name: message.portName });
const expectedMessage = expect.objectContaining({
...message,
token: expect.any(String),
});
expect(
autofillInlineMenuContainer["inlineMenuPageIframe"].contentWindow.postMessage,
).toHaveBeenCalledWith(message, "*");
).toHaveBeenCalledWith(expectedMessage, "*");
});
it("ignores initialization when URLs are not from extension origin", () => {
const invalidIframeUrlMessage = {
command: "initAutofillInlineMenuList",
iframeUrl: "https://malicious.com/overlay/menu-list.html",
pageTitle,
portKey,
portName: AutofillOverlayPort.List,
};
postWindowMessage(invalidIframeUrlMessage, extensionOrigin);
expect(autofillInlineMenuContainer["inlineMenuPageIframe"]).toBeUndefined();
expect(autofillInlineMenuContainer["isInitialized"]).toBe(false);
autofillInlineMenuContainer = new AutofillInlineMenuContainer();
const invalidStyleSheetUrlMessage = {
command: "initAutofillInlineMenuList",
iframeUrl,
pageTitle,
portKey,
portName: AutofillOverlayPort.List,
styleSheetUrl: "https://malicious.com/styles.css",
};
postWindowMessage(invalidStyleSheetUrlMessage, extensionOrigin);
expect(autofillInlineMenuContainer["inlineMenuPageIframe"]).toBeUndefined();
expect(autofillInlineMenuContainer["isInitialized"]).toBe(false);
});
});
@@ -69,7 +104,7 @@ describe("AutofillInlineMenuContainer", () => {
portName: AutofillOverlayPort.Button,
};
postWindowMessage(message);
postWindowMessage(message, extensionOrigin);
iframe = autofillInlineMenuContainer["inlineMenuPageIframe"];
jest.spyOn(iframe.contentWindow, "postMessage");
@@ -112,7 +147,8 @@ describe("AutofillInlineMenuContainer", () => {
});
it("posts a message to the background from the inline menu iframe", () => {
const message = { command: "checkInlineMenuButtonFocused", portKey };
const token = autofillInlineMenuContainer["token"];
const message = { command: "checkInlineMenuButtonFocused", portKey, token };
postWindowMessage(message, "null", iframe.contentWindow as any);
@@ -124,7 +160,28 @@ describe("AutofillInlineMenuContainer", () => {
postWindowMessage(message);
expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith(message, "*");
const expectedMessage = expect.objectContaining({
...message,
token: expect.any(String),
});
expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith(expectedMessage, "*");
});
it("ignores messages from iframe with invalid token", () => {
const message = { command: "checkInlineMenuButtonFocused", portKey, token: "invalid-token" };
postWindowMessage(message, "null", iframe.contentWindow as any);
expect(port.postMessage).not.toHaveBeenCalled();
});
it("ignores messages from iframe with commands not in the allowlist", () => {
const token = autofillInlineMenuContainer["token"];
const message = { command: "maliciousCommand", portKey, token };
postWindowMessage(message, "null", iframe.contentWindow as any);
expect(port.postMessage).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,6 @@
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { setElementStyles } from "../../../../utils";
import { generateRandomChars, setElementStyles } from "../../../../utils";
import {
InitAutofillInlineMenuElementMessage,
AutofillInlineMenuContainerWindowMessageHandlers,
@@ -8,14 +8,37 @@ import {
AutofillInlineMenuContainerPortMessage,
} from "../../abstractions/autofill-inline-menu-container";
/**
* Allowlist of commands that can be sent to the background script.
*/
const ALLOWED_BG_COMMANDS = new Set<string>([
"addNewVaultItem",
"autofillInlineMenuBlurred",
"autofillInlineMenuButtonClicked",
"checkAutofillInlineMenuButtonFocused",
"checkInlineMenuButtonFocused",
"fillAutofillInlineMenuCipher",
"fillGeneratedPassword",
"redirectAutofillInlineMenuFocusOut",
"refreshGeneratedPassword",
"refreshOverlayCiphers",
"triggerDelayedAutofillInlineMenuClosure",
"updateAutofillInlineMenuColorScheme",
"updateAutofillInlineMenuListHeight",
"unlockVault",
"viewSelectedCipher",
]);
export class AutofillInlineMenuContainer {
private readonly setElementStyles = setElementStyles;
private readonly extensionOriginsSet: Set<string>;
private port: chrome.runtime.Port | null = null;
/** Non-null asserted. */
private portName!: string;
/** Non-null asserted. */
private inlineMenuPageIframe!: HTMLIFrameElement;
private token: string;
private isInitialized: boolean = false;
private readonly extensionOrigin: string;
private readonly iframeStyles: Partial<CSSStyleDeclaration> = {
all: "initial",
position: "fixed",
@@ -49,11 +72,8 @@ export class AutofillInlineMenuContainer {
};
constructor() {
this.extensionOriginsSet = new Set([
chrome.runtime.getURL("").slice(0, -1).toLowerCase(), // Remove the trailing slash and normalize the extension url to lowercase
"null",
]);
this.token = generateRandomChars(32);
this.extensionOrigin = chrome.runtime.getURL("").slice(0, -1);
globalThis.addEventListener("message", this.handleWindowMessage);
}
@@ -63,9 +83,22 @@ export class AutofillInlineMenuContainer {
* @param message - The message containing the iframe url and page title.
*/
private handleInitInlineMenuIframe(message: InitAutofillInlineMenuElementMessage) {
if (this.isInitialized) {
return;
}
if (!this.isExtensionUrl(message.iframeUrl)) {
return;
}
if (message.styleSheetUrl && !this.isExtensionUrl(message.styleSheetUrl)) {
return;
}
this.defaultIframeAttributes.src = message.iframeUrl;
this.defaultIframeAttributes.title = message.pageTitle;
this.portName = message.portName;
this.isInitialized = true;
this.inlineMenuPageIframe = globalThis.document.createElement("iframe");
this.setElementStyles(this.inlineMenuPageIframe, this.iframeStyles, true);
@@ -81,6 +114,26 @@ export class AutofillInlineMenuContainer {
globalThis.document.body.appendChild(this.inlineMenuPageIframe);
}
/**
* validates that a URL is from the extension origin.
* prevents loading arbitrary URLs in the iframe.
*
* @param url - The URL to validate.
*/
private isExtensionUrl(url: string): boolean {
if (!url) {
return false;
}
try {
const urlObj = new URL(url);
return (
urlObj.origin === this.extensionOrigin || urlObj.href.startsWith(this.extensionOrigin + "/")
);
} catch {
return false;
}
}
/**
* Sets up the port message listener for the inline menu page.
*
@@ -88,7 +141,8 @@ export class AutofillInlineMenuContainer {
*/
private setupPortMessageListener = (message: InitAutofillInlineMenuElementMessage) => {
this.port = chrome.runtime.connect({ name: this.portName });
this.postMessageToInlineMenuPage(message);
const initMessage = { ...message, token: this.token };
this.postMessageToInlineMenuPageUnsafe(initMessage);
};
/**
@@ -97,6 +151,22 @@ export class AutofillInlineMenuContainer {
* @param message - The message to post.
*/
private postMessageToInlineMenuPage(message: AutofillInlineMenuContainerWindowMessage) {
if (this.inlineMenuPageIframe?.contentWindow) {
const messageWithToken = { ...message, token: this.token };
this.postMessageToInlineMenuPageUnsafe(messageWithToken);
}
}
/**
* Posts a message to the inline menu page iframe without token validation.
*
* UNSAFE: Bypasses token authentication and sends raw messages. Only use internally
* when sending trusted messages (e.g., initialization) or when token validation
* would create circular dependencies. External callers should use postMessageToInlineMenuPage().
*
* @param message - The message to post.
*/
private postMessageToInlineMenuPageUnsafe(message: Record<string, unknown>) {
if (this.inlineMenuPageIframe?.contentWindow) {
this.inlineMenuPageIframe.contentWindow.postMessage(message, "*");
}
@@ -108,9 +178,15 @@ export class AutofillInlineMenuContainer {
* @param message - The message to post.
*/
private postMessageToBackground(message: AutofillInlineMenuContainerPortMessage) {
if (this.port) {
this.port.postMessage(message);
if (!this.port) {
return;
}
if (message.command && !ALLOWED_BG_COMMANDS.has(message.command)) {
return;
}
this.port.postMessage(message);
}
/**
@@ -124,23 +200,33 @@ export class AutofillInlineMenuContainer {
return;
}
if (
this.windowMessageHandlers[
message.command as keyof AutofillInlineMenuContainerWindowMessageHandlers
]
) {
this.windowMessageHandlers[
message.command as keyof AutofillInlineMenuContainerWindowMessageHandlers
](message);
if (this.windowMessageHandlers[message.command]) {
// only accept init messages from extension origin or parent window
if (
(message.command === "initAutofillInlineMenuButton" ||
message.command === "initAutofillInlineMenuList") &&
!this.isMessageFromExtensionOrigin(event) &&
!this.isMessageFromParentWindow(event)
) {
return;
}
this.windowMessageHandlers[message.command](message);
return;
}
if (this.isMessageFromParentWindow(event)) {
// messages from parent window are trusted and forwarded to iframe
this.postMessageToInlineMenuPage(message);
return;
}
this.postMessageToBackground(message);
// messages from iframe to background require object identity verification with a contentWindow check and token auth
if (this.isMessageFromInlineMenuPageIframe(event)) {
if (this.isValidSessionToken(message)) {
this.postMessageToBackground(message);
}
return;
}
};
/**
@@ -184,10 +270,34 @@ export class AutofillInlineMenuContainer {
if (!this.inlineMenuPageIframe) {
return false;
}
// only trust the specific iframe we created
return this.inlineMenuPageIframe.contentWindow === event.source;
}
return (
this.inlineMenuPageIframe.contentWindow === event.source &&
this.extensionOriginsSet.has(event.origin.toLowerCase())
);
/**
* Validates that the message contains a valid session token.
* The session token is generated when the container is created and is refreshed
* every time the inline menu container is recreated.
*
*/
private isValidSessionToken(message: { token?: string }): boolean {
return message.token === this.token;
}
/**
* Validates that a message event originates from the extension.
*
* @param event - The message event to validate.
* @returns True if the message is from the extension origin.
*/
private isMessageFromExtensionOrigin(event: MessageEvent): boolean {
try {
if (event.origin === "null") {
return false;
}
return event.origin === this.extensionOrigin;
} catch {
return false;
}
}
}

View File

@@ -16,6 +16,7 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
private portKey!: string;
/** Non-null asserted. */
protected windowMessageHandlers!: AutofillInlineMenuPageElementWindowMessageHandlers;
private token?: string;
constructor() {
super();
@@ -37,8 +38,12 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
styleSheetUrl: string,
translations: Record<string, string>,
portKey: string,
token?: string,
): Promise<HTMLLinkElement> {
this.portKey = portKey;
if (token) {
this.token = token;
}
this.translations = translations;
globalThis.document.documentElement.setAttribute("lang", this.getTranslation("locale"));
@@ -58,7 +63,11 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
* @param message - The message to post
*/
protected postMessageToParent(message: AutofillInlineMenuPageElementWindowMessage) {
globalThis.parent.postMessage({ portKey: this.portKey, ...message }, "*");
const messageWithAuth: Record<string, unknown> = { portKey: this.portKey, ...message };
if (this.token) {
messageWithAuth.token = this.token;
}
globalThis.parent.postMessage(messageWithAuth, "*");
}
/**
@@ -105,6 +114,15 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
}
const message = event?.data;
if (
message?.token &&
(message?.command === "initAutofillInlineMenuButton" ||
message?.command === "initAutofillInlineMenuList")
) {
this.token = message.token;
}
const handler = this.windowMessageHandlers[message?.command];
if (!handler) {
return;