From 536cedc7f776c61ccb0c0a7805edf38e33b9c271 Mon Sep 17 00:00:00 2001 From: npu Date: Wed, 24 Jun 2026 15:28:12 +0300 Subject: [PATCH] fix(receive): validate message event source and origin The window "message" listener accepted any message whose data.action started with "web-eid:" without checking event.source or event.origin. This allowed an embedded iframe, a framing parent, or any other window to forge extension responses and resolve/reject the library's pending authenticate(), sign(), getSigningCertificate() and status() promises with attacker-controlled data (CWE-346 / CWE-940). Reject messages whose source is not the page's own window or whose origin is not the page's own origin, matching how the extension content script posts responses back into the same window. Tests are updated to dispatch responses as the content script would, plus regression tests covering foreign-source and foreign-origin responses. --- src/__tests__/web-eid-test.ts | 26 ++++- src/services/WebExtensionService.ts | 17 ++- ...ExtensionService-sender-validation-test.ts | 106 ++++++++++++++++++ .../__tests__/WebExtensionService-test.ts | 56 +++++---- 4 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 src/services/__tests__/WebExtensionService-sender-validation-test.ts diff --git a/src/__tests__/web-eid-test.ts b/src/__tests__/web-eid-test.ts index 0553b37..528391f 100644 --- a/src/__tests__/web-eid-test.ts +++ b/src/__tests__/web-eid-test.ts @@ -7,6 +7,24 @@ import ActionOptions from "../models/ActionOptions"; Object.defineProperty(global.window, "isSecureContext", { get: () => true }); +/** + * Dispatch a message as the legitimate extension content script would: from the + * page's own window and origin, so that receive()'s sender validation accepts + * it. jsdom's window.postMessage sets source to null and origin to "". + */ +function postFromExtension(data: unknown): void { + // Deliver asynchronously (as window.postMessage does) so the response arrives + // after status()/authenticate() has awaited extensionLoadDelay and queued the + // request. + setTimeout(() => { + window.dispatchEvent(new MessageEvent("message", { + data, + source: window, + origin: window.location.origin, + })); + }); +} + describe("status", () => { afterEach(() => { jest.restoreAllMocks(); @@ -35,16 +53,16 @@ describe("status", () => { it("should return library, extension and app versions", async () => { const statusPromise = webeid.status(); - window.postMessage({ + postFromExtension({ action: "web-eid:status-ack", - }, "*"); + }); - window.postMessage({ + postFromExtension({ action: "web-eid:status-success", library: process.env.npm_package_version, extension: process.env.npm_package_version, nativeApp: process.env.npm_package_version, - }, "*"); + }); expect(await statusPromise).toMatchObject({ library: process.env.npm_package_version, diff --git a/src/services/WebExtensionService.ts b/src/services/WebExtensionService.ts index e0ba4df..6311118 100644 --- a/src/services/WebExtensionService.ts +++ b/src/services/WebExtensionService.ts @@ -24,11 +24,20 @@ export default class WebExtensionService { window.addEventListener("message", (event) => this.receive(event)); } - private receive(event: { data: ExtensionResponse }): void { - if (typeof event.data?.action !== "string") return; - if (!event.data.action.startsWith("web-eid:")) return; + private receive(event: MessageEvent): void { + // Only accept messages from the page's own window. The extension content + // script is injected into this document and posts responses back into the + // same window (event.source === window, event.origin === own origin). + // Reject anything else (embedded iframes, framing parents, other windows) + // to prevent forged extension responses. See CWE-346 / CWE-940. + if (event.source !== window) return; + if (event.origin !== window.location.origin) return; + + const message = event.data as ExtensionResponse; + + if (typeof message?.action !== "string") return; + if (!message.action.startsWith("web-eid:")) return; - const message = event.data; const suffix = ["success", "failure", "ack"].find((s) => message.action.endsWith(s)); const initialAction = this.getInitialAction(message.action); const pending = this.getPendingMessage(initialAction); diff --git a/src/services/__tests__/WebExtensionService-sender-validation-test.ts b/src/services/__tests__/WebExtensionService-sender-validation-test.ts new file mode 100644 index 0000000..7322251 --- /dev/null +++ b/src/services/__tests__/WebExtensionService-sender-validation-test.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +import { + ExtensionAuthenticateRequest, + ExtensionSignRequest, +} from "../../models/message/ExtensionRequest"; +import { + ExtensionAuthenticateResponse, + ExtensionSignResponse, +} from "../../models/message/ExtensionResponse"; + +import Action from "../../models/Action"; +import ActionTimeoutError from "../../errors/ActionTimeoutError"; +import WebExtensionService from "../WebExtensionService"; + +/** + * Regression tests for sender validation in receive(). + * + * A message is only honoured when it originates from the page's own window + * (event.source === window) and origin (event.origin === window.location.origin), + * matching how the extension content script posts responses back into the page. + * Forged responses from embedded iframes, framing parents or other windows must + * be ignored. See CWE-346 / CWE-940. + */ +describe("WebExtensionService sender validation", () => { + beforeAll(() => { + Object.defineProperty(global.window, "isSecureContext", { get: () => true }); + }); + + function dispatch(data: unknown, init: { source?: unknown; origin?: string }): void { + window.dispatchEvent(new MessageEvent("message", { + data, + source: init.source as MessageEventSource | null, + origin: init.origin ?? window.location.origin, + })); + } + + const authenticateRequest: ExtensionAuthenticateRequest = { + action: Action.AUTHENTICATE, + libraryVersion: "2.1.0", + challengeNonce: "12345678901234567890123456789012345678901234", + }; + + const signRequest: ExtensionSignRequest = { + action: Action.SIGN, + libraryVersion: "2.1.0", + certificate: "cert", + hash: "hash", + hashFunction: "SHA-256", + }; + + it("ignores a success response from a foreign source (e.g. an iframe)", async () => { + const service = new WebExtensionService(); + const pending = service.send(authenticateRequest, 50); + + dispatch({ + action: "web-eid:authenticate-success", + unverifiedCertificate: "ATTACKER_CONTROLLED_CERT", + signature: "ATTACKER_CONTROLLED_SIGNATURE", + }, { source: {}, origin: window.location.origin }); + + // The forged response is dropped, so the request times out instead of + // resolving with attacker data. + await expect(pending).rejects.toBeInstanceOf(ActionTimeoutError); + }); + + it("ignores a success response from a foreign origin", async () => { + const service = new WebExtensionService(); + const pending = service.send(signRequest, 50); + + dispatch({ + action: "web-eid:sign-success", + signature: "ATTACKER_CONTROLLED_SIGNATURE", + }, { source: window, origin: "https://evil.example" }); + + await expect(pending).rejects.toBeInstanceOf(ActionTimeoutError); + }); + + it("ignores a forged failure response from a foreign frame", async () => { + const service = new WebExtensionService(); + const pending = service.send(signRequest, 50); + + dispatch({ + action: "web-eid:sign-failure", + error: { code: "ERR_WEBEID_USER_CANCELLED", message: "spoofed" }, + }, { source: {}, origin: "https://evil.example" }); + + // Not rejected by the spoofed failure; times out instead. + await expect(pending).rejects.toBeInstanceOf(ActionTimeoutError); + }); + + it("accepts a legitimate same-window response", async () => { + const service = new WebExtensionService(); + const pending = service.send(authenticateRequest, 5000); + + dispatch({ + action: "web-eid:authenticate-success", + unverifiedCertificate: "REAL_CERT", + signature: "REAL_SIGNATURE", + }, { source: window, origin: window.location.origin }); + + const result = await pending; + expect(result.unverifiedCertificate).toBe("REAL_CERT"); + }); +}); diff --git a/src/services/__tests__/WebExtensionService-test.ts b/src/services/__tests__/WebExtensionService-test.ts index c126f53..e48bea8 100644 --- a/src/services/__tests__/WebExtensionService-test.ts +++ b/src/services/__tests__/WebExtensionService-test.ts @@ -4,6 +4,20 @@ import Action from "../../models/Action"; import WebExtensionService from "../WebExtensionService"; +/** + * Dispatch a message as the legitimate extension content script would: from the + * page's own window and origin (event.source === window, event.origin === own + * origin). jsdom's window.postMessage sets source to null and origin to "", so + * we construct the MessageEvent explicitly. + */ +function postFromExtension(data: unknown): void { + window.dispatchEvent(new MessageEvent("message", { + data, + source: window, + origin: window.location.origin, + })); +} + describe("WebExtensionService", () => { let service: WebExtensionService; @@ -20,7 +34,7 @@ describe("WebExtensionService", () => { it("should ignore messages with data but no action property", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ someOtherProperty: "value" }, "*"); + postFromExtension({ someOtherProperty: "value" }); await new Promise((resolve) => setTimeout(resolve)); expect(console.warn).not.toHaveBeenCalled(); @@ -29,7 +43,7 @@ describe("WebExtensionService", () => { it("should ignore messages with null data", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage(null, "*"); + postFromExtension(null); await new Promise((resolve) => setTimeout(resolve)); expect(console.warn).not.toHaveBeenCalled(); @@ -38,7 +52,7 @@ describe("WebExtensionService", () => { it("should ignore messages with undefined data", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage(undefined, "*"); + postFromExtension(undefined); await new Promise((resolve) => setTimeout(resolve)); expect(console.warn).not.toHaveBeenCalled(); @@ -47,7 +61,7 @@ describe("WebExtensionService", () => { it("should ignore messages with action as an object", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ action: { id: "123", _t: "456" } }, "*"); + postFromExtension({ action: { id: "123", _t: "456" } }); await new Promise((resolve) => setTimeout(resolve)); expect(console.warn).not.toHaveBeenCalled(); @@ -56,7 +70,7 @@ describe("WebExtensionService", () => { it("should ignore messages with action as an object with startsWith property", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ action: { startsWith: "2022-10-12", endsWith: "2026-10-12" } }, "*"); + postFromExtension({ action: { startsWith: "2022-10-12", endsWith: "2026-10-12" } }); await new Promise((resolve) => setTimeout(resolve)); expect(console.warn).not.toHaveBeenCalled(); @@ -65,7 +79,7 @@ describe("WebExtensionService", () => { it("should ignore messages with action as a number", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ action: 12345 }, "*"); + postFromExtension({ action: 12345 }); await new Promise((resolve) => setTimeout(resolve)); expect(console.warn).not.toHaveBeenCalled(); @@ -74,7 +88,7 @@ describe("WebExtensionService", () => { it("should ignore messages with action as an array", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ action: ["web-eid:test"] }, "*"); + postFromExtension({ action: ["web-eid:test"] }); await new Promise((resolve) => setTimeout(resolve)); expect(console.warn).not.toHaveBeenCalled(); @@ -85,10 +99,10 @@ describe("WebExtensionService", () => { it("should log a warning from web-eid:warning message", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ + postFromExtension({ action: "web-eid:warning", warnings: ["example warning"], - }, "*"); + }); await new Promise((resolve) => setTimeout(resolve)); @@ -99,15 +113,15 @@ describe("WebExtensionService", () => { it("should log multiple different warnings from separate web-eid:warning messages", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ + postFromExtension({ action: "web-eid:warning", warnings: ["example warning 1"], - }, "*"); + }); - window.postMessage({ + postFromExtension({ action: "web-eid:warning", warnings: ["example warning 2"], - }, "*"); + }); await new Promise((resolve) => setTimeout(resolve)); @@ -119,14 +133,14 @@ describe("WebExtensionService", () => { it("should log multiple different warnings from a single web-eid:warning message", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ + postFromExtension({ action: "web-eid:warning", warnings: [ "example warning 3", "example warning 4", "example warning 5", ], - }, "*"); + }); await new Promise((resolve) => setTimeout(resolve)); @@ -139,13 +153,13 @@ describe("WebExtensionService", () => { it("should not log the same message multiple times from one web-eid:warning message", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ + postFromExtension({ action: "web-eid:warning", warnings: [ "example same warning 1", "example same warning 1", ], - }, "*"); + }); await new Promise((resolve) => setTimeout(resolve)); @@ -156,15 +170,15 @@ describe("WebExtensionService", () => { it("should not log the same message multiple times from multiple web-eid:warning messages", async () => { jest.spyOn(console, "warn").mockImplementation(); - window.postMessage({ + postFromExtension({ action: "web-eid:warning", warnings: ["example same warning 2"], - }, "*"); + }); - window.postMessage({ + postFromExtension({ action: "web-eid:warning", warnings: ["example same warning 2"], - }, "*"); + }); await new Promise((resolve) => setTimeout(resolve));