diff --git a/package.json b/package.json index c299b75dbf..941bf2c590 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "tdd": "vitest watch", "e2e": "NEXT_PUBLIC_IS_E2E=1 yarn playwright test --project=@calcom/web", "e2e:app-store": "QUICK=true yarn playwright test --project=@calcom/app-store", - "e2e:embed": "QUICK=true yarn playwright test --project=@calcom/embed-core", + "e2e:embed": "NEXT_PUBLIC_IS_E2E=1 yarn playwright test --project=@calcom/embed-core", "e2e:embed-react": "QUICK=true yarn playwright test --project=@calcom/embed-react", "test-e2e": "yarn db-seed && yarn e2e", "test-e2e:app-store": "yarn db-seed && yarn e2e:app-store", diff --git a/packages/embeds/embed-core/playwright/tests/preview.e2e.ts b/packages/embeds/embed-core/playwright/tests/preview.e2e.ts index 28506adb07..585e5f26ae 100644 --- a/packages/embeds/embed-core/playwright/tests/preview.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/preview.e2e.ts @@ -30,7 +30,7 @@ test.describe("Preview", () => { test("Preview - embed-core should load from embedLibUrl", async ({ page }) => { // Intentionally pass a URL that will not load to be able to easily test that the embed was loaded from there page.goto( - "http://localhost:3000/embed/preview.html?embedLibUrl=http://wronglocalhost:3000/embed/embed.js&bookerUrl=http://localhost:3000&calLink=pro/30min" + "http://localhost:3000/embed/preview.html?embedLibUrl=http://localhost:3000/embed/embed-not-found.js&bookerUrl=http://localhost:3000&calLink=pro/30min" ); const failedRequestUrl = await new Promise((resolve) => @@ -40,6 +40,6 @@ test.describe("Preview", () => { }) ); - expect(failedRequestUrl).toBe("http://wronglocalhost:3000/embed/embed.js"); + expect(failedRequestUrl).toBe("http://localhost:3000/embed/embed-not-found.js"); }); }); diff --git a/packages/embeds/embed-core/src/preview.ts b/packages/embeds/embed-core/src/preview.ts index 3a1b0861a9..457210cec3 100644 --- a/packages/embeds/embed-core/src/preview.ts +++ b/packages/embeds/embed-core/src/preview.ts @@ -1,11 +1,72 @@ +// We can't import @calcom/lib/constants here yet as this file is compiled using Vite +const WEBAPP_URL = process.env.EMBED_PUBLIC_WEBAPP_URL ?? ""; +const EMBED_LIB_URL = process.env.EMBED_PUBLIC_EMBED_LIB_URL ?? WEBAPP_URL; +const IS_E2E = process.env.NEXT_PUBLIC_IS_E2E === "1"; +if (!WEBAPP_URL) { + throw new Error("WEBAPP_URL is not set"); +} + +// Because it is only used in Embed Snippet Generator preview that is accessible through dashboard only which has URL WEBAPP_URL, we are good with this strict restriction +if (!IS_E2E && (window.self === window.top || !document.referrer.startsWith(WEBAPP_URL))) { + throw new Error(`This page can only be accessed within an iframe from ${WEBAPP_URL}`); +} + +// It is a copy of isSafeUrlToLoadResourceFrom in packages/lib/getSafeRedirectUrl. Keep it in sync +// We can't import that here has it is loaded in a separate Vanilla JS page +function isSafeUrlToLoadResourceFrom(urlString: string) { + try { + const url = new URL(urlString); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + + // Allow localhost for development + if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { + return true; + } + + const webappUrl = new URL(WEBAPP_URL); + const embedLibUrl = new URL(EMBED_LIB_URL); + + const urlTldPlus1 = getTldPlus1(url.hostname); + const webappTldPlus1 = getTldPlus1(webappUrl.hostname); + const embedLibTldPlus1 = getTldPlus1(embedLibUrl.hostname); + + // URLs must share the same TLD+1 + return [webappTldPlus1, embedLibTldPlus1].includes(urlTldPlus1); + } catch { + return false; + } + + function getTldPlus1(hostname: string) { + // Note: It doesn't support multipart tlds like .co.uk and thus makes only one part tld's safe like .com(and thus cal.com) + // If we want to use it elsewhere as well(apart from embed/preview.ts) we must consider Public Suffix List + return hostname.split(".").slice(-2).join("."); + } +} + const searchParams = new URL(document.URL).searchParams; const embedType = searchParams.get("embedType"); const calLink = searchParams.get("calLink"); const bookerUrl = searchParams.get("bookerUrl"); const embedLibUrl = searchParams.get("embedLibUrl"); + if (!bookerUrl || !embedLibUrl) { throw new Error('Can\'t Preview: Missing "bookerUrl" or "embedLibUrl" query parameter'); } + +if (!isSafeUrlToLoadResourceFrom(embedLibUrl)) { + throw new Error('Invalid "embedLibUrl".'); +} + +if (!isSafeUrlToLoadResourceFrom(bookerUrl)) { + throw new Error('Invalid "bookerUrl".'); +} + +if (!calLink) { + throw new Error('Missing "calLink" query parameter'); +} + // TODO: Reuse the embed code snippet from the embed-snippet package - Not able to use it because of circular dependency // Install Cal Embed Code Snippet (function (C, A, L) { @@ -56,9 +117,6 @@ previewWindow.Cal("init", { origin: bookerUrl, }); -if (!calLink) { - throw new Error('Missing "calLink" query parameter'); -} if (embedType === "inline") { previewWindow.Cal("inline", { elementOrSelector: "#my-embed", diff --git a/packages/embeds/embed-core/vite.config.js b/packages/embeds/embed-core/vite.config.js index 8b5597c13a..e200aea28e 100644 --- a/packages/embeds/embed-core/vite.config.js +++ b/packages/embeds/embed-core/vite.config.js @@ -16,6 +16,8 @@ module.exports = defineConfig((configEnv) => { EMBED_PUBLIC_EMBED_VERSION: embedCoreEnvVars.EMBED_PUBLIC_EMBED_VERSION, EMBED_PUBLIC_VERCEL_URL: embedCoreEnvVars.EMBED_PUBLIC_VERCEL_URL, EMBED_PUBLIC_WEBAPP_URL: embedCoreEnvVars.EMBED_PUBLIC_WEBAPP_URL, + EMBED_PUBLIC_EMBED_LIB_URL: embedCoreEnvVars.EMBED_PUBLIC_EMBED_LIB_URL, + NEXT_PUBLIC_IS_E2E: embedCoreEnvVars.NEXT_PUBLIC_IS_E2E, }), ...(process.argv.includes("--https") ? [basicSsl()] : []), ], diff --git a/packages/embeds/vite.config.js b/packages/embeds/vite.config.js index c074f0efae..c51870678a 100644 --- a/packages/embeds/vite.config.js +++ b/packages/embeds/vite.config.js @@ -34,6 +34,8 @@ export const embedCoreEnvVars = { EMBED_PUBLIC_EMBED_FINGER_PRINT: process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT ?? "", EMBED_PUBLIC_EMBED_VERSION: process.env.EMBED_PUBLIC_EMBED_VERSION ?? "", EMBED_PUBLIC_VERCEL_URL: process.env.EMBED_PUBLIC_VERCEL_URL ?? "http://localhost:3000", + EMBED_PUBLIC_EMBED_LIB_URL: process.env.EMBED_PUBLIC_EMBED_LIB_URL ?? "", + NEXT_PUBLIC_IS_E2E: process.env.NEXT_PUBLIC_IS_E2E ?? "", EMBED_PUBLIC_WEBAPP_URL: process.env.EMBED_PUBLIC_WEBAPP_URL ?? (process.env.EMBED_PUBLIC_VERCEL_URL diff --git a/packages/lib/getSafeRedirectUrl.test.ts b/packages/lib/getSafeRedirectUrl.test.ts new file mode 100644 index 0000000000..027fde65f6 --- /dev/null +++ b/packages/lib/getSafeRedirectUrl.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { isSafeUrlToLoadResourceFrom } from "./getSafeRedirectUrl"; + +vi.mock("./constants", () => ({ + WEBAPP_URL: "https://app.cal.com", + EMBED_LIB_URL: "https://embed.com", +})); + +describe("isSafeUrlToLoadResourceFrom", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + it("should return true for URLs with same TLD+1 as WEBAPP_URL", () => { + expect(isSafeUrlToLoadResourceFrom("https://acme.cal.com/path")).toBe(true); + expect(isSafeUrlToLoadResourceFrom("http://dunder.cal.com?query=1")).toBe(true); + expect(isSafeUrlToLoadResourceFrom("https://cal.com/path")).toBe(true); + }); + + it("should return true for URLs with same TLD+1 as EMBED_LIB_URL", () => { + expect(isSafeUrlToLoadResourceFrom("https://test.embed.com/path")).toBe(true); + expect(isSafeUrlToLoadResourceFrom("http://embed.com/script.js")).toBe(true); + }); + + it("should return true for localhost URLs", () => { + expect(isSafeUrlToLoadResourceFrom("http://localhost:3000")).toBe(true); + expect(isSafeUrlToLoadResourceFrom("http://127.0.0.1:3000")).toBe(true); + }); + + it("should return false for different TLD+1", () => { + expect(isSafeUrlToLoadResourceFrom("https://malicious1.com")).toBe(false); + expect(isSafeUrlToLoadResourceFrom("https://malicious2.com")).toBe(false); + }); + + it("should return false for non-http/https protocols", () => { + expect(isSafeUrlToLoadResourceFrom("ftp://example.com")).toBe(false); + expect(isSafeUrlToLoadResourceFrom("javascript:alert(1)")).toBe(false); + expect(isSafeUrlToLoadResourceFrom("data:text/html,")).toBe(false); + }); + + it("should return false for invalid URLs", () => { + expect(isSafeUrlToLoadResourceFrom("not-a-url")).toBe(false); + expect(isSafeUrlToLoadResourceFrom("http://")).toBe(false); + }); +}); diff --git a/packages/lib/getSafeRedirectUrl.ts b/packages/lib/getSafeRedirectUrl.ts index 2c9688eb84..13740577be 100644 --- a/packages/lib/getSafeRedirectUrl.ts +++ b/packages/lib/getSafeRedirectUrl.ts @@ -1,4 +1,4 @@ -import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL, EMBED_LIB_URL } from "@calcom/lib/constants"; // It ensures that redirection URL safe where it is accepted through a query params or other means where user can change it. export const getSafeRedirectUrl = (url = "") => { @@ -20,3 +20,36 @@ export const getSafeRedirectUrl = (url = "") => { return url; }; + +// There is a copy of this fn at packages/embed/embed-core/src/preview.ts as that can't import this function. Keep it in sync +export function isSafeUrlToLoadResourceFrom(urlString: string) { + try { + const url = new URL(urlString); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + + // Allow localhost for development + if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { + return true; + } + + const webappUrl = new URL(WEBAPP_URL); + const embedLibUrl = new URL(EMBED_LIB_URL); + + const urlTldPlus1 = getTldPlus1(url.hostname); + const webappTldPlus1 = getTldPlus1(webappUrl.hostname); + const embedLibTldPlus1 = getTldPlus1(embedLibUrl.hostname); + + // URLs must share the same TLD+1 so that org domains are also allowed. + return [webappTldPlus1, embedLibTldPlus1].includes(urlTldPlus1); + } catch { + return false; + } + + function getTldPlus1(hostname: string) { + // Note: It doesn't support multipart tlds like .co.uk and thus makes only one part tld's safe like .com(and thus cal.com) + // If we want to use it elsewhere as well(apart from embed/preview.ts) we must consider Public Suffix List + return hostname.split(".").slice(-2).join("."); + } +} diff --git a/turbo.json b/turbo.json index 1970f44a18..1e175b4ec1 100644 --- a/turbo.json +++ b/turbo.json @@ -438,6 +438,7 @@ "EMBED_PUBLIC_EMBED_VERSION", "EMBED_PUBLIC_WEBAPP_URL", "EMBED_PUBLIC_VERCEL_URL", + "EMBED_PUBLIC_EMBED_LIB_URL", "NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER", "NEXT_PUBLIC_HEAD_SCRIPTS", "NEXT_PUBLIC_BODY_SCRIPTS",