mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[PM-27797] Prevent host page manipulation of inline menu popover attribute (#17400)
* turn off inline experience if host page aggressively competes for top of top-layer * add alert message for top-layer hijack scenarios * widen the backoff threshold * refactor backoff logic to include popover attribute mutations * improve getPageIsOpaque check * do not attempt inline menu insertion if it has been disabled for security concerns * fix typo * cleanup * add tests
This commit is contained in:
@@ -2436,6 +2436,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"topLayerHijackWarning": {
|
||||
"message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure."
|
||||
},
|
||||
"setMasterPassword": {
|
||||
"message": "Set master password"
|
||||
},
|
||||
|
||||
@@ -53,13 +53,35 @@ describe("AutofillInlineMenuContentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageHandlers", () => {
|
||||
it("returns the extension message handlers", () => {
|
||||
const handlers = autofillInlineMenuContentService.messageHandlers;
|
||||
|
||||
expect(handlers).toHaveProperty("closeAutofillInlineMenu");
|
||||
expect(handlers).toHaveProperty("appendAutofillInlineMenuToDom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isElementInlineMenu", () => {
|
||||
it("returns true if the passed element is the inline menu", () => {
|
||||
it("returns true if the passed element is the inline menu list", () => {
|
||||
const element = document.createElement("div");
|
||||
autofillInlineMenuContentService["listElement"] = element;
|
||||
|
||||
expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true if the passed element is the inline menu button", () => {
|
||||
const element = document.createElement("div");
|
||||
autofillInlineMenuContentService["buttonElement"] = element;
|
||||
|
||||
expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if the passed element is not the inline menu", () => {
|
||||
const element = document.createElement("div");
|
||||
|
||||
expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension message handlers", () => {
|
||||
@@ -388,7 +410,7 @@ describe("AutofillInlineMenuContentService", () => {
|
||||
});
|
||||
|
||||
it("closes the inline menu if the page body is not sufficiently opaque", async () => {
|
||||
document.querySelector("html").style.opacity = "0.9";
|
||||
document.documentElement.style.opacity = "0.9";
|
||||
document.body.style.opacity = "0";
|
||||
await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]);
|
||||
|
||||
@@ -397,7 +419,7 @@ describe("AutofillInlineMenuContentService", () => {
|
||||
});
|
||||
|
||||
it("closes the inline menu if the page html is not sufficiently opaque", async () => {
|
||||
document.querySelector("html").style.opacity = "0.3";
|
||||
document.documentElement.style.opacity = "0.3";
|
||||
document.body.style.opacity = "0.7";
|
||||
await autofillInlineMenuContentService["handlePageMutations"]([mockHTMLMutationRecord]);
|
||||
|
||||
@@ -406,7 +428,7 @@ describe("AutofillInlineMenuContentService", () => {
|
||||
});
|
||||
|
||||
it("does not close the inline menu if the page html and body is sufficiently opaque", async () => {
|
||||
document.querySelector("html").style.opacity = "0.9";
|
||||
document.documentElement.style.opacity = "0.9";
|
||||
document.body.style.opacity = "1";
|
||||
await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]);
|
||||
await waitForIdleCallback();
|
||||
@@ -599,5 +621,465 @@ describe("AutofillInlineMenuContentService", () => {
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the persistent last child override timeout", () => {
|
||||
jest.useFakeTimers();
|
||||
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = setTimeout(
|
||||
jest.fn(),
|
||||
500,
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService.destroy();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unobserves page attributes", () => {
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["htmlMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService.destroy();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOwnedTagNames", () => {
|
||||
it("returns an empty array when no elements are created", () => {
|
||||
expect(autofillInlineMenuContentService.getOwnedTagNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the button element tag name", () => {
|
||||
const buttonElement = document.createElement("div");
|
||||
autofillInlineMenuContentService["buttonElement"] = buttonElement;
|
||||
|
||||
const tagNames = autofillInlineMenuContentService.getOwnedTagNames();
|
||||
|
||||
expect(tagNames).toContain("DIV");
|
||||
});
|
||||
|
||||
it("returns both button and list element tag names", () => {
|
||||
const buttonElement = document.createElement("div");
|
||||
const listElement = document.createElement("span");
|
||||
autofillInlineMenuContentService["buttonElement"] = buttonElement;
|
||||
autofillInlineMenuContentService["listElement"] = listElement;
|
||||
|
||||
const tagNames = autofillInlineMenuContentService.getOwnedTagNames();
|
||||
|
||||
expect(tagNames).toEqual(["DIV", "SPAN"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUnownedTopLayerItems", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("returns the tag names from button and list elements", () => {
|
||||
const buttonElement = document.createElement("div");
|
||||
buttonElement.setAttribute("popover", "manual");
|
||||
autofillInlineMenuContentService["buttonElement"] = buttonElement;
|
||||
|
||||
const listElement = document.createElement("span");
|
||||
listElement.setAttribute("popover", "manual");
|
||||
autofillInlineMenuContentService["listElement"] = listElement;
|
||||
|
||||
/** Mock querySelectorAll to avoid :modal selector issues in jsdom */
|
||||
const querySelectorAllSpy = jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([] as any);
|
||||
|
||||
const items = autofillInlineMenuContentService.getUnownedTopLayerItems();
|
||||
|
||||
expect(querySelectorAllSpy).toHaveBeenCalled();
|
||||
expect(items.length).toBe(0);
|
||||
});
|
||||
|
||||
it("calls querySelectorAll with correct selector when includeCandidates is false", () => {
|
||||
/** Mock querySelectorAll to avoid :modal selector issues in jsdom */
|
||||
const querySelectorAllSpy = jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([] as any);
|
||||
|
||||
autofillInlineMenuContentService.getUnownedTopLayerItems(false);
|
||||
|
||||
const calledSelector = querySelectorAllSpy.mock.calls[0][0];
|
||||
expect(calledSelector).toContain(":modal");
|
||||
expect(calledSelector).toContain(":popover-open");
|
||||
});
|
||||
|
||||
it("includes candidates selector when requested", () => {
|
||||
/** Mock querySelectorAll to avoid :modal selector issues in jsdom */
|
||||
const querySelectorAllSpy = jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([] as any);
|
||||
|
||||
autofillInlineMenuContentService.getUnownedTopLayerItems(true);
|
||||
|
||||
const calledSelector = querySelectorAllSpy.mock.calls[0][0];
|
||||
expect(calledSelector).toContain("[popover], dialog");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshTopLayerPosition", () => {
|
||||
it("does nothing when inline menu is disabled", () => {
|
||||
const getUnownedTopLayerItemsSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService,
|
||||
"getUnownedTopLayerItems",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["inlineMenuEnabled"] = false;
|
||||
const buttonElement = document.createElement("div");
|
||||
autofillInlineMenuContentService["buttonElement"] = buttonElement;
|
||||
|
||||
autofillInlineMenuContentService.refreshTopLayerPosition();
|
||||
|
||||
// Should exit early and not call `getUnownedTopLayerItems`
|
||||
expect(getUnownedTopLayerItemsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when no other top layer items exist", () => {
|
||||
const buttonElement = document.createElement("div");
|
||||
autofillInlineMenuContentService["buttonElement"] = buttonElement;
|
||||
jest
|
||||
.spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems")
|
||||
.mockReturnValue([] as any);
|
||||
|
||||
const getElementsByTagSpy = jest.spyOn(globalThis.document, "getElementsByTagName");
|
||||
|
||||
autofillInlineMenuContentService.refreshTopLayerPosition();
|
||||
|
||||
// Should exit early and not get inline elements to refresh
|
||||
expect(getElementsByTagSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes button popover when button is in document", () => {
|
||||
jest
|
||||
.spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems")
|
||||
.mockReturnValue([document.createElement("div")] as any);
|
||||
|
||||
const buttonElement = document.createElement("div");
|
||||
buttonElement.setAttribute("popover", "manual");
|
||||
buttonElement.showPopover = jest.fn();
|
||||
buttonElement.hidePopover = jest.fn();
|
||||
document.body.appendChild(buttonElement);
|
||||
autofillInlineMenuContentService["buttonElement"] = buttonElement;
|
||||
|
||||
autofillInlineMenuContentService.refreshTopLayerPosition();
|
||||
|
||||
expect(buttonElement.hidePopover).toHaveBeenCalled();
|
||||
expect(buttonElement.showPopover).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes list popover when list is in document", () => {
|
||||
jest
|
||||
.spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems")
|
||||
.mockReturnValue([document.createElement("div")] as any);
|
||||
|
||||
const listElement = document.createElement("div");
|
||||
listElement.setAttribute("popover", "manual");
|
||||
listElement.showPopover = jest.fn();
|
||||
listElement.hidePopover = jest.fn();
|
||||
document.body.appendChild(listElement);
|
||||
autofillInlineMenuContentService["listElement"] = listElement;
|
||||
|
||||
autofillInlineMenuContentService.refreshTopLayerPosition();
|
||||
|
||||
expect(listElement.hidePopover).toHaveBeenCalled();
|
||||
expect(listElement.showPopover).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAndUpdateRefreshCount", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date("2023-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("does nothing when inline menu is disabled", () => {
|
||||
autofillInlineMenuContentService["inlineMenuEnabled"] = false;
|
||||
|
||||
autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer");
|
||||
|
||||
expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(0);
|
||||
});
|
||||
|
||||
it("increments refresh count when within time threshold", () => {
|
||||
autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 1000;
|
||||
|
||||
autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer");
|
||||
|
||||
expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(1);
|
||||
});
|
||||
|
||||
it("resets count when outside time threshold", () => {
|
||||
autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 6000;
|
||||
autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer = 5;
|
||||
|
||||
autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer");
|
||||
|
||||
expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(0);
|
||||
});
|
||||
|
||||
it("disables inline menu and shows alert when count exceeds threshold", () => {
|
||||
const alertSpy = jest.spyOn(globalThis.window, "alert").mockImplementation();
|
||||
const checkPageRisksSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"checkPageRisks",
|
||||
);
|
||||
autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 1000;
|
||||
autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer = 6;
|
||||
|
||||
autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer");
|
||||
|
||||
expect(autofillInlineMenuContentService["inlineMenuEnabled"]).toBe(false);
|
||||
expect(alertSpy).toHaveBeenCalled();
|
||||
expect(checkPageRisksSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshPopoverAttribute", () => {
|
||||
it("calls checkAndUpdateRefreshCount with popoverAttribute type", () => {
|
||||
const checkSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"checkAndUpdateRefreshCount",
|
||||
);
|
||||
const element = document.createElement("div");
|
||||
element.setAttribute("popover", "auto");
|
||||
element.showPopover = jest.fn();
|
||||
|
||||
autofillInlineMenuContentService["refreshPopoverAttribute"](element);
|
||||
|
||||
expect(checkSpy).toHaveBeenCalledWith("popoverAttribute");
|
||||
expect(element.getAttribute("popover")).toBe("manual");
|
||||
expect(element.showPopover).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleInlineMenuElementMutationObserverUpdate - popover attribute", () => {
|
||||
it("refreshes popover attribute when changed from manual", () => {
|
||||
const element = document.createElement("div");
|
||||
element.setAttribute("popover", "auto");
|
||||
element.showPopover = jest.fn();
|
||||
const refreshSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"refreshPopoverAttribute",
|
||||
);
|
||||
autofillInlineMenuContentService["buttonElement"] = element;
|
||||
|
||||
const mockMutation = createMutationRecordMock({
|
||||
target: element,
|
||||
type: "attributes",
|
||||
attributeName: "popover",
|
||||
});
|
||||
|
||||
autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([
|
||||
mockMutation,
|
||||
]);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(element);
|
||||
});
|
||||
|
||||
it("does not refresh popover attribute when already manual", () => {
|
||||
const element = document.createElement("div");
|
||||
element.setAttribute("popover", "manual");
|
||||
const refreshSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"refreshPopoverAttribute",
|
||||
);
|
||||
autofillInlineMenuContentService["buttonElement"] = element;
|
||||
|
||||
const mockMutation = createMutationRecordMock({
|
||||
target: element,
|
||||
type: "attributes",
|
||||
attributeName: "popover",
|
||||
});
|
||||
|
||||
autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([
|
||||
mockMutation,
|
||||
]);
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendInlineMenuElements when disabled", () => {
|
||||
beforeEach(() => {
|
||||
observeContainerMutationsSpy.mockImplementation();
|
||||
});
|
||||
|
||||
it("does not append button when inline menu is disabled", async () => {
|
||||
autofillInlineMenuContentService["inlineMenuEnabled"] = false;
|
||||
jest.spyOn(globalThis.document.body, "appendChild");
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.document.body.appendChild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not append list when inline menu is disabled", async () => {
|
||||
autofillInlineMenuContentService["inlineMenuEnabled"] = false;
|
||||
jest.spyOn(globalThis.document.body, "appendChild");
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.document.body.appendChild).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom element creation for non-Firefox browsers", () => {
|
||||
beforeEach(() => {
|
||||
autofillInlineMenuContentService["isFirefoxBrowser"] = false;
|
||||
observeContainerMutationsSpy.mockImplementation();
|
||||
});
|
||||
|
||||
it("creates a custom element for button in non-Firefox browsers", () => {
|
||||
const definespy = jest.spyOn(globalThis.customElements, "define");
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
|
||||
expect(definespy).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuContentService["buttonElement"]).toBeDefined();
|
||||
expect(autofillInlineMenuContentService["buttonElement"]?.tagName).not.toBe("DIV");
|
||||
});
|
||||
|
||||
it("creates a custom element for list in non-Firefox browsers", () => {
|
||||
const defineSpy = jest.spyOn(globalThis.customElements, "define");
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
|
||||
expect(defineSpy).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuContentService["listElement"]).toBeDefined();
|
||||
expect(autofillInlineMenuContentService["listElement"]?.tagName).not.toBe("DIV");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPageIsOpaque", () => {
|
||||
it("returns false when no page elements exist", () => {
|
||||
jest.spyOn(globalThis.document, "querySelectorAll").mockReturnValue([] as any);
|
||||
|
||||
const result = autofillInlineMenuContentService["getPageIsOpaque"]();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when all html and body nodes have sufficient opacity", () => {
|
||||
jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([document.documentElement, document.body] as any);
|
||||
jest
|
||||
.spyOn(globalThis.window, "getComputedStyle")
|
||||
.mockImplementation(() => ({ opacity: "1" }) as CSSStyleDeclaration);
|
||||
|
||||
const result = autofillInlineMenuContentService["getPageIsOpaque"]();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when html opacity is below threshold", () => {
|
||||
jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([document.documentElement, document.body] as any);
|
||||
let callCount = 0;
|
||||
jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => {
|
||||
callCount++;
|
||||
return { opacity: callCount === 1 ? "0.5" : "1" } as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
const result = autofillInlineMenuContentService["getPageIsOpaque"]();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when body opacity is below threshold", () => {
|
||||
jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([document.documentElement, document.body] as any);
|
||||
let callCount = 0;
|
||||
jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => {
|
||||
callCount++;
|
||||
return { opacity: callCount === 1 ? "1" : "0.5" } as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
const result = autofillInlineMenuContentService["getPageIsOpaque"]();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when opacity of at least one duplicate body is below threshold", () => {
|
||||
const duplicateBody = document.createElement("body");
|
||||
jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([document.documentElement, document.body, duplicateBody] as any);
|
||||
let callCount = 0;
|
||||
jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => {
|
||||
callCount++;
|
||||
|
||||
let opacityValue = "0.5";
|
||||
switch (callCount) {
|
||||
case 1:
|
||||
opacityValue = "1";
|
||||
break;
|
||||
case 2:
|
||||
opacityValue = "0.7";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return { opacity: opacityValue } as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
const result = autofillInlineMenuContentService["getPageIsOpaque"]();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when opacity is above threshold", () => {
|
||||
jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([document.documentElement, document.body] as any);
|
||||
jest
|
||||
.spyOn(globalThis.window, "getComputedStyle")
|
||||
.mockImplementation(() => ({ opacity: "0.7" }) as CSSStyleDeclaration);
|
||||
|
||||
const result = autofillInlineMenuContentService["getPageIsOpaque"]();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when opacity is at threshold", () => {
|
||||
jest
|
||||
.spyOn(globalThis.document, "querySelectorAll")
|
||||
.mockReturnValue([document.documentElement, document.body] as any);
|
||||
jest
|
||||
.spyOn(globalThis.window, "getComputedStyle")
|
||||
.mockImplementation(() => ({ opacity: "0.6" }) as CSSStyleDeclaration);
|
||||
|
||||
const result = autofillInlineMenuContentService["getPageIsOpaque"]();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,19 @@ import {
|
||||
import { AutofillInlineMenuButtonIframe } from "../iframe-content/autofill-inline-menu-button-iframe";
|
||||
import { AutofillInlineMenuListIframe } from "../iframe-content/autofill-inline-menu-list-iframe";
|
||||
|
||||
const experienceValidationBackoffThresholds = {
|
||||
topLayer: {
|
||||
countLimit: 5,
|
||||
timeSpanLimit: 5000,
|
||||
},
|
||||
popoverAttribute: {
|
||||
countLimit: 10,
|
||||
timeSpanLimit: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
type BackoffCheckType = keyof typeof experienceValidationBackoffThresholds;
|
||||
|
||||
export class AutofillInlineMenuContentService implements AutofillInlineMenuContentServiceInterface {
|
||||
private readonly sendExtensionMessage = sendExtensionMessage;
|
||||
private readonly generateRandomCustomElementName = generateRandomCustomElementName;
|
||||
@@ -35,6 +48,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
private bodyMutationObserver: MutationObserver;
|
||||
private inlineMenuElementsMutationObserver: MutationObserver;
|
||||
private containerElementMutationObserver: MutationObserver;
|
||||
private refreshCountWithinTimeThreshold: { [key in BackoffCheckType]: number } = {
|
||||
topLayer: 0,
|
||||
popoverAttribute: 0,
|
||||
};
|
||||
private lastTrackedTimestamp = {
|
||||
topLayer: Date.now(),
|
||||
popoverAttribute: Date.now(),
|
||||
};
|
||||
/**
|
||||
* Distinct from preventing inline menu script injection, this is for cases
|
||||
* where the page is subsequently determined to be risky.
|
||||
*/
|
||||
private inlineMenuEnabled = true;
|
||||
private mutationObserverIterations = 0;
|
||||
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
|
||||
private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout;
|
||||
@@ -140,6 +166,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
* Updates the position of both the inline menu button and inline menu list.
|
||||
*/
|
||||
private async appendInlineMenuElements({ overlayElement }: AutofillExtensionMessage) {
|
||||
if (!this.inlineMenuEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlayElement === AutofillOverlayElement.Button) {
|
||||
return this.appendButtonElement();
|
||||
}
|
||||
@@ -151,6 +181,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
* Updates the position of the inline menu button.
|
||||
*/
|
||||
private async appendButtonElement(): Promise<void> {
|
||||
if (!this.inlineMenuEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.buttonElement) {
|
||||
this.createButtonElement();
|
||||
this.updateCustomElementDefaultStyles(this.buttonElement);
|
||||
@@ -167,6 +201,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
* Updates the position of the inline menu list.
|
||||
*/
|
||||
private async appendListElement(): Promise<void> {
|
||||
if (!this.inlineMenuEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.listElement) {
|
||||
this.createListElement();
|
||||
this.updateCustomElementDefaultStyles(this.listElement);
|
||||
@@ -219,6 +257,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
* to create the element if it already exists in the DOM.
|
||||
*/
|
||||
private createButtonElement() {
|
||||
if (!this.inlineMenuEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isFirefoxBrowser) {
|
||||
this.buttonElement = globalThis.document.createElement("div");
|
||||
this.buttonElement.setAttribute("popover", "manual");
|
||||
@@ -247,6 +289,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
* to create the element if it already exists in the DOM.
|
||||
*/
|
||||
private createListElement() {
|
||||
if (!this.inlineMenuEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isFirefoxBrowser) {
|
||||
this.listElement = globalThis.document.createElement("div");
|
||||
this.listElement.setAttribute("popover", "manual");
|
||||
@@ -381,14 +427,23 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
}
|
||||
|
||||
const element = record.target as HTMLElement;
|
||||
if (record.attributeName !== "style") {
|
||||
this.removeModifiedElementAttributes(element);
|
||||
if (record.attributeName === "popover" && this.inlineMenuEnabled) {
|
||||
const attributeValue = element.getAttribute(record.attributeName);
|
||||
if (attributeValue !== "manual") {
|
||||
this.refreshPopoverAttribute(element);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.attributeName === "style") {
|
||||
element.removeAttribute("style");
|
||||
this.updateCustomElementDefaultStyles(element);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
this.removeModifiedElementAttributes(element);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -402,7 +457,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
const attributes = Array.from(element.attributes);
|
||||
for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) {
|
||||
const attribute = attributes[attributeIndex];
|
||||
if (attribute.name === "style") {
|
||||
if (attribute.name === "style" || attribute.name === "popover") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -432,7 +487,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
private checkPageRisks = async () => {
|
||||
const pageIsOpaque = await this.getPageIsOpaque();
|
||||
|
||||
const risksFound = !pageIsOpaque;
|
||||
const risksFound = !pageIsOpaque || !this.inlineMenuEnabled;
|
||||
|
||||
if (risksFound) {
|
||||
this.closeInlineMenu();
|
||||
@@ -483,7 +538,49 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
return otherTopLayeritems;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internally track owned injected experience refreshes as a side-effect
|
||||
* of host page interference.
|
||||
*/
|
||||
private checkAndUpdateRefreshCount = (countType: BackoffCheckType) => {
|
||||
if (!this.inlineMenuEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { countLimit, timeSpanLimit } = experienceValidationBackoffThresholds[countType];
|
||||
const now = Date.now();
|
||||
const timeSinceLastTrackedRefresh = now - this.lastTrackedTimestamp[countType];
|
||||
const currentlyWithinTimeThreshold = timeSinceLastTrackedRefresh <= timeSpanLimit;
|
||||
const withinCountThreshold = this.refreshCountWithinTimeThreshold[countType] <= countLimit;
|
||||
|
||||
if (currentlyWithinTimeThreshold) {
|
||||
if (withinCountThreshold) {
|
||||
this.refreshCountWithinTimeThreshold[countType]++;
|
||||
} else {
|
||||
// Set inline menu to be off; page is aggressively trying to take top position of top layer
|
||||
this.inlineMenuEnabled = false;
|
||||
void this.checkPageRisks();
|
||||
|
||||
const warningMessage = chrome.i18n.getMessage("topLayerHijackWarning");
|
||||
globalThis.window.alert(warningMessage);
|
||||
}
|
||||
} else {
|
||||
this.lastTrackedTimestamp[countType] = now;
|
||||
this.refreshCountWithinTimeThreshold[countType] = 0;
|
||||
}
|
||||
};
|
||||
|
||||
private refreshPopoverAttribute = (element: HTMLElement) => {
|
||||
this.checkAndUpdateRefreshCount("popoverAttribute");
|
||||
element.setAttribute("popover", "manual");
|
||||
element.showPopover();
|
||||
};
|
||||
|
||||
refreshTopLayerPosition = () => {
|
||||
if (!this.inlineMenuEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const otherTopLayerItems = this.getUnownedTopLayerItems();
|
||||
|
||||
// No need to refresh if there are no other top-layer items
|
||||
@@ -497,6 +594,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
const listInDocument =
|
||||
this.listElement &&
|
||||
(globalThis.document.getElementsByTagName(this.listElement.tagName)[0] as HTMLElement);
|
||||
|
||||
if (buttonInDocument) {
|
||||
buttonInDocument.hidePopover();
|
||||
buttonInDocument.showPopover();
|
||||
@@ -506,6 +604,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
listInDocument.hidePopover();
|
||||
listInDocument.showPopover();
|
||||
}
|
||||
|
||||
if (buttonInDocument || listInDocument) {
|
||||
this.checkAndUpdateRefreshCount("topLayer");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -515,24 +617,28 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
* `body` (enforced elsewhere).
|
||||
*/
|
||||
private getPageIsOpaque = () => {
|
||||
// These are computed style values, so we don't need to worry about non-float values
|
||||
// for `opacity`, here
|
||||
// @TODO for definitive checks, traverse up the node tree from the inline menu container;
|
||||
// nodes can exist between `html` and `body`
|
||||
const htmlElement = globalThis.document.querySelector("html");
|
||||
const bodyElement = globalThis.document.querySelector("body");
|
||||
/**
|
||||
* `querySelectorAll` for (non-standard) cases where the page has additional copies of
|
||||
* page nodes that should be unique
|
||||
*/
|
||||
const pageElements = globalThis.document.querySelectorAll("html, body");
|
||||
|
||||
if (!htmlElement || !bodyElement) {
|
||||
if (!pageElements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const htmlOpacity = globalThis.window.getComputedStyle(htmlElement)?.opacity || "0";
|
||||
const bodyOpacity = globalThis.window.getComputedStyle(bodyElement)?.opacity || "0";
|
||||
return [...pageElements].every((element) => {
|
||||
// These are computed style values, so we don't need to worry about non-float values
|
||||
// for `opacity`, here
|
||||
const elementOpacity = globalThis.window.getComputedStyle(element)?.opacity || "0";
|
||||
|
||||
// Any value above this is considered "opaque" for our purposes
|
||||
const opacityThreshold = 0.6;
|
||||
|
||||
return parseFloat(htmlOpacity) > opacityThreshold && parseFloat(bodyOpacity) > opacityThreshold;
|
||||
return parseFloat(elementOpacity) > opacityThreshold;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user