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:
@@ -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 });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InlineMenuCipherData } from "../../../background/abstractions/overlay.b
|
||||
export type AutofillInlineMenuContainerMessage = {
|
||||
command: string;
|
||||
portKey: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type InitAutofillInlineMenuElementMessage = AutofillInlineMenuContainerMessage & {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user