Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/__tests__/web-eid-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 13 additions & 4 deletions src/services/WebExtensionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
106 changes: 106 additions & 0 deletions src/services/__tests__/WebExtensionService-sender-validation-test.ts
Original file line number Diff line number Diff line change
@@ -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<ExtensionAuthenticateResponse>(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<ExtensionSignResponse>(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<ExtensionSignResponse>(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<ExtensionAuthenticateResponse>(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");
});
});
56 changes: 35 additions & 21 deletions src/services/__tests__/WebExtensionService-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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));

Expand All @@ -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));

Expand All @@ -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));

Expand All @@ -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));

Expand All @@ -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));

Expand Down