diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index bb836fe4a..3a0eaf885 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -743,12 +743,61 @@ describe("", () => { }); describe("message routing", () => { - it("handles protocol messages from any HTTPS origin when the source matches", async () => { + it("accepts protocol messages from the cart URL origin by default", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); const payload = makeCheckoutPayload(); checkout.addEventListener("ec.start", onStartSpy); + simulateProtocolMessageEvent(checkout, "ec.start", payload, { + source: mockCheckoutWindow, + origin: new URL(checkout.src).origin, + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); + }); + + it("accepts protocol messages from shop.app by default", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onStartSpy = vi.fn(); + const payload = makeCheckoutPayload(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", payload, { + source: mockCheckoutWindow, + origin: "https://shop.app", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); + }); + + it("drops protocol messages from an untrusted HTTPS origin by default", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).not.toHaveBeenCalled(); + expect(checkout.checkout).toBeUndefined(); + }); + + it("accepts protocol messages from a configured allowed origin", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": "https://other.example.com", + }); + const onStartSpy = vi.fn(); + const payload = makeCheckoutPayload(); + checkout.addEventListener("ec.start", onStartSpy); + simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow, origin: "https://other.example.com", @@ -759,6 +808,167 @@ describe("", () => { expect(checkout.checkout).toEqual(decodeCheckout(payload)); }); + it("accepts an exact configured origin with a trailing slash", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": "https://other.example.com/", + }); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + }); + + it.each([ + "https://user@other.example.com", + "https://other.example.com/path", + "https://other.example.com?query=value", + "https://other.example.com#fragment", + ])("ignores a configured URL that is not an origin: %s", async (pattern) => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": pattern, + }); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).not.toHaveBeenCalled(); + }); + + it("accepts protocol messages from a shop.app subdomain by default", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onStartSpy = vi.fn(); + const payload = makeCheckoutPayload(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", payload, { + source: mockCheckoutWindow, + origin: "https://checkout.shop.app", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); + }); + + it("accepts protocol messages matching a configured wildcard subdomain", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": "https://*.example.com", + }); + const onStartSpy = vi.fn(); + const payload = makeCheckoutPayload(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", payload, { + source: mockCheckoutWindow, + origin: "https://fr.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); + }); + + it("normalizes a default HTTPS port in a configured wildcard subdomain", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": "https://*.example.com:443", + }); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://checkout.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + }); + + it("normalizes a default HTTPS port in an exact configured origin", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": "https://other.example.com:443", + }); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + }); + + it("supports configured origins in browsers without URL.canParse", async () => { + const originalCanParse = URL.canParse; + Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }); + + try { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": "https://other.example.com", + }); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + } finally { + Object.defineProperty(URL, "canParse", { configurable: true, value: originalCanParse }); + } + }); + + it("does not match the apex origin for a wildcard subdomain pattern", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": "https://*.example.com", + }); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).not.toHaveBeenCalled(); + expect(checkout.checkout).toBeUndefined(); + }); + + it("accepts protocol messages from any origin when allowedOrigins includes '*'", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "allowed-origins": "*", + }); + const onStartSpy = vi.fn(); + const payload = makeCheckoutPayload(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", payload, { + source: mockCheckoutWindow, + origin: "https://anything.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); + }); + it("drops protocol messages when the source is not the checkout window", async () => { const { checkout } = openPopupCheckout(); const otherWindow = createMockWindow(); @@ -858,6 +1068,59 @@ describe("", () => { }); }); + describe("onMessageRejected callback", () => { + it("invokes onMessageRejected with origin, data, and reason for dropped messages", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onMessageRejected = vi.fn(); + checkout.onMessageRejected = onMessageRejected; + const onStartSpy = vi.fn(); + checkout.addEventListener("ec.start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).not.toHaveBeenCalled(); + expect(onMessageRejected).toHaveBeenCalledOnce(); + expect(onMessageRejected).toHaveBeenCalledWith( + expect.objectContaining({ + origin: "https://other.example.com", + reason: expect.stringContaining("not in allowlist"), + data: expect.objectContaining({ method: "ec.start" }), + }), + ); + }); + + it("falls back to a warning when onMessageRejected is not set", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ "log-level": "warn" }); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await flushProtocolDispatch(); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("not in allowlist")); + }); + + it("does not invoke onMessageRejected for trusted origins", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onMessageRejected = vi.fn(); + checkout.onMessageRejected = onMessageRejected; + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: new URL(checkout.src).origin, + }); + await flushProtocolDispatch(); + + expect(onMessageRejected).not.toHaveBeenCalled(); + }); + }); + describe("addEventListener override", () => { it("is a no-op when called with a null listener", () => { const checkout = renderCheckout(); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 4f0a52fdf..10c0398ec 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -21,12 +21,88 @@ import type { CheckoutAppearance, ErrorResponse, LogLevel, + MessageRejectedDetail, } from "./checkout.types"; export const DEFAULT_POPUP_WIDTH = 600; export const DEFAULT_POPUP_HEIGHT = 600; export const CK_VERSION = "4.0.0"; +/** + * Trusted origin always allowed to post messages, alongside the cart URL + * origin derived from `src`. Both are included whether or not the integrator + * configures an explicit `allowedOrigins` list. + */ +export const SHOP_APP_ORIGIN = "https://shop.app"; + +/** + * Default trusted origin patterns for `shop.app`: the apex origin plus a + * wildcard covering its subdomains (e.g. regional or checkout subdomains). + */ +const SHOP_APP_ORIGIN_PATTERNS = [SHOP_APP_ORIGIN, "https://*.shop.app"]; + +/** Matches a wildcard-subdomain origin pattern, e.g. `https://*.example.com[:8443]`. */ +const WILDCARD_ORIGIN_PATTERN = /^(https?):\/\/\*\.([^/:]+)(?::(\d+))?\/?$/i; + +/** Returns whether `pattern` is a usable origin pattern (`*`, wildcard, or exact origin). */ +function isValidOriginPattern(pattern: string): boolean { + if (pattern === "*") return true; + if (pattern.includes("*")) return WILDCARD_ORIGIN_PATTERN.test(pattern); + try { + const url = new URL(pattern); + return ( + (url.protocol === "https:" || url.protocol === "http:") && + url.username === "" && + url.password === "" && + url.pathname === "/" && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } +} + +/** Returns the serialized port browsers use for an origin. */ +function normalizedOriginPort(protocol: string, port: string | undefined): string { + if (port === undefined) return ""; + if ((protocol === "http" && port === "80") || (protocol === "https" && port === "443")) { + return ""; + } + return port; +} + +/** + * Tests whether `origin` satisfies an allowlist `pattern`: + * - `"*"` matches every origin. + * - `https://*.example.com` matches proper subdomains of `example.com` (not the + * apex), requiring the scheme and port to match too. + * - Anything else is treated as an exact origin (normalized via `URL`). + */ +function originMatchesPattern(pattern: string, origin: URL): boolean { + if (pattern === "*") return true; + + if (!pattern.includes("*")) { + try { + return isValidOriginPattern(pattern) && new URL(pattern).origin === origin.origin; + } catch { + return false; + } + } + + const match = WILDCARD_ORIGIN_PATTERN.exec(pattern); + if (!match) return false; + const [, scheme, suffix, port] = match; + if (scheme === undefined || suffix === undefined) return false; + + if (`${scheme.toLowerCase()}:` !== origin.protocol) return false; + if (normalizedOriginPort(scheme.toLowerCase(), port) !== origin.port) return false; + + const host = origin.hostname.toLowerCase(); + const suffixHost = suffix.toLowerCase(); + return host !== suffixHost && host.endsWith(`.${suffixHost}`); +} + const WINDOW_OPEN_INVALID_URL_WARNING = "ec.window.open_request received without a valid url"; const EMBED_DELEGATIONS = [EmbeddedCheckoutProtocol.Delegations.windowOpen] as const; @@ -198,6 +274,43 @@ export class ShopifyCheckout this.#setAttribute("appearance", value); } + /** + * Extra origins allowed to post incoming checkout-protocol messages, on top + * of the always-trusted cart URL origin (from `src`) and `shop.app`. + * + * Checkout on web is closed by default: with no configured origins, only the + * cart URL origin and `shop.app` (including its subdomains) are trusted. Add + * origins here to widen the allowlist. Entries may be exact origins + * (`https://example.com`), wildcard subdomains (`https://*.example.com`), or + * `"*"` to disable origin validation entirely. + * + * Reflected to the space/comma-separated `allowed-origins` attribute, so the + * attribute and property can be used interchangeably. + */ + get allowedOrigins(): string[] { + const attr = this.getAttribute("allowed-origins"); + if (!attr) return []; + return attr.split(/[\s,]+/).filter(Boolean); + } + + set allowedOrigins(value: string[] | string | undefined) { + if (value == null) { + this.removeAttribute("allowed-origins"); + return; + } + const serialized = Array.isArray(value) ? value.join(" ") : value; + this.#setAttribute("allowed-origins", serialized); + } + + /** + * Invoked when an incoming message is dropped by origin validation. The + * smart default logs a warning; assign a function to observe rejected + * messages instead (for example, to report them). Beware treating rejected + * messages as trusted — they were dropped precisely because their origin was + * not in the allowlist. + */ + onMessageRejected?: (detail: MessageRejectedDetail) => void; + #setAttribute(name: string, value: string | boolean | undefined) { if (value === true) { this.setAttribute(name, ""); @@ -494,7 +607,8 @@ export class ShopifyCheckout */ #validateMessageOrigin(event: MessageEvent) { - if (!this.#srcAsURL()) { + const src = this.#srcAsURL(); + if (!src) { throw new Error("Dropped message because src is invalid or unset"); } @@ -508,6 +622,53 @@ export class ShopifyCheckout if (origin.protocol !== "https:") { throw new Error(`Dropped message from non-HTTPS origin "${event.origin}"`); } + + const patterns = this.#allowedOriginPatterns(src); + if (patterns !== null && !patterns.some((pattern) => originMatchesPattern(pattern, origin))) { + throw new Error(`Dropped message from origin "${origin.origin}" not in allowlist`); + } + } + + /** + * Computes the effective set of trusted origins for incoming messages, or + * `null` when validation is disabled via the `"*"` escape hatch. + * + * Web is closed by default: the cart URL origin (from `src`) and `shop.app` + * are always trusted, and any configured `allowedOrigins` are added on top. + */ + #allowedOriginPatterns(src: URL): string[] | null { + const configured = this.allowedOrigins; + if (configured.includes("*")) return null; + + const patterns = [src.origin, ...SHOP_APP_ORIGIN_PATTERNS]; + for (const entry of configured) { + if (isValidOriginPattern(entry)) { + patterns.push(entry); + } else { + this.#logger.warn(`Ignoring invalid allowed origin "${entry}"`); + } + } + return patterns; + } + + /** + * Routes a dropped message to the {@link onMessageRejected} callback, falling + * back to a logged warning when no callback is set. + */ + #rejectMessage(event: MessageEvent, error: unknown) { + const reason = error instanceof Error ? error.message : String(error); + if (this.onMessageRejected) { + try { + this.onMessageRejected({ origin: event.origin, data: event.data, reason }); + } catch (callbackError) { + this.#logger.error( + "onMessageRejected callback threw", + callbackError instanceof Error ? callbackError.message : String(callbackError), + ); + } + return; + } + this.#logger.warn(reason); } #initCheckoutProtocol() { @@ -533,7 +694,7 @@ export class ShopifyCheckout try { this.#validateMessageOrigin(event); } catch (error) { - this.#logger.warn(error instanceof Error ? error.message : String(error)); + this.#rejectMessage(event, error); return; } diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index b19727fe3..d07592bc8 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -34,6 +34,22 @@ export interface CheckoutAttributes { target?: CheckoutTarget | string; appearance?: CheckoutAppearance | string; "log-level"?: LogLevel; + /** + * Space/comma-separated list of extra trusted message origin patterns. Each + * entry may be an exact origin (`https://example.com`), a wildcard subdomain + * (`https://*.example.com`), or `*` to disable origin validation. + */ + "allowed-origins"?: string; +} + +/** Payload passed to {@link CheckoutProperties.onMessageRejected}. */ +export interface MessageRejectedDetail { + /** Origin of the dropped `MessageEvent`. */ + origin: string; + /** Raw `event.data` of the dropped message. Treat as untrusted. */ + data: unknown; + /** Human-readable reason the message was dropped. */ + reason: string; } export interface CheckoutMethods { @@ -95,6 +111,27 @@ export interface CheckoutProperties { * ``` */ logLevel?: LogLevel; + + /** + * Extra origins allowed to post incoming checkout-protocol messages, on top + * of the always-trusted cart URL origin (from `src`) and `shop.app`. + * + * Web is closed by default: with no configured origins only the cart URL + * origin and `shop.app` (including its subdomains) are trusted. Entries may + * be exact origins (`https://example.com`), wildcard subdomains + * (`https://*.example.com`), or `'*'` to disable origin validation entirely. + * + * Reflected to the space/comma-separated `allowed-origins` attribute. + */ + allowedOrigins?: string[]; + + /** + * Called when an incoming message is dropped by origin validation. The smart + * default logs a warning; override to observe rejected messages. Treat the + * payload as untrusted — it was dropped precisely because its origin was not + * in the allowlist. + */ + onMessageRejected?: (detail: MessageRejectedDetail) => void; } export type TypedEventListener = diff --git a/platforms/web/src/index.ts b/platforms/web/src/index.ts index 5671d4ba3..b45786552 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -26,7 +26,12 @@ export type { } from "./checkout"; // Public configuration types. -export type { CheckoutAppearance, CheckoutTarget, LogLevel } from "./checkout.types"; +export type { + CheckoutAppearance, + CheckoutTarget, + LogLevel, + MessageRejectedDetail, +} from "./checkout.types"; // UCP domain types — surfaced because they appear on event details and the // `element.checkout` / `element.error` mirrors.