mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +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": {
|
"setMasterPassword": {
|
||||||
"message": "Set master password"
|
"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", () => {
|
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");
|
const element = document.createElement("div");
|
||||||
autofillInlineMenuContentService["listElement"] = element;
|
autofillInlineMenuContentService["listElement"] = element;
|
||||||
|
|
||||||
expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(true);
|
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", () => {
|
describe("extension message handlers", () => {
|
||||||
@@ -388,7 +410,7 @@ describe("AutofillInlineMenuContentService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("closes the inline menu if the page body is not sufficiently opaque", async () => {
|
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";
|
document.body.style.opacity = "0";
|
||||||
await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]);
|
await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]);
|
||||||
|
|
||||||
@@ -397,7 +419,7 @@ describe("AutofillInlineMenuContentService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("closes the inline menu if the page html is not sufficiently opaque", async () => {
|
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";
|
document.body.style.opacity = "0.7";
|
||||||
await autofillInlineMenuContentService["handlePageMutations"]([mockHTMLMutationRecord]);
|
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 () => {
|
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";
|
document.body.style.opacity = "1";
|
||||||
await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]);
|
await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]);
|
||||||
await waitForIdleCallback();
|
await waitForIdleCallback();
|
||||||
@@ -599,5 +621,465 @@ describe("AutofillInlineMenuContentService", () => {
|
|||||||
overlayElement: AutofillOverlayElement.List,
|
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 { AutofillInlineMenuButtonIframe } from "../iframe-content/autofill-inline-menu-button-iframe";
|
||||||
import { AutofillInlineMenuListIframe } from "../iframe-content/autofill-inline-menu-list-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 {
|
export class AutofillInlineMenuContentService implements AutofillInlineMenuContentServiceInterface {
|
||||||
private readonly sendExtensionMessage = sendExtensionMessage;
|
private readonly sendExtensionMessage = sendExtensionMessage;
|
||||||
private readonly generateRandomCustomElementName = generateRandomCustomElementName;
|
private readonly generateRandomCustomElementName = generateRandomCustomElementName;
|
||||||
@@ -35,6 +48,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
private bodyMutationObserver: MutationObserver;
|
private bodyMutationObserver: MutationObserver;
|
||||||
private inlineMenuElementsMutationObserver: MutationObserver;
|
private inlineMenuElementsMutationObserver: MutationObserver;
|
||||||
private containerElementMutationObserver: 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 mutationObserverIterations = 0;
|
||||||
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
|
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
|
||||||
private handlePersistentLastChildOverrideTimeout: 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.
|
* Updates the position of both the inline menu button and inline menu list.
|
||||||
*/
|
*/
|
||||||
private async appendInlineMenuElements({ overlayElement }: AutofillExtensionMessage) {
|
private async appendInlineMenuElements({ overlayElement }: AutofillExtensionMessage) {
|
||||||
|
if (!this.inlineMenuEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (overlayElement === AutofillOverlayElement.Button) {
|
if (overlayElement === AutofillOverlayElement.Button) {
|
||||||
return this.appendButtonElement();
|
return this.appendButtonElement();
|
||||||
}
|
}
|
||||||
@@ -151,6 +181,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
* Updates the position of the inline menu button.
|
* Updates the position of the inline menu button.
|
||||||
*/
|
*/
|
||||||
private async appendButtonElement(): Promise<void> {
|
private async appendButtonElement(): Promise<void> {
|
||||||
|
if (!this.inlineMenuEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.buttonElement) {
|
if (!this.buttonElement) {
|
||||||
this.createButtonElement();
|
this.createButtonElement();
|
||||||
this.updateCustomElementDefaultStyles(this.buttonElement);
|
this.updateCustomElementDefaultStyles(this.buttonElement);
|
||||||
@@ -167,6 +201,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
* Updates the position of the inline menu list.
|
* Updates the position of the inline menu list.
|
||||||
*/
|
*/
|
||||||
private async appendListElement(): Promise<void> {
|
private async appendListElement(): Promise<void> {
|
||||||
|
if (!this.inlineMenuEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.listElement) {
|
if (!this.listElement) {
|
||||||
this.createListElement();
|
this.createListElement();
|
||||||
this.updateCustomElementDefaultStyles(this.listElement);
|
this.updateCustomElementDefaultStyles(this.listElement);
|
||||||
@@ -219,6 +257,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
* to create the element if it already exists in the DOM.
|
* to create the element if it already exists in the DOM.
|
||||||
*/
|
*/
|
||||||
private createButtonElement() {
|
private createButtonElement() {
|
||||||
|
if (!this.inlineMenuEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isFirefoxBrowser) {
|
if (this.isFirefoxBrowser) {
|
||||||
this.buttonElement = globalThis.document.createElement("div");
|
this.buttonElement = globalThis.document.createElement("div");
|
||||||
this.buttonElement.setAttribute("popover", "manual");
|
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.
|
* to create the element if it already exists in the DOM.
|
||||||
*/
|
*/
|
||||||
private createListElement() {
|
private createListElement() {
|
||||||
|
if (!this.inlineMenuEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isFirefoxBrowser) {
|
if (this.isFirefoxBrowser) {
|
||||||
this.listElement = globalThis.document.createElement("div");
|
this.listElement = globalThis.document.createElement("div");
|
||||||
this.listElement.setAttribute("popover", "manual");
|
this.listElement.setAttribute("popover", "manual");
|
||||||
@@ -381,14 +427,23 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
}
|
}
|
||||||
|
|
||||||
const element = record.target as HTMLElement;
|
const element = record.target as HTMLElement;
|
||||||
if (record.attributeName !== "style") {
|
if (record.attributeName === "popover" && this.inlineMenuEnabled) {
|
||||||
this.removeModifiedElementAttributes(element);
|
const attributeValue = element.getAttribute(record.attributeName);
|
||||||
|
if (attributeValue !== "manual") {
|
||||||
|
this.refreshPopoverAttribute(element);
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (record.attributeName === "style") {
|
||||||
element.removeAttribute("style");
|
element.removeAttribute("style");
|
||||||
this.updateCustomElementDefaultStyles(element);
|
this.updateCustomElementDefaultStyles(element);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeModifiedElementAttributes(element);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -402,7 +457,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
const attributes = Array.from(element.attributes);
|
const attributes = Array.from(element.attributes);
|
||||||
for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) {
|
for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) {
|
||||||
const attribute = attributes[attributeIndex];
|
const attribute = attributes[attributeIndex];
|
||||||
if (attribute.name === "style") {
|
if (attribute.name === "style" || attribute.name === "popover") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +487,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
private checkPageRisks = async () => {
|
private checkPageRisks = async () => {
|
||||||
const pageIsOpaque = await this.getPageIsOpaque();
|
const pageIsOpaque = await this.getPageIsOpaque();
|
||||||
|
|
||||||
const risksFound = !pageIsOpaque;
|
const risksFound = !pageIsOpaque || !this.inlineMenuEnabled;
|
||||||
|
|
||||||
if (risksFound) {
|
if (risksFound) {
|
||||||
this.closeInlineMenu();
|
this.closeInlineMenu();
|
||||||
@@ -483,7 +538,49 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
return otherTopLayeritems;
|
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 = () => {
|
refreshTopLayerPosition = () => {
|
||||||
|
if (!this.inlineMenuEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const otherTopLayerItems = this.getUnownedTopLayerItems();
|
const otherTopLayerItems = this.getUnownedTopLayerItems();
|
||||||
|
|
||||||
// No need to refresh if there are no other top-layer items
|
// No need to refresh if there are no other top-layer items
|
||||||
@@ -497,6 +594,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
const listInDocument =
|
const listInDocument =
|
||||||
this.listElement &&
|
this.listElement &&
|
||||||
(globalThis.document.getElementsByTagName(this.listElement.tagName)[0] as HTMLElement);
|
(globalThis.document.getElementsByTagName(this.listElement.tagName)[0] as HTMLElement);
|
||||||
|
|
||||||
if (buttonInDocument) {
|
if (buttonInDocument) {
|
||||||
buttonInDocument.hidePopover();
|
buttonInDocument.hidePopover();
|
||||||
buttonInDocument.showPopover();
|
buttonInDocument.showPopover();
|
||||||
@@ -506,6 +604,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
listInDocument.hidePopover();
|
listInDocument.hidePopover();
|
||||||
listInDocument.showPopover();
|
listInDocument.showPopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (buttonInDocument || listInDocument) {
|
||||||
|
this.checkAndUpdateRefreshCount("topLayer");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -515,24 +617,28 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
* `body` (enforced elsewhere).
|
* `body` (enforced elsewhere).
|
||||||
*/
|
*/
|
||||||
private getPageIsOpaque = () => {
|
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;
|
// @TODO for definitive checks, traverse up the node tree from the inline menu container;
|
||||||
// nodes can exist between `html` and `body`
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const htmlOpacity = globalThis.window.getComputedStyle(htmlElement)?.opacity || "0";
|
return [...pageElements].every((element) => {
|
||||||
const bodyOpacity = globalThis.window.getComputedStyle(bodyElement)?.opacity || "0";
|
// 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
|
// Any value above this is considered "opaque" for our purposes
|
||||||
const opacityThreshold = 0.6;
|
const opacityThreshold = 0.6;
|
||||||
|
|
||||||
return parseFloat(htmlOpacity) > opacityThreshold && parseFloat(bodyOpacity) > opacityThreshold;
|
return parseFloat(elementOpacity) > opacityThreshold;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user