From 654cfa8ff118963a2e1a98fdf58a18e6a9bdfe15 Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Wed, 15 Jul 2026 11:54:45 -0400 Subject: [PATCH 1/4] feat: incoming mesage origin validation for web --- platforms/web/src/checkout-protocol.test.ts | 173 +++++++++++++++++++- platforms/web/src/checkout.ts | 144 +++++++++++++++- platforms/web/src/checkout.types.ts | 37 +++++ platforms/web/src/index.ts | 7 +- 4 files changed, 357 insertions(+), 4 deletions(-) diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index bb836fe4a..df0d6c62b 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -743,18 +743,136 @@ 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", + }); + await flushProtocolDispatch(); + + expect(onStartSpy).toHaveBeenCalledOnce(); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); + }); + + 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("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)); }); @@ -858,6 +976,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..0fe32c4fb 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -21,12 +21,67 @@ 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"] as const; + +/** Matches a wildcard-subdomain origin pattern, e.g. `https://*.example.com[:8443]`. */ +const WILDCARD_ORIGIN_PATTERN = /^([a-zA-Z][\w+.-]*):\/\/\*\.([^/:]+)(?::(\d+))?$/; + +/** 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); + return URL.canParse(pattern); +} + +/** + * 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 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 ((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 +253,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 +586,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 +601,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 +673,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. From ac6d0a9c2d2bc336e80e794b3382dfc5e080eabe Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Fri, 31 Jul 2026 13:07:20 +0200 Subject: [PATCH 2/4] fix(web): normalize allowed message origins --- platforms/web/src/checkout-protocol.test.ts | 55 +++++++++++++++++++++ platforms/web/src/checkout.ts | 17 ++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index df0d6c62b..79c8b6cd3 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -842,6 +842,61 @@ describe("", () => { 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("validates configured origins when URL.canParse is unavailable", 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", diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 0fe32c4fb..67cc5991e 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -48,7 +48,20 @@ const WILDCARD_ORIGIN_PATTERN = /^([a-zA-Z][\w+.-]*):\/\/\*\.([^/:]+)(?::(\d+))? function isValidOriginPattern(pattern: string): boolean { if (pattern === "*") return true; if (pattern.includes("*")) return WILDCARD_ORIGIN_PATTERN.test(pattern); - return URL.canParse(pattern); + try { + return new URL(pattern).origin.length > 0; + } 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; } /** @@ -75,7 +88,7 @@ function originMatchesPattern(pattern: string, origin: URL): boolean { if (scheme === undefined || suffix === undefined) return false; if (`${scheme.toLowerCase()}:` !== origin.protocol) return false; - if ((port ?? "") !== origin.port) return false; + if (normalizedOriginPort(scheme.toLowerCase(), port) !== origin.port) return false; const host = origin.hostname.toLowerCase(); const suffixHost = suffix.toLowerCase(); From 1c51e83919013f99bdc8a097bcf79569c80dba4a Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 3 Aug 2026 13:40:38 +0200 Subject: [PATCH 3/4] fix(web): require configured origins --- platforms/web/src/checkout-protocol.test.ts | 37 +++++++++++++++++++++ platforms/web/src/checkout.ts | 16 ++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index 79c8b6cd3..4b7c08968 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -808,6 +808,43 @@ 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(); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 67cc5991e..10c0398ec 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -39,17 +39,25 @@ 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"] as const; +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 = /^([a-zA-Z][\w+.-]*):\/\/\*\.([^/:]+)(?::(\d+))?$/; +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 { - return new URL(pattern).origin.length > 0; + 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; } @@ -76,7 +84,7 @@ function originMatchesPattern(pattern: string, origin: URL): boolean { if (!pattern.includes("*")) { try { - return new URL(pattern).origin === origin.origin; + return isValidOriginPattern(pattern) && new URL(pattern).origin === origin.origin; } catch { return false; } From 6dd675efd1778cea26425acf6e0b232c6a8b13fa Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 3 Aug 2026 17:43:26 +0200 Subject: [PATCH 4/4] test(web): clarify URL compatibility coverage Assisted-By: devx/b6c5d227-73eb-46ef-8749-52f1bd6779d2 --- platforms/web/src/checkout-protocol.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index 4b7c08968..3a0eaf885 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -911,7 +911,7 @@ describe("", () => { expect(onStartSpy).toHaveBeenCalledOnce(); }); - it("validates configured origins when URL.canParse is unavailable", async () => { + it("supports configured origins in browsers without URL.canParse", async () => { const originalCanParse = URL.canParse; Object.defineProperty(URL, "canParse", { configurable: true, value: undefined });