From 3ff4ce25a83a0aa8c2cea1587c271dce5b7b861e Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 22 Oct 2026 15:37:51 +0200 Subject: [PATCH 1/2] fix(security): let the Sanity Dashboard frame the site in Presentation Every `*.sanity.studio` URL now redirects into the Sanity Dashboard (`www.sanity.io/@org/studio/`), which frames the Studio, which frames this site. `frame-ancestors` is checked against every ancestor, so the policy that only listed `*.sanity.studio` blocked the Presentation iframe ("Framing ... violates ... frame-ancestors"). A Studio on its own domain opened from the Dashboard sits in the same chain. The policy has been in place since April, but only reaches the HTML since dd59cb39 moved it into next.config, which is why Presentation broke without a preview config change. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 26c49b1ce8adf91df599eba0672be24d799e4f66) --- README.md | 2 +- web/.env.example | 3 ++- web/next.config.ts | 15 ++++++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 00840c3d..d2c09aff 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Production needs the webhook. [Revalidate webhook](#revalidate-webhook) sets it - `SANITY_API_READ_TOKEN` on the site: a Viewer token, so the site can read drafts. - `SANITY_STUDIO_PREVIEW_ORIGIN` on the Studio: the site's origin. Default `http://localhost:3000`. `SANITY_STUDIO_WEB_PREVIEW_ORIGINS` adds more, such as deploy previews. -- `NEXT_PUBLIC_SANITY_STUDIO_URL` on the site: the Studio's URL, so the site lets the Studio frame it and click-to-edit opens the right Studio. Default `http://localhost:3333`; `*.sanity.studio` is always allowed. +- `NEXT_PUBLIC_SANITY_STUDIO_URL` on the site: the Studio's URL, so the site lets the Studio frame it and click-to-edit opens the right Studio. Default `http://localhost:3333`; `*.sanity.studio` and the Sanity Dashboard (`*.sanity.io`) are always allowed. Site and Studio must read the same dataset, or the preview shows other content. diff --git a/web/.env.example b/web/.env.example index d70eb1a2..53522c4b 100644 --- a/web/.env.example +++ b/web/.env.example @@ -28,7 +28,8 @@ SANITY_API_READ_TOKEN= SANITY_REVALIDATE_SECRET= # Studio URL (BUILD): click-to-edit links and CSP `frame-ancestors`. Default -# `http://localhost:3333`; `*.sanity.studio` is always allowed. In production, the +# `http://localhost:3333`; `*.sanity.studio` and the Sanity Dashboard (`*.sanity.io`) +# are always allowed. In production, the # Studio's `SANITY_STUDIO_HOSTING_URL`. # NEXT_PUBLIC_SANITY_STUDIO_URL=https://studio.your-domain.tld diff --git a/web/next.config.ts b/web/next.config.ts index ee0bef1f..7dfc36b9 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -14,10 +14,10 @@ function originOf(raw: string | undefined): string | null { /** * Origin of the hosted Studio, for `frame-ancestors` — Sanity Presentation - * embeds this site in an iframe on the Studio domain. `*.sanity.studio` and - * the local Studio are always allowed; a custom Studio domain is picked up - * from `NEXT_PUBLIC_SANITY_STUDIO_URL` (the same variable the Sanity client - * uses for stega links). + * embeds this site in an iframe on the Studio domain. `*.sanity.studio`, the + * Sanity Dashboard and the local Studio are always allowed; a custom Studio + * domain is picked up from `NEXT_PUBLIC_SANITY_STUDIO_URL` (the same variable + * the Sanity client uses for stega links). */ const studioOrigin = originOf(process.env.NEXT_PUBLIC_SANITY_STUDIO_URL); @@ -101,7 +101,11 @@ const analyticsSources = { * manifests/segments/thumbnails, analytics, and the dev server's HMR * websocket. * - `frame-ancestors` must stay a header (a `` CSP cannot carry it): - * it is what lets Sanity Presentation embed the site. + * it is what lets Sanity Presentation embed the site. The browser checks + * every ancestor, not only the parent: a `*.sanity.studio` URL redirects + * into the Sanity Dashboard (`www.sanity.io`), which frames the Studio, + * which frames this site. A Studio opened from the Dashboard on its own + * domain sits in the same chain, so `*.sanity.io` is listed as well. */ function contentSecurityPolicy(): string { const directives: Record = { @@ -151,6 +155,7 @@ function contentSecurityPolicy(): string { "'self'", "http://localhost:3333", "https://*.sanity.studio", + "https://*.sanity.io", ...(studioOrigin ? [studioOrigin] : []), ], "worker-src": ["'self'", "blob:"], From 3172ec29c7b1859291a2b5caaab9e882cf00cfb8 Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 22 Oct 2026 15:37:51 +0200 Subject: [PATCH 2/2] fix(preview): keep draft mode inside frames while Presentation connects `useIsPresentationTool()` turns `false` inside a frame when the comlink handshake has not connected after one second, not only outside Presentation. `DisableDraftMode` took that as "outside" and left Draft Mode, which cut Presentation off whenever the Studio was slow to connect (it now sits in the Dashboard's frame), and in the cross-site iframe the disable route cannot clear the partitioned cookie anyway. Leave Draft Mode automatically only on a top-level page that no other window opened. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 52822978f712a5d1306f76064d69b0abe8325c6f) --- web/src/components/sanity/DisableDraftMode.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/web/src/components/sanity/DisableDraftMode.tsx b/web/src/components/sanity/DisableDraftMode.tsx index 4e7f7d44..094323d0 100644 --- a/web/src/components/sanity/DisableDraftMode.tsx +++ b/web/src/components/sanity/DisableDraftMode.tsx @@ -4,16 +4,22 @@ import { useIsPresentationTool } from "next-sanity/hooks"; import { useEffect } from "react"; /** - * Drafts are for the Presentation preview only. Once the page is confirmed to run outside the - * Presentation tool (`false`, not the `null` "still detecting" state), Draft Mode is switched - * off through a full navigation, so the page re-renders on the server with published content. - * Inside Presentation (iframe or "open preview" window) nothing happens. + * Drafts are for the Presentation preview only. A top-level page that Presentation does not + * drive (`false`, not the `null` "still detecting" state) leaves Draft Mode through a full + * navigation, so it re-renders on the server with published content. + * + * Inside a frame or a window another page opened, `false` only means the handshake has not + * finished: `` reports it after one second without a connection, and the Studio + * can take longer (it may itself sit in the Sanity Dashboard's frame). Leaving Draft Mode there + * would cut Presentation off, and in its cross-site iframe the disable route cannot even clear + * the partitioned cookie — so nothing happens. */ export function DisableDraftMode() { const isPresentationTool = useIsPresentationTool(); useEffect(() => { if (isPresentationTool !== false) return; + if (window.self !== window.top || window.opener) return; const { pathname, search, hash } = window.location; const target = `${pathname}${search}${hash}`; window.location.href = `/api/draft-mode/disable?redirect=${encodeURIComponent(target)}`;