diff --git a/next.config.js b/next.config.js
index 8db71f3..ef1fd80 100644
--- a/next.config.js
+++ b/next.config.js
@@ -7,7 +7,11 @@ const nextConfig = {
async headers() {
return [
{
- source: "/:path*",
+ // Every route except /embed. The blanket rule has to leave /embed out: a later, more
+ // specific rule cannot remove a header the earlier one set, and /embed was being
+ // served with both `X-Frame-Options: DENY` and `frame-ancestors *`. Browsers that
+ // let frame-ancestors win framed it; any that honoured DENY would not.
+ source: "/((?!embed(?:/|$)).*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
@@ -28,8 +32,10 @@ const nextConfig = {
// page meant to be framed by other sites. It renders no account
// state - public map data only - so allowing any origin to frame it
// isn't a privacy or clickjacking risk the way framing an
- // authenticated page would be. A more specific `source` here takes
- // precedence over the blanket DENY above for the same header.
+ // authenticated page would be. The rule above skips this path, so no
+ // X-Frame-Options header is sent and `frame-ancestors` is the only
+ // framing policy. Anything added to /embed that reads account state
+ // needs a review of this carve-out first (see #311).
source: "/embed/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
diff --git a/src/components/layout/navbar.test.tsx b/src/components/layout/navbar.test.tsx
new file mode 100644
index 0000000..4d9e38a
--- /dev/null
+++ b/src/components/layout/navbar.test.tsx
@@ -0,0 +1,54 @@
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { Navbar } from "@/components/layout/navbar";
+
+const { pathname, createClient, getUser } = vi.hoisted(() => ({
+ pathname: { current: "/map" },
+ createClient: vi.fn(),
+ getUser: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => pathname.current,
+ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
+}));
+vi.mock("next/dynamic", () => ({ default: () => () => null }));
+vi.mock("@/lib/supabase/client", () => ({ createClient }));
+vi.mock("@/lib/supabase/config", () => ({ isSupabaseConfigured: () => true }));
+vi.mock("@/components/layout/theme-toggle", () => ({ ThemeToggle: () => null }));
+
+beforeEach(() => {
+ getUser.mockReset().mockResolvedValue({ data: { user: null } });
+ createClient.mockReset().mockReturnValue({
+ auth: {
+ getUser,
+ onAuthStateChange: () => ({ data: { subscription: { unsubscribe: vi.fn() } } }),
+ },
+ });
+});
+
+afterEach(cleanup);
+
+describe("Navbar auth lookup", () => {
+ it("asks Supabase who the visitor is on a normal page", () => {
+ pathname.current = "/map";
+ const { container } = render();
+
+ expect(createClient).toHaveBeenCalled();
+ expect(getUser).toHaveBeenCalled();
+ expect(container.querySelector("header")).not.toBeNull();
+ });
+
+ it.each(["/embed", "/embed/anything"])(
+ "renders nothing and never touches Supabase on %s (the third-party iframe)",
+ (path) => {
+ pathname.current = path;
+ const { container } = render();
+
+ expect(container.innerHTML).toBe("");
+ expect(createClient).not.toHaveBeenCalled();
+ expect(getUser).not.toHaveBeenCalled();
+ },
+ );
+});
diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx
index b7a6bd9..e3e74a8 100644
--- a/src/components/layout/navbar.tsx
+++ b/src/components/layout/navbar.tsx
@@ -41,6 +41,9 @@ export function Navbar() {
const [searchLoaded, setSearchLoaded] = React.useState(false);
const authEnabled = isSupabaseConfigured();
+ // The embeddable map widget renders chrome-free in a third-party iframe - no nav, no
+ // sign-in, nothing account-aware. See /docs/embedding-widget.
+ const isEmbed = pathname.startsWith("/embed");
function openSearch() {
setSearchLoaded(true);
@@ -69,6 +72,8 @@ export function Navbar() {
}, []);
React.useEffect(() => {
+ // Nothing on /embed shows login state, so do not ask Supabase about the visitor at all.
+ if (isEmbed) return;
const supabase = createClient();
if (!supabase) return;
supabase.auth
@@ -79,7 +84,7 @@ export function Navbar() {
data: { subscription },
} = supabase.auth.onAuthStateChange((_, session) => setLoggedIn(!!session));
return () => subscription.unsubscribe();
- }, []);
+ }, [isEmbed]);
async function handleSignOut() {
const supabase = createClient();
@@ -91,9 +96,7 @@ export function Navbar() {
const isActive = (href: string) =>
href === "/" ? pathname === "/" : pathname.startsWith(href);
- // The embeddable map widget renders chrome-free in a third-party iframe -
- // no nav, no sign-in, nothing account-aware. See /docs/embedding-widget.
- if (pathname.startsWith("/embed")) return null;
+ if (isEmbed) return null;
return (
diff --git a/src/lib/embed-frame-policy.test.ts b/src/lib/embed-frame-policy.test.ts
new file mode 100644
index 0000000..5cf1381
--- /dev/null
+++ b/src/lib/embed-frame-policy.test.ts
@@ -0,0 +1,80 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { createRequire } from "node:module";
+import { describe, expect, it } from "vitest";
+
+// Guards the /embed carve-out reviewed in #311: it is the one route other sites may frame, so
+// it must not grow account state, and its framing headers must not contradict each other.
+
+const require = createRequire(import.meta.url);
+const root = path.resolve(__dirname, "../..");
+const nextConfig = require("../../next.config.js") as {
+ headers: () => Promise<{ source: string; headers: { key: string; value: string }[] }[]>;
+};
+
+const headerNames = (rule: { headers: { key: string }[] }) =>
+ rule.headers.map((h) => h.key.toLowerCase());
+
+/** Does a Next.js `source` pattern like `/((?!embed(?:/|$)).*)` match `pathname`? */
+function matchesBlanket(source: string, pathname: string) {
+ const inner = source.replace(/^\//, "");
+ return new RegExp(`^${inner}$`).test(pathname.replace(/^\//, ""));
+}
+
+describe("next.config.js framing headers", () => {
+ it("denies framing everywhere except /embed", async () => {
+ const rules = await nextConfig.headers();
+ const blanket = rules.find((r) => headerNames(r).includes("x-frame-options"));
+
+ expect(blanket).toBeDefined();
+ expect(blanket!.headers.find((h) => h.key === "X-Frame-Options")?.value).toBe("DENY");
+
+ for (const pathname of [
+ "/",
+ "/map",
+ "/login",
+ "/docs/embedding-widget",
+ "/embedded-things",
+ ]) {
+ expect(matchesBlanket(blanket!.source, pathname), pathname).toBe(true);
+ }
+ for (const pathname of ["/embed", "/embed/anything"]) {
+ expect(matchesBlanket(blanket!.source, pathname), pathname).toBe(false);
+ }
+ });
+
+ it("lets /embed be framed with frame-ancestors only, never together with X-Frame-Options", async () => {
+ const rules = await nextConfig.headers();
+ const embed = rules.find((r) => r.source === "/embed/:path*");
+
+ expect(embed).toBeDefined();
+ expect(embed!.headers.find((h) => h.key === "Content-Security-Policy")?.value).toBe(
+ "frame-ancestors *",
+ );
+ expect(headerNames(embed!)).not.toContain("x-frame-options");
+ });
+});
+
+describe("/embed renders no account state", () => {
+ const files = [
+ "src/app/embed/page.tsx",
+ "src/components/map/embed-map.tsx",
+ "src/components/map/embed-widget.tsx",
+ ];
+
+ it.each(files)("%s does not touch Supabase, auth or user data", (file) => {
+ const source = readFileSync(path.join(root, file), "utf8");
+ const imports = source
+ .split("\n")
+ .filter(
+ (line) =>
+ /^\s*(import|export)\b.*\bfrom\b/.test(line) || /^\s*import\s+["']/.test(line),
+ )
+ .join("\n");
+
+ expect(imports).not.toMatch(/supabase/i);
+ expect(imports).not.toMatch(
+ /\bauth\b|AuthProvider|use-user|user-places|user-profile/i,
+ );
+ });
+});