1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 22:44:11 +00:00

PM-28831 Add isTrusted checks to ignore programmatically generated events (#18627)

* ignore events that do not originate from the user agent
* [pm-28831] Add isTrusted checks and update tests
* [pm-28831] Add isTrusted check to click events
* [pm-28831] Replace in-code jest exceptions with new utils
* [pm-28831] Move isTrusted checks to testable util
* [pm-28831] Remove redundant check in cipher-action.ts
* [pm-28831] Add isTrusted checks to click events in autofill-inine-menu-list
---------

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
This commit is contained in:
Ben Brooks
2026-02-11 14:47:27 -08:00
committed by GitHub
parent 30d3a36c7e
commit 11e2b25ede
17 changed files with 219 additions and 23 deletions

View File

@@ -3,6 +3,7 @@ import { html, TemplateResult } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { EventSecurity } from "../../../utils/event-security";
import { border, themes, typography, spacing } from "../constants/styles";
import { Spinner } from "../icons";
@@ -26,7 +27,7 @@ export function ActionButton({
fullWidth = true,
}: ActionButtonProps) {
const handleButtonClick = (event: Event) => {
if (!disabled && !isLoading) {
if (EventSecurity.isEventTrusted(event) && !disabled && !isLoading) {
handleClick(event);
}
};

View File

@@ -3,6 +3,7 @@ import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { EventSecurity } from "../../../utils/event-security";
import { border, themes, typography, spacing } from "../constants/styles";
export type BadgeButtonProps = {
@@ -23,7 +24,7 @@ export function BadgeButton({
username,
}: BadgeButtonProps) {
const handleButtonClick = (event: Event) => {
if (!disabled) {
if (EventSecurity.isEventTrusted(event) && !disabled) {
buttonAction(event);
}
};

View File

@@ -3,6 +3,7 @@ import { html } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { EventSecurity } from "../../../utils/event-security";
import { themes, typography, spacing } from "../constants/styles";
import { PencilSquare } from "../icons";
@@ -21,7 +22,7 @@ export function EditButton({ buttonAction, buttonText, disabled = false, theme }
aria-label=${buttonText}
class=${editButtonStyles({ disabled, theme })}
@click=${(event: Event) => {
if (!disabled) {
if (EventSecurity.isEventTrusted(event) && !disabled) {
buttonAction(event);
}
}}

View File

@@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { EventSecurity } from "../../../../utils/event-security";
import { spacing, themes, typography } from "../../constants/styles";
export type NotificationConfirmationMessageProps = {
@@ -127,7 +128,7 @@ const AdditionalMessageStyles = ({ theme }: { theme: Theme }) => css`
`;
function handleButtonKeyDown(event: KeyboardEvent, handleClick: () => void) {
if (event.key === "Enter" || event.key === " ") {
if (EventSecurity.isEventTrusted(event) && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
handleClick();
}

View File

@@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { EventSecurity } from "../../../utils/event-security";
import { IconProps, Option } from "../common-types";
import { themes, spacing } from "../constants/styles";
@@ -29,6 +30,13 @@ export function OptionItem({
handleSelection,
}: OptionItemProps) {
const handleSelectionKeyUpProxy = (event: KeyboardEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event)) {
return;
}
const listenedForKeys = new Set(["Enter", "Space"]);
if (listenedForKeys.has(event.code) && event.target instanceof Element) {
handleSelection();
@@ -37,6 +45,17 @@ export function OptionItem({
return;
};
const handleSelectionClickProxy = (event: MouseEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event)) {
return;
}
handleSelection();
};
const iconProps: IconProps = { color: themes[theme].text.main, theme };
const itemIcon = icon?.(iconProps);
const ariaLabel =
@@ -52,7 +71,7 @@ export function OptionItem({
title=${text}
role="option"
aria-label=${ariaLabel}
@click=${handleSelection}
@click=${handleSelectionClickProxy}
@keyup=${handleSelectionKeyUpProxy}
>
${itemIcon ? html`<div class=${optionItemIconContainerStyles}>${itemIcon}</div>` : nothing}

View File

@@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import { Theme } from "@bitwarden/common/platform/enums";
import { EventSecurity } from "../../../utils/event-security";
import { Option } from "../common-types";
import { themes, typography, scrollbarStyles, spacing } from "../constants/styles";
@@ -57,6 +58,10 @@ export function OptionItems({
}
function handleMenuKeyUp(event: KeyboardEvent) {
if (!EventSecurity.isEventTrusted(event)) {
return;
}
const items = [
...(event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[tabindex="0"]'),
];

View File

@@ -4,6 +4,7 @@ import { property, state } from "lit/decorators.js";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
import { EventSecurity } from "../../../utils/event-security";
import { OptionSelectionButton } from "../buttons/option-selection-button";
import { Option } from "../common-types";
@@ -54,7 +55,7 @@ export class OptionSelection extends LitElement {
private static currentOpenInstance: OptionSelection | null = null;
private handleButtonClick = async (event: Event) => {
if (!this.disabled) {
if (EventSecurity.isEventTrusted(event) && !this.disabled) {
const isOpening = !this.showMenu;
if (isOpening) {

View File

@@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { postWindowMessage, sendMockExtensionMessage } from "../spec/testing-utils";
import { EventSecurity } from "../utils/event-security";
describe("ContentMessageHandler", () => {
const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage");
@@ -19,6 +20,7 @@ describe("ContentMessageHandler", () => {
);
beforeEach(() => {
jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./content-message-handler");

View File

@@ -1,6 +1,8 @@
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { EventSecurity } from "../utils/event-security";
import {
ContentMessageWindowData,
ContentMessageWindowEventHandlers,
@@ -92,7 +94,10 @@ function handleOpenBrowserExtensionToUrlMessage({ url }: { url?: ExtensionPageUr
*/
function handleWindowMessageEvent(event: MessageEvent) {
const { source, data, origin } = event;
if (source !== window || !data?.command) {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event) || source !== window || !data?.command) {
return;
}

View File

@@ -1,3 +1,5 @@
import { EventSecurity } from "../utils/event-security";
const inputTags = ["input", "textarea", "select"];
const labelTags = ["label", "span"];
const attributeKeys = ["id", "name", "label-aria", "placeholder"];
@@ -52,6 +54,12 @@ function isNullOrEmpty(s: string | null) {
// We only have access to the element that's been clicked when the context menu is first opened.
// Remember it for use later.
document.addEventListener("contextmenu", (event) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event)) {
return;
}
clickedElement = event.target as HTMLElement;
});

View File

@@ -10,6 +10,7 @@ import {
createInitAutofillInlineMenuListMessageMock,
} from "../../../../spec/autofill-mocks";
import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils";
import { EventSecurity } from "../../../../utils/event-security";
import { AutofillInlineMenuList } from "./autofill-inline-menu-list";
@@ -28,6 +29,7 @@ describe("AutofillInlineMenuList", () => {
const events: { eventName: any; callback: any }[] = [];
beforeEach(() => {
jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true);
const oldEv = globalThis.addEventListener;
globalThis.addEventListener = (eventName: any, callback: any) => {
events.push({ eventName, callback });

View File

@@ -10,6 +10,7 @@ import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background";
import { InlineMenuFillType } from "../../../../enums/autofill-overlay.enum";
import { buildSvgDomElement, specialCharacterToKeyMap, throttle } from "../../../../utils";
import { EventSecurity } from "../../../../utils/event-security";
import {
creditCardIcon,
globeIcon,
@@ -203,7 +204,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
private handleSaveLoginInlineMenuKeyUp = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
if (
/**
* Reject synthetic events (not originating from the user agent)
*/
!EventSecurity.isEventTrusted(event) ||
!listenedForKeys.has(event.code) ||
!(event.target instanceof Element)
) {
return;
}
@@ -229,7 +237,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Handles the click event for the unlock button.
* Sends a message to the parent window to unlock the vault.
*/
private handleUnlockButtonClick = () => {
private handleUnlockButtonClick = (event: MouseEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event)) {
return;
}
this.postMessageToParent({ command: "unlockVault" });
};
@@ -352,7 +367,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Handles the click event for the fill generated password button. Triggers
* a message to the background script to fill the generated password.
*/
private handleFillGeneratedPasswordClick = () => {
private handleFillGeneratedPasswordClick = (event?: MouseEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (event && !EventSecurity.isEventTrusted(event)) {
return;
}
this.postMessageToParent({ command: "fillGeneratedPassword" });
};
@@ -362,7 +384,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param event - The keyup event.
*/
private handleFillGeneratedPasswordKeyUp = (event: KeyboardEvent) => {
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (
!EventSecurity.isEventTrusted(event) ||
event.ctrlKey ||
event.altKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
@@ -388,6 +419,13 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param event - The click event.
*/
private handleRefreshGeneratedPasswordClick = (event?: MouseEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (event && !EventSecurity.isEventTrusted(event)) {
return;
}
if (event) {
(event.target as HTMLElement)
.closest(".password-generator-actions")
@@ -403,7 +441,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param event - The keyup event.
*/
private handleRefreshGeneratedPasswordKeyUp = (event: KeyboardEvent) => {
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (
!EventSecurity.isEventTrusted(event) ||
event.ctrlKey ||
event.altKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
@@ -620,7 +667,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Handles the click event for the new item button.
* Sends a message to the parent window to add a new vault item.
*/
private handleNewLoginVaultItemAction = () => {
private handleNewLoginVaultItemAction = (event: MouseEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event)) {
return;
}
let addNewCipherType = this.inlineMenuFillType;
if (this.showInlineMenuAccountCreation) {
@@ -958,7 +1012,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => {
const usePasskey = !!cipher.login?.passkey;
return this.useEventHandlersMemo(
() => this.triggerFillCipherClickEvent(cipher, usePasskey),
(event: MouseEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event)) {
return;
}
this.triggerFillCipherClickEvent(cipher, usePasskey);
},
`${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`,
);
};
@@ -990,7 +1053,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
*/
private handleFillCipherKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowRight"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (
!EventSecurity.isEventTrusted(event) ||
!listenedForKeys.has(event.code) ||
!(event.target instanceof Element)
) {
return;
}
@@ -1018,7 +1088,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
*/
private handleNewItemButtonKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (
!EventSecurity.isEventTrusted(event) ||
!listenedForKeys.has(event.code) ||
!(event.target instanceof Element)
) {
return;
}
@@ -1063,11 +1140,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param cipher - The cipher to view.
*/
private handleViewCipherClickEvent = (cipher: InlineMenuCipherData) => {
return this.useEventHandlersMemo(
() =>
this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id }),
`${cipher.id}-view-cipher-button-click-handler`,
);
return this.useEventHandlersMemo((event: MouseEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event)) {
return;
}
this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id });
}, `${cipher.id}-view-cipher-button-click-handler`);
};
/**
@@ -1080,7 +1162,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
*/
private handleViewCipherKeyUpEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft"]);
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (
!EventSecurity.isEventTrusted(event) ||
!listenedForKeys.has(event.code) ||
!(event.target instanceof Element)
) {
return;
}

View File

@@ -1,6 +1,7 @@
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum";
import { EventSecurity } from "../../../../utils/event-security";
import {
AutofillInlineMenuPageElementWindowMessage,
AutofillInlineMenuPageElementWindowMessageHandlers,
@@ -163,7 +164,10 @@ export class AutofillInlineMenuPageElement extends HTMLElement {
*/
private handleDocumentKeyDownEvent = (event: KeyboardEvent) => {
const listenedForKeys = new Set(["Tab", "Escape", "ArrowUp", "ArrowDown"]);
if (!listenedForKeys.has(event.code)) {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event) || !listenedForKeys.has(event.code)) {
return;
}

View File

@@ -23,6 +23,7 @@ import {
sendMockExtensionMessage,
} from "../spec/testing-utils";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import { EventSecurity } from "../utils/event-security";
import { AutoFillConstants } from "./autofill-constants";
import { AutofillOverlayContentService } from "./autofill-overlay-content.service";
@@ -55,6 +56,9 @@ describe("AutofillOverlayContentService", () => {
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
beforeEach(async () => {
// Mock EventSecurity to allow synthetic events in tests
jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true);
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
domQueryService = new DomQueryService();
domElementVisibilityService = new DomElementVisibilityService();
@@ -331,6 +335,8 @@ describe("AutofillOverlayContentService", () => {
pageDetailsMock,
);
jest.spyOn(globalThis.customElements, "define").mockImplementation();
// Mock EventSecurity to allow synthetic events in tests
jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true);
});
it("closes the autofill inline menu when the `Escape` key is pressed", () => {

View File

@@ -45,6 +45,7 @@ import {
sendExtensionMessage,
throttle,
} from "../utils";
import { EventSecurity } from "../utils/event-security";
import {
AutofillOverlayContentExtensionMessageHandlers,
@@ -618,6 +619,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
*/
private handleSubmitButtonInteraction = (event: PointerEvent) => {
if (
/**
* Reject synthetic events (not originating from the user agent)
*/
!EventSecurity.isEventTrusted(event) ||
!this.submitElements.has(event.target as HTMLElement) ||
(event.type === "keyup" &&
!["Enter", "Space"].includes((event as unknown as KeyboardEvent).code))
@@ -703,6 +708,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* @param event - The keyup event.
*/
private handleFormFieldKeyupEvent = async (event: globalThis.KeyboardEvent) => {
/**
* Reject synthetic events (not originating from the user agent)
*/
if (!EventSecurity.isEventTrusted(event)) {
return;
}
const eventCode = event.code;
if (eventCode === "Escape") {
void this.sendExtensionMessage("closeAutofillInlineMenu", {

View File

@@ -0,0 +1,26 @@
import { EventSecurity } from "./event-security";
describe("EventSecurity", () => {
describe("isEventTrusted", () => {
it("should call the event.isTrusted property", () => {
const testEvent = new KeyboardEvent("keyup", { code: "Escape" });
const result = EventSecurity.isEventTrusted(testEvent);
// In test environment, events are untrusted by default
expect(result).toBe(false);
expect(result).toBe(testEvent.isTrusted);
});
it("should be mockable with jest.spyOn", () => {
const testEvent = new KeyboardEvent("keyup", { code: "Escape" });
const spy = jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true);
const result = EventSecurity.isEventTrusted(testEvent);
expect(result).toBe(true);
expect(spy).toHaveBeenCalledWith(testEvent);
spy.mockRestore();
});
});
});

View File

@@ -0,0 +1,13 @@
/**
* Event security utilities for validating trusted events
*/
export class EventSecurity {
/**
* Validates that an event is trusted (originated from user agent)
* @param event - The event to validate
* @returns true if the event is trusted, false otherwise
*/
static isEventTrusted(event: Event): boolean {
return event.isTrusted;
}
}