diff --git a/apps/web/app/WithEmbedSSR.tsx b/apps/web/app/WithEmbedSSR.tsx index a7d5387425..0b1ba099a1 100644 --- a/apps/web/app/WithEmbedSSR.tsx +++ b/apps/web/app/WithEmbedSSR.tsx @@ -14,7 +14,10 @@ export default function withEmbedSsrAppDir>( ) { return async (context: GetServerSidePropsContext): Promise => { const { embed, layout } = context.query; - + const isCOEPEnabled = context.query["flag.coep"] === "true"; + if (isCOEPEnabled) { + context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + } try { const props = await getData(context); diff --git a/apps/web/lib/withEmbedSsr.test.ts b/apps/web/lib/withEmbedSsr.test.ts index cdb4a4f233..825fbfe24f 100644 --- a/apps/web/lib/withEmbedSsr.test.ts +++ b/apps/web/lib/withEmbedSsr.test.ts @@ -133,6 +133,24 @@ describe("withEmbedSsr", () => { }, }); }); + + it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => { + const withEmbedGetSsr = withEmbedSsr( + getServerSidePropsFnGenerator({ + redirectUrl: "/reschedule", + }) + ); + + const context = getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + "flag.coep": "true", + }, + }); + await withEmbedGetSsr(context); + expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp"); + }); }); describe("when redirect destination is absolute, should add /embed to end of the path", () => { @@ -206,6 +224,23 @@ describe("withEmbedSsr", () => { }, }); }); + it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => { + const withEmbedGetSsr = withEmbedSsr( + getServerSidePropsFnGenerator({ + redirectUrl: "https://calcom.cal.local/owner", + }) + ); + + const context = getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + "flag.coep": "true", + }, + }); + await withEmbedGetSsr(context); + expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp"); + }); }); }); @@ -233,6 +268,85 @@ describe("withEmbedSsr", () => { }, }); }); + + it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => { + const withEmbedGetSsr = withEmbedSsr( + getServerSidePropsFnGenerator({ + props: { + prop1: "value1", + }, + }) + ); + + const context = getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + "flag.coep": "true", + }, + }); + await withEmbedGetSsr(context); + expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp"); + }); + + it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is false', async () => { + const withEmbedGetSsr = withEmbedSsr( + getServerSidePropsFnGenerator({ + props: { + prop1: "value1", + }, + }) + ); + + const context = getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + "flag.coep": "false", + }, + }); + await withEmbedGetSsr(context); + expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined(); + }); + + it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is not set', async () => { + const withEmbedGetSsr = withEmbedSsr( + getServerSidePropsFnGenerator({ + props: { + prop1: "value1", + }, + }) + ); + + const context = getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + }, + }); + await withEmbedGetSsr(context); + expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined(); + }); + + it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is set to something other than true', async () => { + const withEmbedGetSsr = withEmbedSsr( + getServerSidePropsFnGenerator({ + props: { + prop1: "value1", + }, + }) + ); + + const context = getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + "flag.coep": "abasdf", + }, + }); + await withEmbedGetSsr(context); + expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined(); + }); }); describe("when gSSP doesn't have props or redirect ", () => { @@ -254,5 +368,23 @@ describe("withEmbedSsr", () => { expect(ret).toEqual({ notFound: true }); }); + + it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => { + const withEmbedGetSsr = withEmbedSsr( + getServerSidePropsFnGenerator({ + notFound: true, + }) + ); + + const context = getServerSidePropsContextArg({ + embedRelatedParams: { + layout: "week_view", + embed: "", + "flag.coep": "true", + }, + }); + await withEmbedGetSsr(context); + expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp"); + }); }); }); diff --git a/apps/web/lib/withEmbedSsr.tsx b/apps/web/lib/withEmbedSsr.tsx index 19a98ee263..8d8371780b 100644 --- a/apps/web/lib/withEmbedSsr.tsx +++ b/apps/web/lib/withEmbedSsr.tsx @@ -11,6 +11,10 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) { const ssrResponse = await getServerSideProps(context); const embed = context.query.embed; const layout = context.query.layout; + const isCOEPEnabled = context.query["flag.coep"] === "true"; + if (isCOEPEnabled) { + context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + } if ("redirect" in ssrResponse) { const destinationUrl = ssrResponse.redirect.destination; @@ -44,6 +48,7 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) { if (!("props" in ssrResponse)) { return ssrResponse; } + return { ...ssrResponse, props: { diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 0b5a9395bf..059070135f 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -387,6 +387,13 @@ const nextConfig = { }; }, async headers() { + // This header can be set safely as it ensures the browser will load the resources even when COEP is set. + // But this header must be set only on those resources that are safe to be loaded in a cross-origin context e.g. all embeddable pages's resources + const CORP_CROSS_ORIGIN_HEADER = { + key: "Cross-Origin-Resource-Policy", + value: "cross-origin", + }; + return [ { source: "/auth/:path*", @@ -419,6 +426,26 @@ const nextConfig = { }, ], }, + { + source: "/embed/embed.js", + headers: [CORP_CROSS_ORIGIN_HEADER], + }, + { + source: "/:path*/embed", + // COEP require-corp header is set conditionally when flag.coep is set to true + headers: [CORP_CROSS_ORIGIN_HEADER], + }, + // These resources loads through embed as well, so they need to have CORP_CROSS_ORIGIN_HEADER + ...[ + { + source: "/api/avatar/:path*", + headers: [CORP_CROSS_ORIGIN_HEADER], + }, + { + source: "/avatar.svg", + headers: [CORP_CROSS_ORIGIN_HEADER], + }, + ], ...(isOrganizationsEnabled ? [ { diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index 8e3c8ed2c4..c11b4c3d7f 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -20,7 +20,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const { uid: bookingUid, seatReferenceUid } = z .object({ uid: z.string(), seatReferenceUid: z.string().optional() }) .parse(context.query); - + const coepFlag = context.query["flag.coep"]; const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat( prisma, bookingUid @@ -136,6 +136,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { destinationUrl.set("rescheduleUid", seatReferenceUid || bookingUid); + // TODO: I think we should just forward all the query params here including coep flag + if (coepFlag) { + destinationUrl.set("flag.coep", coepFlag as string); + } return { redirect: { destination: `/${eventPage}?${destinationUrl.toString()}${ diff --git a/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts b/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts index eb5a80b47a..ae4fc6c553 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts @@ -58,6 +58,9 @@ export const getServerSideProps = async function getServerSideProps( const { currentOrgDomain } = orgDomainConfig(context.req); const isEmbed = params.appPages[1] === "embed"; + if (context.query["flag.coep"] === "true") { + context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + } const form = await prisma.app_RoutingForms_Form.findFirst({ where: { diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 2f9f7fcb08..869eb5e5a4 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -36,6 +36,9 @@ Cal.ns.withoutReloadNamespace("inline", { elementOrSelector: selector, calLink: "pro?case=addInlineEmbedInANewNamespaceWithoutReload", + config: { + "flag.coep":"true" + } }); }; @@ -88,6 +91,9 @@ Cal.ns.doubleInstall("inline", { elementOrSelector: selector, calLink: "pro?case=doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace", + config: { + "flag.coep":"true" + } }); embedSnippet(); Cal("init", "doubleInstall", { @@ -97,6 +103,9 @@ Cal.ns.doubleInstall("inline", { elementOrSelector: selector, calLink: "pro", + config: { + "flag.coep":"true" + } }); } @@ -109,6 +118,9 @@ Cal("inline", { elementOrSelector: selector, calLink: "pro?case=doubleInstallSnippetWithInlineEmbed", + config: { + "flag.coep":"true" + } }); embedSnippet(); Cal("init", { @@ -118,6 +130,9 @@ Cal("inline", { elementOrSelector: selector, calLink: "pro", + config: { + "flag.coep":"true" + } }); } @@ -133,10 +148,16 @@ Cal.ns['namespace-init-test-2']("inline", { elementOrSelector: '#two-different-namespace-with-different-init-config-content-2', calLink: "pro", + config: { + "flag.coep":"true" + } }); Cal.ns['namespace-init-test-1']("inline", { elementOrSelector: '#two-different-namespace-with-different-init-config-content-1', calLink: "pro", + config: { + "flag.coep":"true" + } }); } @@ -198,7 +219,7 @@ - - - - - - + I am Anchor [I am Bold inside anchor] @@ -350,6 +371,13 @@
If you render booking embed in me, I would not let you be more than 30% wide
+ +
+

