From 668e8c752760784ece7a26753b6840302fa4cd13 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Wed, 29 Jul 2026 15:58:08 +0200 Subject: [PATCH 1/4] feat: change frame ancestor csp for /browse pages --- app/middleware.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/app/middleware.ts b/app/middleware.ts index f081184f9..6ae9db30c 100644 --- a/app/middleware.ts +++ b/app/middleware.ts @@ -1,10 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; -const EMBEDDABLE_PATH_PATTERNS = [ - /^\/embed\//, - /^\/preview$/, - /^\/api\/embed-aem-ext\//, -]; +const ALLOWED_BROWSE_FRAME_ANCESTORS = + "'self' https://*.opendata.swiss https://opendata.swiss https://*.admin.ch https://admin.ch"; + +const EMBEDDABLE_PATH_PATTERNS: { pattern: RegExp; frameAncestors: string }[] = + [ + { pattern: /^\/embed\//, frameAncestors: "*" }, + { pattern: /^\/preview$/, frameAncestors: "*" }, + { pattern: /^\/api\/embed-aem-ext\//, frameAncestors: "*" }, + { + pattern: /^(\/(de|fr|it|en))?\/browse(\/|$)/, + frameAncestors: ALLOWED_BROWSE_FRAME_ANCESTORS, + }, + ]; function buildCSP(frameAncestors: string): string { const isDev = process.env.NODE_ENV === "development"; @@ -44,8 +52,10 @@ export function middleware(request: NextRequest) { // This middleware is adding some dynamic headers that depends on environment variables and request path. const { pathname } = request.nextUrl; - const isEmbeddable = EMBEDDABLE_PATH_PATTERNS.some((p) => p.test(pathname)); - const frameAncestors = isEmbeddable ? "*" : "'self'"; + const matched = EMBEDDABLE_PATH_PATTERNS.find((p) => + p.pattern.test(pathname) + ); + const frameAncestors = matched?.frameAncestors ?? "'self'"; const reportOnly = process.env.CSP_REPORT_ONLY === "true"; const cspKey = reportOnly From e8eee8deb347c12db5c57b974686df0ee57bc2f0 Mon Sep 17 00:00:00 2001 From: Max Burri Date: Wed, 29 Jul 2026 16:45:22 +0200 Subject: [PATCH 2/4] feat: detect if the /browse page is embedded in an iframe --- app/browse/lib/params.ts | 14 ++++++++++++++ app/browse/ui/select-dataset-step.tsx | 4 ++-- app/hooks/useIsEmbedded.ts | 17 +++++++++++++++++ app/pages/browse/index.tsx | 4 +++- 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 app/hooks/useIsEmbedded.ts diff --git a/app/browse/lib/params.ts b/app/browse/lib/params.ts index 8744fa63e..6a01fe5f2 100644 --- a/app/browse/lib/params.ts +++ b/app/browse/lib/params.ts @@ -9,6 +9,7 @@ import { ComponentProps } from "react"; import { truthy } from "@/domain/types"; import { SearchCubeResultOrder } from "@/graphql/query-hooks"; +import { useIsEmbedded } from "@/hooks/useIsEmbedded"; const params = [ "type", @@ -110,3 +111,16 @@ export const extractParamFromPath = (path: string, param: string) => { export const isOdsIframe = (query: ParsedUrlQuery) => { return query["odsiframe"] === "true"; }; + +/** + * Combines the explicit `odsiframe` query param with generic iframe-embed + * detection. The embed detection is only applied when `pathname` is within + * `/browse`, since `useOdsIframe` is also used by components (chart + * configurator, add-dataset drawer) that are not part of the ODS embed flow + * and must not be affected by generic iframe detection. + */ +export const useOdsIframe = (query: ParsedUrlQuery, pathname: string) => { + const isEmbedded = useIsEmbedded(); + const isBrowseRoute = /^(\/(de|fr|it|en))?\/browse(\/|$)/.test(pathname); + return isOdsIframe(query) || (isEmbedded && isBrowseRoute); +}; diff --git a/app/browse/ui/select-dataset-step.tsx b/app/browse/ui/select-dataset-step.tsx index 9cc1ea048..39eceafe0 100644 --- a/app/browse/ui/select-dataset-step.tsx +++ b/app/browse/ui/select-dataset-step.tsx @@ -11,7 +11,7 @@ import { ComponentProps, type MouseEvent, useCallback, useMemo } from "react"; import { useDebounce } from "use-debounce"; import { BrowseFilter, DataCubeAbout } from "@/browse/lib/filters"; -import { buildURLFromBrowseParams, isOdsIframe } from "@/browse/lib/params"; +import { buildURLFromBrowseParams, useOdsIframe } from "@/browse/lib/params"; import { useRedirectToLatestCube } from "@/browse/lib/use-redirect-to-latest-cube"; import { BrowseStateProvider, useBrowseContext } from "@/browse/model/context"; import { DatasetMetadataSingleCube } from "@/browse/ui/dataset-metadata-single-cube"; @@ -106,7 +106,7 @@ const SelectDatasetStepInner = ({ } = browseState; const dataset = propsDataset ?? browseDataset; const router = useRouter(); - const odsIframe = isOdsIframe(router.query); + const odsIframe = useOdsIframe(router.query, router.pathname); const classes = useStyles({ datasetPresent: !!dataset, odsIframe }); const [debouncedQuery] = useDebounce(search, 500, { leading: true }); diff --git a/app/hooks/useIsEmbedded.ts b/app/hooks/useIsEmbedded.ts new file mode 100644 index 000000000..47ad7f678 --- /dev/null +++ b/app/hooks/useIsEmbedded.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export function useIsEmbedded() { + const [isEmbedded, setIsEmbedded] = useState(false); + + useEffect(() => { + // Check if the current window is not the top window + try { + setIsEmbedded(window.self !== window.top); + } catch (e) { + // If reading window.top throws a security error, it means we are in a cross-origin iframe - fallback for legacy browsers + setIsEmbedded(true); + } + }, []); + + return isEmbedded; +} diff --git a/app/pages/browse/index.tsx b/app/pages/browse/index.tsx index bd2336b77..81cca825b 100644 --- a/app/pages/browse/index.tsx +++ b/app/pages/browse/index.tsx @@ -3,6 +3,7 @@ import { GetServerSideProps } from "next"; import { SelectDatasetStep } from "@/browse/ui/select-dataset-step"; import { AppLayout } from "@/components/layout"; import { ConfiguratorStateProvider } from "@/configurator/configurator-state"; +import { useIsEmbedded } from "@/hooks/useIsEmbedded"; export const getServerSideProps: GetServerSideProps = async ({ query }) => { return { @@ -13,8 +14,9 @@ export const getServerSideProps: GetServerSideProps = async ({ query }) => { }; export function DatasetBrowser({ hideHeader }: { hideHeader: boolean }) { + const isEmbedded = useIsEmbedded(); return ( - + From 651612d4aa216676cfcb17e43937bd467fa059df Mon Sep 17 00:00:00 2001 From: Max Burri Date: Wed, 29 Jul 2026 16:47:14 +0200 Subject: [PATCH 3/4] doc: add changelog entry related to openddata.swiss embdeddings --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f924e31a..87b30f164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ You can also check the ## Unreleased +- Fixes + - Detect if the `/browse` page is embedded in an iframe and adjust the layout accordingly + - Allow '/browse' to be embedded in an iframe for opendata.swiss and admin.ch domains + ## 6.5.1 – 2026-07-10 - Fixes From c1cfcb3f751dd52bf00f2d8a7eebe3af589a78ae Mon Sep 17 00:00:00 2001 From: Max Burri Date: Tue, 4 Aug 2026 08:34:20 +0200 Subject: [PATCH 4/4] chore: do no export function that is only used internally - fixes a knip error --- app/browse/lib/params.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/browse/lib/params.ts b/app/browse/lib/params.ts index 6a01fe5f2..f4a8235a6 100644 --- a/app/browse/lib/params.ts +++ b/app/browse/lib/params.ts @@ -108,7 +108,7 @@ export const extractParamFromPath = (path: string, param: string) => { return path.match(new RegExp(`[&?]${param}=(.*?)(&|$)`)); }; -export const isOdsIframe = (query: ParsedUrlQuery) => { +const isOdsIframe = (query: ParsedUrlQuery) => { return query["odsiframe"] === "true"; };