Prevent xss in preview aand load resources from known URL (#18978)

Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>
This commit is contained in:
Hariom Balhara
2025-02-05 12:17:56 +05:30
committed by GitHub
co-authored by Hariom Balhara
parent 1232eefece
commit 288248d94f
8 changed files with 153 additions and 7 deletions
+1 -1
View File
@@ -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",
@@ -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<string>((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");
});
});
+61 -3
View File
@@ -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",
@@ -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()] : []),
],
+2
View File
@@ -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
+50
View File
@@ -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,<script>alert(1)</script>")).toBe(false);
});
it("should return false for invalid URLs", () => {
expect(isSafeUrlToLoadResourceFrom("not-a-url")).toBe(false);
expect(isSafeUrlToLoadResourceFrom("http://")).toBe(false);
});
});
+34 -1
View File
@@ -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(".");
}
}
+1
View File
@@ -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",