CORP/COEP Blocking Test

+
+
It would forever load
+
+
diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index 8c09f504bb..c4d1f6fdf3 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -58,6 +58,7 @@ if (only === "all" || only === "ns:default") { notes: "Test Meeting", guests: ["janedoe@example.com", "test@example.com"], theme: "dark", + "flag.coep": "true", }, }); Cal("on", { @@ -84,6 +85,7 @@ if (only === "all" || only === "ns:second") { id: "cal-booking-place-second-iframe", }, theme: "auto", + "flag.coep": "true", }, } ); @@ -111,6 +113,7 @@ if (only === "all" || only === "ns:third") { iframeAttrs: { id: "cal-booking-place-third-iframe", }, + "flag.coep": "true", }, }, ], @@ -161,6 +164,7 @@ if (only === "all" || only === "ns:fourth") { iframeAttrs: { id: "cal-booking-place-fourth-iframe", }, + "flag.coep": "true", }, }, ], @@ -188,6 +192,28 @@ if (only === "all" || only === "ns:fourth") { callback, }); } + +if (only === "all" || only === "ns:corpTest") { + Cal("init", "corpTest", { + debug: true, + origin: "http://localhost:3000", + }); + Cal.ns.corpTest([ + "inline", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + { + elementOrSelector: "#cal-booking-place-corpTest .place", + calLink: "pro", + config: { + iframeAttrs: { + id: "cal-booking-place-corpTest-iframe", + }, + "flag.coep": "false", + }, + }, + ]); +} if (only === "all" || only === "ns:fifth") { Cal("init", "fifth", { debug: true, @@ -204,6 +230,7 @@ if (only === "all" || only === "ns:fifth") { iframeAttrs: { id: "cal-booking-place-fifth-iframe", }, + "flag.coep": "true", }, }, ]); @@ -219,7 +246,7 @@ if (only === "all" || only === "prerender-test") { origin: origin, }); Cal.ns.e2ePrerenderLightTheme("prerender", { - calLink: "free/30min", + calLink: "free/30min?flag.coep=true", type: "modal", }); } @@ -250,6 +277,7 @@ if (only === "all" || only === "inline-routing-form") { iframeAttrs: { id: "cal-booking-place-inline-routing-form-iframe", }, + "flag.coep": "true", }, }, ]); @@ -274,6 +302,7 @@ if (only === "all" || only === "hideEventTypeDetails") { iframeAttrs: { id: `cal-booking-place-${identifier}-iframe`, }, + "flag.coep": "true", }, }, ], @@ -404,6 +433,7 @@ if (only === "all" || only == "ns:floatingButton") { iframeAttrs: { id: "floatingtest", }, + "flag.coep": "true", name: "John", email: "johndoe@gmail.com", notes: "Test Meeting", @@ -431,6 +461,7 @@ if (only === "all" || only == "ns:monthView") { iframeAttrs: { id: "cal-booking-place-monthView-iframe", }, + "flag.coep": "true", layout: "month_view", }, } @@ -459,6 +490,7 @@ if (only === "all" || only == "ns:weekView") { iframeAttrs: { id: "cal-booking-place-weekView-iframe", }, + "flag.coep": "true", layout: "week_view", }, } @@ -487,6 +519,7 @@ if (only === "all" || only == "ns:columnView") { iframeAttrs: { id: "cal-booking-place-columnView-iframe", }, + "flag.coep": "true", layout: "column_view", }, } diff --git a/packages/embeds/embed-core/playwright/lib/testUtils.ts b/packages/embeds/embed-core/playwright/lib/testUtils.ts index 23a5fc996c..85ffb22b79 100644 --- a/packages/embeds/embed-core/playwright/lib/testUtils.ts +++ b/packages/embeds/embed-core/playwright/lib/testUtils.ts @@ -165,3 +165,13 @@ export async function installAppleCalendar(page: Page) { await page.waitForURL("/apps/apple-calendar"); await page.click('[data-testid="install-app-button"]'); } + +export async function assertNoRequestIsBlocked(page: Page) { + page.on("requestfailed", (request) => { + const error = request.failure()?.errorText; + // Identifies that the request is blocked by the browser due to COEP restrictions + if (error?.includes("ERR_BLOCKED_BY_RESPONSE")) { + throw new Error(`Request Blocked: ${request.url()}. Error: ${error}`); + } + }); +} diff --git a/packages/embeds/embed-core/playwright/tests/inline.e2e.ts b/packages/embeds/embed-core/playwright/tests/inline.e2e.ts index 7c32ceefaf..c48373234c 100644 --- a/packages/embeds/embed-core/playwright/tests/inline.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/inline.e2e.ts @@ -2,10 +2,16 @@ import { expect } from "@playwright/test"; import { test } from "@calcom/web/playwright/lib/fixtures"; -import { bookFirstEvent, deleteAllBookingsByEmail, getEmbedIframe, todo } from "../lib/testUtils"; +import { + assertNoRequestIsBlocked, + bookFirstEvent, + deleteAllBookingsByEmail, + getEmbedIframe, + todo, +} from "../lib/testUtils"; test.describe("Inline Iframe", () => { - test("Inline Iframe - Configured with Dark Theme", async ({ + test("Inline Iframe - Configured with Dark Theme. Do booking and verify that COEP/CORP headers are correctly set", async ({ page, embeds: { addEmbedListeners, getActionFiredDetails }, }) => { @@ -24,10 +30,33 @@ test.describe("Inline Iframe", () => { if (!embedIframe) { throw new Error("Embed iframe not found"); } + + assertNoRequestIsBlocked(page); + await bookFirstEvent("pro", embedIframe, page); await deleteAllBookingsByEmail("embed-user@example.com"); }); + test("COEP flag if not enabled, embed is blocked", async ({ page, embeds }) => { + const embedBlockedPromise = new Promise((resolve) => { + page.on("requestfailed", (request) => { + const error = request.failure()?.errorText; + // Identifies that the request is blocked by the browser due to COEP restrictions + if (error?.includes("ERR_BLOCKED_BY_RESPONSE")) { + console.log("Request failed: ", request.url(), error); + resolve(request.url().includes("/pro/embed")); + } + }); + }); + + const calNamespace = "corpTest"; + await embeds.gotoPlayground({ calNamespace, url: `?only=ns:${calNamespace}` }); + + await embedBlockedPromise.then((isBlocked) => { + expect(isBlocked).toBe(true); + }); + }); + todo( "Ensure that on all pages - [user], [user]/[type], team/[slug], team/[slug]/book, UI styling works if these pages are directly linked in embed" ); diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index 709d855200..bb0b748222 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -23,6 +23,7 @@ export type PrefillAndIframeAttrsConfig = Record { }), ...(process.argv.includes("--https") ? [basicSsl()] : []), ], + server: { + // Helps us to test that embed works with these headers + headers: { + "Cross-Origin-Embedder-Policy": "require-corp", + }, + }, build: { emptyOutDir: true, rollupOptions: {