From e87c116ce75b5eb0a2498766099cea28ba2229b1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:43:07 +0800 Subject: [PATCH] fix(demo): fail closed when synthetic demo data would reach live production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural guard so the /api/search/universal leak class cannot recur: demoSearch() — the single choke point for synthetic document search (demoAnswer routes through it too) — now throws in a production request unless NEXT_PUBLIC_DEMO_MODE=true. In production the Supabase-config demo fallback and isLocalNoAuthMode are already disabled, so an explicit demo deploy is the only legitimate case. The guard reads process.env directly (not isDemoMode) so it cannot be silently disabled by a test that mocks @/lib/env, and so every current and future caller is covered — not just the routes audited today. Adds 2 regression tests (throws in production; allowed under explicit demo mode). Co-Authored-By: Claude Opus 4.8 --- src/lib/demo-data.ts | 18 ++++++++++++++++++ tests/demo-data.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/lib/demo-data.ts b/src/lib/demo-data.ts index 7b0f7e49f..9929c1789 100644 --- a/src/lib/demo-data.ts +++ b/src/lib/demo-data.ts @@ -331,7 +331,25 @@ function scoreChunk(query: string, chunk: SearchResult) { return Math.min(0.98, chunk.similarity * 0.6 + keywordScore + tokenScore); } +// Fail closed: synthetic demo content must never reach a live production request. Reads +// process.env directly (not isDemoMode) so it cannot be silently disabled by a test that mocks +// @/lib/env, and so every current/future caller of demoSearch is covered — this is what prevents +// a recurrence of the /api/search/universal leak. In production isLocalNoAuthMode and the +// Supabase-config demo fallback are already disabled, so an explicit NEXT_PUBLIC_DEMO_MODE=true +// deploy is the sole legitimate case. Mirrors the fail-closed requireOwnerScope retrieval guard. +function assertDemoDataAllowed(context: string) { + if (process.env.NODE_ENV === "production" && process.env.NEXT_PUBLIC_DEMO_MODE !== "true") { + throw new Error( + `Refusing to serve synthetic demo data (${context}) to a live production request. ` + + "Demo fixtures are only permitted when NEXT_PUBLIC_DEMO_MODE=true.", + ); + } +} + export function demoSearch(query: string, topK = 8, documentId?: string, documentIds?: string[]) { + // Single choke point for synthetic document search (demoAnswer routes through here too), so the + // guard covers any current or future caller, not just the routes audited today. + assertDemoDataAllowed("demoSearch"); const filters = documentIds?.length ? documentIds : documentId ? [documentId] : null; return demoChunks .filter((chunk) => !filters || filters.includes(chunk.document_id)) diff --git a/tests/demo-data.test.ts b/tests/demo-data.test.ts index a932c5823..677f6f6d1 100644 --- a/tests/demo-data.test.ts +++ b/tests/demo-data.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { demoAnswer, demoDocuments, demoSearch, getDemoDocumentPayload } from "../src/lib/demo-data"; describe("demo data mode", () => { @@ -83,3 +83,25 @@ describe("demo data mode", () => { expect(payload?.images[0].caption).toContain("monitoring table"); }); }); + +// Class-level guard so a future route cannot reintroduce the /api/search/universal leak: demo +// fixtures must fail closed in a live production request and only run under an explicit demo deploy. +describe("demoSearch production safety guard", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("refuses to serve synthetic demo data to a live production request", async () => { + vi.stubEnv("NODE_ENV", "production"); + const { demoSearch: guarded } = await import("../src/lib/demo-data"); + expect(() => guarded("lithium toxicity")).toThrow(/synthetic demo data/i); + }); + + it("still serves fixtures for an explicit NEXT_PUBLIC_DEMO_MODE=true deploy", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "true"); + const { demoSearch: guarded } = await import("../src/lib/demo-data"); + expect(() => guarded("lithium toxicity")).not.toThrow(); + }); +});