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));