Skip to content
Closed
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
40 changes: 40 additions & 0 deletions packages/controller/src/__tests__/iframeSecurity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { resolveChildOrigin } from "../iframe/security";

describe("resolveChildOrigin", () => {
it("uses keychain URL origin when configured origin is omitted", () => {
const origin = resolveChildOrigin(
new URL("https://x.cartridge.gg/session"),
);
expect(origin).toBe("https://x.cartridge.gg");
});

it("accepts matching configured origin", () => {
const origin = resolveChildOrigin(
new URL("https://x.cartridge.gg/session"),
"https://x.cartridge.gg/path",
);

expect(origin).toBe("https://x.cartridge.gg");
});

it("throws when configured origin does not match keychain URL origin", () => {
expect(() =>
resolveChildOrigin(
new URL("https://x.cartridge.gg/session"),
"https://evil.example.com",
),
).toThrow("Keychain URL origin mismatch");
});

it("throws when keychain URL protocol is not http/https", () => {
expect(() => resolveChildOrigin(new URL("javascript:alert(1)"))).toThrow(
"Only http: and https: are allowed.",
);
});

it("throws when configured origin protocol is not http/https", () => {
expect(() =>
resolveChildOrigin(new URL("https://x.cartridge.gg"), "data:text/html"),
).toThrow("Only http: and https: are allowed.");
});
});
6 changes: 5 additions & 1 deletion packages/controller/src/iframe/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ export class IFrame<CallSender extends {}> implements Modal {
url,
onClose,
onConnect,
childOrigin,
methods = {},
}: {
id: string;
url: URL;
onClose?: () => void;
onConnect: (child: AsyncMethodReturns<CallSender>) => void;
childOrigin?: string;
methods?: { [key: string]: (...args: any[]) => void };
}) {
if (typeof document === "undefined" || typeof window === "undefined") {
Expand Down Expand Up @@ -54,7 +56,8 @@ export class IFrame<CallSender extends {}> implements Modal {
iframe.sandbox.add("allow-scripts");
iframe.sandbox.add("allow-same-origin");
iframe.allow =
"publickey-credentials-create *; publickey-credentials-get *; clipboard-write; local-network-access *; payment *";
"publickey-credentials-create *; publickey-credentials-get *; clipboard-write; payment *";
iframe.referrerPolicy = "no-referrer";
iframe.style.scrollbarWidth = "none";
iframe.style.setProperty("-ms-overflow-style", "none");
iframe.style.setProperty("-webkit-scrollbar", "none");
Expand Down Expand Up @@ -122,6 +125,7 @@ export class IFrame<CallSender extends {}> implements Modal {

connectToChild<CallSender>({
iframe: this.iframe,
childOrigin,
methods: {
close: (_origin: string) => () => this.close(),
reload: (_origin: string) => () => window.location.reload(),
Expand Down
4 changes: 4 additions & 0 deletions packages/controller/src/iframe/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { KEYCHAIN_URL } from "../constants";
import { Keychain, KeychainOptions } from "../types";
import { WalletBridge } from "../wallets/bridge";
import { IFrame, IFrameOptions } from "./base";
import { resolveChildOrigin } from "./security";

type KeychainIframeOptions = IFrameOptions<Keychain> &
KeychainOptions & {
Expand Down Expand Up @@ -31,6 +32,7 @@ export class KeychainIFrame extends IFrame<Keychain> {
preset,
shouldOverridePresetPolicies,
rpcUrl,
origin,
ref,
refGroup,
needsSessionCreation,
Expand All @@ -44,6 +46,7 @@ export class KeychainIFrame extends IFrame<Keychain> {
}: KeychainIframeOptions) {
let onStarterpackPlayHandler: (() => Promise<void>) | undefined;
const _url = new URL(url ?? KEYCHAIN_URL);
const childOrigin = resolveChildOrigin(_url, origin);
const walletBridge = new WalletBridge();

if (propagateSessionErrors) {
Expand Down Expand Up @@ -114,6 +117,7 @@ export class KeychainIFrame extends IFrame<Keychain> {

super({
...iframeOptions,
childOrigin,
id: "controller-keychain",
url: _url,
methods: {
Expand Down
37 changes: 37 additions & 0 deletions packages/controller/src/iframe/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const ALLOWED_IFRAME_PROTOCOLS = new Set(["http:", "https:"]);

const parseOrigin = (value: string, label: string): string => {
let parsedUrl: URL;
try {
parsedUrl = new URL(value);
} catch {
throw new Error(`Invalid ${label}: "${value}"`);
}

if (!ALLOWED_IFRAME_PROTOCOLS.has(parsedUrl.protocol)) {
throw new Error(
`Invalid ${label} protocol "${parsedUrl.protocol}". Only http: and https: are allowed.`,
);
}

return parsedUrl.origin;
};

export const resolveChildOrigin = (
url: URL,
configuredOrigin?: string,
): string => {
const urlOrigin = parseOrigin(url.toString(), "keychain URL");
if (!configuredOrigin) {
return urlOrigin;
}

const expectedOrigin = parseOrigin(configuredOrigin, "keychain origin");
if (expectedOrigin !== urlOrigin) {
throw new Error(
`Keychain URL origin mismatch. Expected "${expectedOrigin}" but received "${urlOrigin}".`,
);
}

return expectedOrigin;
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,25 @@ import {
cn,
} from "@cartridge/ui";
import { useOnchainPurchaseContext } from "@/context";
import { getSafeCoinbasePaymentUrl } from "@/utils/iframe-url";

export function CoinbaseCheckout() {
const { paymentLink, onCreateCoinbaseOrder } = useOnchainPurchaseContext();
const [isLoaded, setIsLoaded] = useState(false);
const [showPolicies, setShowPolicies] = useState(true);
const safePaymentLink = getSafeCoinbasePaymentUrl(paymentLink);
const hasInvalidPaymentLink = !!paymentLink && !safePaymentLink;

useEffect(() => {
if (!paymentLink) {
onCreateCoinbaseOrder();
}
}, [paymentLink, onCreateCoinbaseOrder]);

useEffect(() => {
setIsLoaded(false);
}, [safePaymentLink]);

return (
<>
{/* Policies Screen */}
Expand Down Expand Up @@ -75,16 +82,22 @@ export function CoinbaseCheckout() {
<SpinnerIcon className="animate-spin" size="lg" />
</div>
)}
{paymentLink ? (
{safePaymentLink ? (
<div className="h-full w-full px-10 flex justify-center">
<iframe
src={paymentLink}
src={safePaymentLink}
className="h-full w-full max-w-[440px] border-none"
sandbox="allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
allow="payment"
referrerPolicy="no-referrer"
title="Coinbase Onramp"
onLoad={() => setIsLoaded(true)}
/>
</div>
) : hasInvalidPaymentLink ? (
<div className="flex items-center justify-center h-full text-sm text-foreground-300 px-8 text-center">
Unable to load Coinbase checkout. Please try again.
</div>
) : (
<div className="flex items-center justify-center h-full">
<SpinnerIcon className="animate-spin" size="lg" />
Expand Down
42 changes: 42 additions & 0 deletions packages/keychain/src/utils/iframe-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { getSafeCoinbasePaymentUrl } from "./iframe-url";

describe("getSafeCoinbasePaymentUrl", () => {
it("accepts secure coinbase URLs", () => {
expect(
getSafeCoinbasePaymentUrl("https://pay.coinbase.com/buy/checkout"),
).toBe("https://pay.coinbase.com/buy/checkout");
});

it("accepts secure coinbase subdomains", () => {
expect(
getSafeCoinbasePaymentUrl(
"https://commerce.coinbase.com/checkout/abc-123",
),
).toBe("https://commerce.coinbase.com/checkout/abc-123");
});

it("rejects non-https URLs", () => {
expect(
getSafeCoinbasePaymentUrl("http://pay.coinbase.com/buy/checkout"),
).toBeUndefined();
});

it("rejects non-coinbase domains", () => {
expect(
getSafeCoinbasePaymentUrl("https://example.com/checkout"),
).toBeUndefined();
});

it("rejects malformed URLs", () => {
expect(getSafeCoinbasePaymentUrl("not-a-url")).toBeUndefined();
});

it("rejects credentialed URLs", () => {
expect(
getSafeCoinbasePaymentUrl(
"https://user:pass@pay.coinbase.com/buy/checkout",
),
).toBeUndefined();
});
});
35 changes: 35 additions & 0 deletions packages/keychain/src/utils/iframe-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const COINBASE_HOST = "coinbase.com";
const COINBASE_SUBDOMAIN_SUFFIX = ".coinbase.com";

export function getSafeCoinbasePaymentUrl(
paymentUrl?: string,
): string | undefined {
if (!paymentUrl) {
return undefined;
}

let parsedUrl: URL;
try {
parsedUrl = new URL(paymentUrl);
} catch {
return undefined;
}

if (parsedUrl.protocol !== "https:") {
return undefined;
}

if (parsedUrl.username || parsedUrl.password) {
return undefined;
}

const hostname = parsedUrl.hostname.toLowerCase();
const isCoinbaseHostname =
hostname === COINBASE_HOST || hostname.endsWith(COINBASE_SUBDOMAIN_SUFFIX);

if (!isCoinbaseHostname) {
return undefined;
}

return parsedUrl.toString();
}
Loading