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

describe("iframe security", () => {
describe("isLocalhostHostname", () => {
it("returns true for localhost hosts", () => {
expect(isLocalhostHostname("localhost")).toBe(true);
expect(isLocalhostHostname("app.localhost")).toBe(true);
expect(isLocalhostHostname("127.0.0.1")).toBe(true);
expect(isLocalhostHostname("[::1]")).toBe(true);
expect(isLocalhostHostname("::1")).toBe(true);
});

it("returns false for non-local hosts", () => {
expect(isLocalhostHostname("example.com")).toBe(false);
expect(isLocalhostHostname("localhost.example.com")).toBe(false);
});
});

describe("validateKeychainIframeUrl", () => {
it("allows https URLs", () => {
expect(() =>
validateKeychainIframeUrl(new URL("https://x.cartridge.gg")),
).not.toThrow();
});

it("allows localhost http URLs for development", () => {
expect(() =>
validateKeychainIframeUrl(new URL("http://localhost:3001")),
).not.toThrow();
expect(() =>
validateKeychainIframeUrl(new URL("http://127.0.0.1:3001")),
).not.toThrow();
expect(() =>
validateKeychainIframeUrl(new URL("http://[::1]:3001")),
).not.toThrow();
});

it("rejects insecure remote http URLs", () => {
expect(() =>
validateKeychainIframeUrl(new URL("http://evil.example")),
).toThrow(
"Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
);
});

it("rejects non-http(s) protocols", () => {
expect(() =>
validateKeychainIframeUrl(new URL("javascript:alert(1)")),
).toThrow(
"Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
);

expect(() =>
validateKeychainIframeUrl(new URL("data:text/html,<h1>xss</h1>")),
).toThrow(
"Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
);
});

it("rejects credentialed URLs", () => {
expect(() =>
validateKeychainIframeUrl(new URL("https://user:[email protected]")),
).toThrow("Invalid keychain iframe URL: credentials are not allowed");
});
});

describe("buildIframeAllowList", () => {
it("does not include local-network-access for remote URLs", () => {
const allowList = buildIframeAllowList(new URL("https://x.cartridge.gg"));
expect(allowList).toContain("publickey-credentials-create *");
expect(allowList).toContain("payment *");
expect(allowList).not.toContain("local-network-access *");
});

it("includes local-network-access for localhost development URLs", () => {
const allowList = buildIframeAllowList(new URL("http://localhost:3001"));
expect(allowList).toContain("local-network-access *");
});
});
});
16 changes: 13 additions & 3 deletions packages/controller/src/iframe/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AsyncMethodReturns, connectToChild } from "@cartridge/penpal";
import { Modal } from "../types";
import { buildIframeAllowList, validateKeychainIframeUrl } from "./security";

export type IFrameOptions<CallSender> = Omit<
ConstructorParameters<typeof IFrame>[0],
Expand Down Expand Up @@ -34,6 +35,7 @@ export class IFrame<CallSender extends {}> implements Modal {
}

this.url = url;
validateKeychainIframeUrl(url);

const docHead = document.head;

Expand All @@ -53,8 +55,8 @@ export class IFrame<CallSender extends {}> implements Modal {
iframe.sandbox.add("allow-popups-to-escape-sandbox");
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 *";
iframe.allow = buildIframeAllowList(url);
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,12 +124,20 @@ export class IFrame<CallSender extends {}> implements Modal {

connectToChild<CallSender>({
iframe: this.iframe,
childOrigin: url.origin,
methods: {
close: (_origin: string) => () => this.close(),
reload: (_origin: string) => () => window.location.reload(),
...methods,
},
}).promise.then(onConnect);
})
.promise.then(onConnect)
.catch((error) => {
console.error("Failed to establish secure keychain iframe connection", {
error,
childOrigin: url.origin,
});
});

this.resize();
window.addEventListener("resize", () => this.resize());
Expand Down
48 changes: 48 additions & 0 deletions packages/controller/src/iframe/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);

export function isLocalhostHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase();
return (
LOCALHOST_HOSTNAMES.has(normalized) || normalized.endsWith(".localhost")
);
}

/**
* Restrict iframe targets to HTTPS in production, while still allowing local HTTP dev.
*/
export function validateKeychainIframeUrl(url: URL): void {
if (url.username || url.password) {
throw new Error("Invalid keychain iframe URL: credentials are not allowed");
}

if (url.protocol === "https:") {
return;
}

if (url.protocol === "http:" && isLocalhostHostname(url.hostname)) {
return;
}

throw new Error(
"Invalid keychain iframe URL: only https:// or local http:// URLs are allowed",
);
}

/**
* Build a conservative allow list for iframe feature policy.
* Local network access is only needed for localhost development.
*/
export function buildIframeAllowList(url: URL): string {
const allowFeatures = [
"publickey-credentials-create *",
"publickey-credentials-get *",
"clipboard-write",
"payment *",
];

if (isLocalhostHostname(url.hostname)) {
allowFeatures.push("local-network-access *");
}

return allowFeatures.join("; ");
}
43 changes: 6 additions & 37 deletions packages/keychain/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<head>
<title>Cartridge Controller</title>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data: https:; connect-src 'self' https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; frame-src 'self' https:; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self' https:"
/>

<link rel="icon" type="image/png" href="/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
Expand Down Expand Up @@ -36,43 +40,8 @@
content="{CartridgeTheme.semanticTokens.colors.brand}"
/>
<script src="/noflash.js"></script>
<script>
if ("virtualKeyboard" in navigator) {
navigator.virtualKeyboard.overlaysContent = true;
}
</script>

<script>
window.addEventListener(
"touchstart",
(e) => {
if (e.touches.length > 1) {
e.preventDefault();
}
},
{ passive: false },
);

window.addEventListener(
"touchmove",
(e) => {
if (e.touches.length > 1) {
e.preventDefault();
}
},
{ passive: false },
);

window.addEventListener(
"touchend",
(e) => {
if (e.touches.length > 1) {
e.preventDefault();
}
},
{ passive: false },
);
</script>
<script src="/init-virtual-keyboard.js"></script>
<script src="/disable-multitouch-zoom.js"></script>
</head>
<body class="h-dvh bg-background text-foreground overflow-y-auto">
<div id="root"></div>
Expand Down
15 changes: 15 additions & 0 deletions packages/keychain/public/disable-multitouch-zoom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const disableMultiTouchZoom = (event) => {
if (event.touches.length > 1) {
event.preventDefault();
}
};

window.addEventListener("touchstart", disableMultiTouchZoom, {
passive: false,
});
window.addEventListener("touchmove", disableMultiTouchZoom, {
passive: false,
});
window.addEventListener("touchend", disableMultiTouchZoom, {
passive: false,
});
3 changes: 3 additions & 0 deletions packages/keychain/public/init-virtual-keyboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
if ("virtualKeyboard" in navigator) {
navigator.virtualKeyboard.overlaysContent = true;
}
21 changes: 21 additions & 0 deletions packages/keychain/vercel.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data: https:; connect-src 'self' https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; frame-src 'self' https:; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self' https:; frame-ancestors 'self' https: http://localhost:* http://127.0.0.1:* capacitor:;"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "Referrer-Policy",
"value": "no-referrer"
},
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=()"
}
]
},
{
"source": "/ingest/static/:path(.*)",
"headers": [
Expand Down
Loading