From 4c0e249da487ee6efd6402e480529b8c539ad8ae Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:44:58 -0800 Subject: [PATCH 1/4] feat(inflow-lab): public demo proxy without RegEngine session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /api/inflow-lab-demo with a strict method+path allowlist, per-IP rate limit, same-origin + CSRF on mutations, tenant-field stripping, body size cap, and fresh upstream headers (server Basic only — never forward cookies or caller tokens). Leave requireProxyAuth and the authed /api/inflow-lab proxy unchanged; point the tools client at the demo path so /tools/inflow-lab works signed-out. Signed-off-by: C.Lee Sellers <131207452+PetrefiedThunder@users.noreply.github.com> Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- frontend/.env.example | 5 + .../api/inflow-lab-demo-proxy.test.ts | 265 +++++++++++++ .../api/inflow-lab-demo/[...path]/route.ts | 361 ++++++++++++++++++ .../inflow-lab/components/InflowLabClient.tsx | 4 +- 4 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts create mode 100644 frontend/src/app/api/inflow-lab-demo/[...path]/route.ts diff --git a/frontend/.env.example b/frontend/.env.example index 832269d4c..594787ccc 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -36,6 +36,11 @@ INFLOW_WORKBENCH_READ_API_KEY= # INFLOW_LAB_SERVICE_URL=http://127.0.0.1:8000 # INFLOW_LAB_BASIC_AUTH_USERNAME= # INFLOW_LAB_BASIC_AUTH_PASSWORD= +# Public /tools/inflow-lab demo proxy (no RegEngine session). Leave the +# authed /api/inflow-lab proxy gated separately. Production needs an +# explicit enable; local/test is on automatically. Reuses the service URL +# and Basic auth vars above; never expose those via NEXT_PUBLIC_*. +# REGENGINE_ENABLE_INFLOW_LAB_DEMO_PROXY=0 # ── Backend Service URLs ──────────────────────────────────── # Used by /api/admin proxy to reach the consolidated backend. diff --git a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts new file mode 100644 index 000000000..4fb53a946 --- /dev/null +++ b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GET, POST, __resetInflowLabDemoRateLimitForTests } from '@/app/api/inflow-lab-demo/[...path]/route'; +import { CSRF_HEADER, CSRF_SIG_COOKIE, signCsrfToken } from '@/lib/csrf'; +import type { NextRequest } from 'next/server'; + +function makeRequest({ + method = 'GET', + url = 'http://localhost/api/inflow-lab-demo/api/healthz', + headers = {}, + body = '', + cookies = {} as Record, +}: { + method?: string; + url?: string; + headers?: Record; + body?: string; + cookies?: Record; +} = {}): NextRequest { + const cookieEntries = Object.entries(cookies).map(([name, value]) => ({ name, value })); + return { + method, + headers: new Headers(headers), + nextUrl: new URL(url), + cookies: { + getAll: vi.fn(() => cookieEntries), + get: vi.fn((name: string) => cookieEntries.find((cookie) => cookie.name === name)), + }, + text: vi.fn().mockResolvedValue(body), + } as unknown as NextRequest; +} + +async function csrfCookies(token = 'demo-csrf-token') { + const signature = await signCsrfToken(token); + return { + token, + cookies: { + re_csrf: token, + [CSRF_SIG_COOKIE]: signature, + }, + headers: { + [CSRF_HEADER]: token, + origin: 'http://localhost', + 'content-type': 'application/json', + }, + }; +} + +describe('Inflow Lab public demo proxy', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + __resetInflowLabDemoRateLimitForTests(); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('INFLOW_LAB_SERVICE_URL', 'http://inflow.test'); + vi.stubEnv('CSRF_SECRET', 'test-csrf-secret-for-demo-proxy'); + vi.stubEnv('INFLOW_LAB_DEMO_RATE_MAX', '100'); + vi.stubEnv('INFLOW_LAB_DEMO_RATE_WINDOW_MS', '60000'); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + __resetInflowLabDemoRateLimitForTests(); + }); + + it('allows unauthenticated allowlisted GET healthz without contacting auth', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, status: 'healthy' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const response = await GET( + makeRequest({ + url: 'http://localhost/api/inflow-lab-demo/api/healthz', + // No session cookies — public demo. + cookies: {}, + }), + { params: Promise.resolve({ path: ['api', 'healthz'] }) }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, status: 'healthy' }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0][0])).toBe('http://inflow.test/api/healthz'); + const init = mockFetch.mock.calls[0][1] as RequestInit; + const headers = init.headers as Headers; + expect(headers.get('cookie')).toBeNull(); + expect(headers.get('authorization')).toBeNull(); + expect(headers.get('x-regengine-tenant')).toBeNull(); + expect(init.credentials).toBe('omit'); + }); + + it('forwards allowlisted POST mutations with CSRF and strips tenant_id fields', async () => { + const csrf = await csrfCookies(); + vi.stubEnv('INFLOW_LAB_BASIC_AUTH_USERNAME', 'service-user'); + vi.stubEnv('INFLOW_LAB_BASIC_AUTH_PASSWORD', 'service-password'); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ started: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const response = await POST( + makeRequest({ + method: 'POST', + url: 'http://localhost/api/inflow-lab-demo/api/simulate/start', + headers: csrf.headers, + cookies: { + ...csrf.cookies, + // Browser session cookies must never be forwarded upstream. + __Host_regengine_session: 'opaque-session', + re_access_token: 'should-not-forward', + }, + body: JSON.stringify({ + config: { + source: 'demo', + delivery: { mode: 'local', tenant_id: 'tenant-should-strip', customer_id: 'cust' }, + tenantId: 'also-strip', + }, + }), + }), + { params: Promise.resolve({ path: ['api', 'simulate', 'start'] }) }, + ); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toBe('http://inflow.test/api/simulate/start'); + const headers = init.headers as Headers; + expect(headers.get('authorization')).toMatch(/^Basic /); + expect(headers.get('cookie')).toBeNull(); + expect(headers.get('x-regengine-tenant')).toBeNull(); + expect(headers.get('x-regengine-api-key')).toBeNull(); + expect(JSON.parse(String(init.body))).toEqual({ + config: { + source: 'demo', + delivery: { mode: 'local' }, + }, + }); + }); + + it('rejects non-allowlisted paths without contacting upstream', async () => { + const response = await GET( + makeRequest({ + url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/fda-request', + }), + { params: Promise.resolve({ path: ['api', 'regengine', 'export', 'fda-request'] }) }, + ); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ code: 'NOT_ALLOWLISTED' }); + expect(mockFetch).not.toHaveBeenCalled(); + + const scenarios = await GET( + makeRequest({ url: 'http://localhost/api/inflow-lab-demo/api/scenarios' }), + { params: Promise.resolve({ path: ['api', 'scenarios'] }) }, + ); + expect(scenarios.status).toBe(404); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects cross-origin requests', async () => { + const response = await GET( + makeRequest({ + url: 'http://localhost/api/inflow-lab-demo/api/healthz', + headers: { origin: 'https://attacker.example' }, + }), + { params: Promise.resolve({ path: ['api', 'healthz'] }) }, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: 'CROSS_ORIGIN' }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects mutations missing CSRF', async () => { + const response = await POST( + makeRequest({ + method: 'POST', + url: 'http://localhost/api/inflow-lab-demo/api/simulate/stop', + headers: { + origin: 'http://localhost', + 'content-type': 'application/json', + }, + body: '{}', + }), + { params: Promise.resolve({ path: ['api', 'simulate', 'stop'] }) }, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: 'CSRF_MISSING' }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not forward caller Authorization or Cookie headers upstream', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + await GET( + makeRequest({ + url: 'http://localhost/api/inflow-lab-demo/api/events?limit=10&tenant_id=nope', + headers: { + authorization: 'Bearer stolen-user-token', + cookie: 're_access_token=stolen; __Host-regengine_session=opaque', + }, + }), + { params: Promise.resolve({ path: ['api', 'events'] }) }, + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toBe('http://inflow.test/api/events?limit=10'); + const headers = init.headers as Headers; + expect(headers.get('authorization')).toBeNull(); + expect(headers.get('cookie')).toBeNull(); + }); + + it('trips the per-IP rate limit', async () => { + vi.stubEnv('INFLOW_LAB_DEMO_RATE_MAX', '2'); + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const first = await GET( + makeRequest({ headers: { 'x-forwarded-for': '203.0.113.9' } }), + { params: Promise.resolve({ path: ['api', 'healthz'] }) }, + ); + const second = await GET( + makeRequest({ headers: { 'x-forwarded-for': '203.0.113.9' } }), + { params: Promise.resolve({ path: ['api', 'healthz'] }) }, + ); + const third = await GET( + makeRequest({ headers: { 'x-forwarded-for': '203.0.113.9' } }), + { params: Promise.resolve({ path: ['api', 'healthz'] }) }, + ); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(third.status).toBe(429); + await expect(third.json()).resolves.toMatchObject({ code: 'RATE_LIMITED' }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('returns exact 404 in production unless the demo proxy flag is enabled', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('REGENGINE_ENABLE_INFLOW_LAB_DEMO_PROXY', ''); + + const response = await GET( + makeRequest(), + { params: Promise.resolve({ path: ['api', 'healthz'] }) }, + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe(''); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts new file mode 100644 index 000000000..700bd0931 --- /dev/null +++ b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts @@ -0,0 +1,361 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + CSRF_HEADER, + CSRF_PROTECTED_METHODS, + CSRF_SIG_COOKIE, + verifyCsrfToken, +} from "@/lib/csrf"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const DEFAULT_LOCAL_INFLOW_LAB_URL = "http://127.0.0.1:8000"; +const ALLOWED_METHODS = new Set(["GET", "POST"]); +const JSON_CONTENT_TYPES = ["application/json", "application/problem+json"]; +const MAX_BODY_BYTES = 32 * 1024; +const TENANT_LIKE_KEY = /^(tenant(_?id)?|tenantId|customer(_?id)?|customerId|organization(_?id)?|org_id|orgId)$/i; + +// Best-effort per-instance token bucket (same posture as /api/client-errors). +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_MAX_PER_WINDOW = 60; +const MAX_TRACKED_IPS = 10_000; +const buckets = new Map(); + +type DemoContract = + | "health" + | "status" + | "events" + | "lineage" + | "start" + | "stop" + | "reset" + | "fixture"; + +function serviceBaseUrl() { + return ( + process.env.INFLOW_LAB_SERVICE_URL || + DEFAULT_LOCAL_INFLOW_LAB_URL + ).replace(/\/$/, ""); +} + +function demoProxyEnabled() { + if (process.env.NODE_ENV !== "production") return true; + const value = process.env.REGENGINE_ENABLE_INFLOW_LAB_DEMO_PROXY?.trim().toLowerCase(); + return value === "1" || value === "true"; +} + +function basicAuthHeader() { + const username = process.env.INFLOW_LAB_BASIC_AUTH_USERNAME; + const password = process.env.INFLOW_LAB_BASIC_AUTH_PASSWORD; + if (!username || !password) return null; + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + +function rateWindowMs() { + const raw = Number(process.env.INFLOW_LAB_DEMO_RATE_WINDOW_MS || ""); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_WINDOW_MS; +} + +function rateMaxPerWindow() { + const raw = Number(process.env.INFLOW_LAB_DEMO_RATE_MAX || ""); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_PER_WINDOW; +} + +/** Test-only: clear in-memory buckets between cases. */ +export function __resetInflowLabDemoRateLimitForTests() { + buckets.clear(); +} + +function clientIp(request: NextRequest): string { + const xff = request.headers.get("x-forwarded-for"); + if (xff) return xff.split(",")[0]?.trim() || "unknown"; + return request.headers.get("x-real-ip") || "unknown"; +} + +function allowIp(ip: string): boolean { + const now = Date.now(); + const windowMs = rateWindowMs(); + const max = rateMaxPerWindow(); + const bucket = buckets.get(ip); + if (!bucket || now - bucket.windowStart >= windowMs) { + if (buckets.size >= MAX_TRACKED_IPS) { + for (const [key, value] of buckets) { + if (now - value.windowStart >= windowMs) buckets.delete(key); + } + if (buckets.size >= MAX_TRACKED_IPS) buckets.clear(); + } + buckets.set(ip, { count: 1, windowStart: now }); + return true; + } + bucket.count += 1; + return bucket.count <= max; +} + +function pathFromParams(path: string[] = []) { + if (path.some((segment) => segment === ".." || segment.includes("\0"))) { + return null; + } + return `/${path.map(encodeURIComponent).join("/")}`; +} + +function demoContract(path: string, method: string): DemoContract | null { + if (method === "GET" && path === "/api/healthz") return "health"; + if (method === "GET" && path === "/api/simulate/status") return "status"; + if (method === "GET" && path === "/api/events") return "events"; + if (method === "GET" && /^\/api\/lineage\/[^/]+$/.test(path)) return "lineage"; + if (method === "POST" && path === "/api/simulate/start") return "start"; + if (method === "POST" && path === "/api/simulate/stop") return "stop"; + if (method === "POST" && path === "/api/simulate/reset") return "reset"; + if (method === "POST" && /^\/api\/demo-fixtures\/[^/]+\/load$/.test(path)) return "fixture"; + return null; +} + +function copyAllowedSearchParams(source: URLSearchParams, target: URLSearchParams) { + for (const [key, value] of source.entries()) { + if (TENANT_LIKE_KEY.test(key)) continue; + if (/^[a-zA-Z0-9_.-]{1,64}$/.test(key)) { + target.append(key, value.slice(0, 256)); + } + } +} + +function isJsonResponse(response: Response) { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + return JSON_CONTENT_TYPES.some((type) => contentType.includes(type)); +} + +function stripTenantLike(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripTenantLike); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + if (TENANT_LIKE_KEY.test(key)) continue; + out[key] = stripTenantLike(child); + } + return out; + } + return value; +} + +function sameOriginAllowed(request: NextRequest, requirePresent: boolean): boolean { + const sourceUrl = request.headers.get("origin") || request.headers.get("referer"); + if (!sourceUrl) return !requirePresent; + try { + return new URL(sourceUrl).origin === request.nextUrl.origin; + } catch { + return false; + } +} + +async function enforceCsrf(request: NextRequest): Promise { + const method = request.method.toUpperCase(); + if (!CSRF_PROTECTED_METHODS.has(method)) return null; + + if (!sameOriginAllowed(request, true)) { + return NextResponse.json( + { ok: false, error: "Cross-origin mutation rejected", code: "CROSS_ORIGIN" }, + { status: 403, headers: { "cache-control": "no-store" } }, + ); + } + + const headerToken = request.headers.get(CSRF_HEADER); + const sigCookie = request.cookies.get(CSRF_SIG_COOKIE)?.value; + if (!headerToken || !sigCookie) { + return NextResponse.json( + { ok: false, error: "Missing CSRF token", code: "CSRF_MISSING" }, + { status: 403, headers: { "cache-control": "no-store" } }, + ); + } + const valid = await verifyCsrfToken(headerToken, sigCookie); + if (!valid) { + return NextResponse.json( + { ok: false, error: "Invalid CSRF token", code: "CSRF_INVALID" }, + { status: 403, headers: { "cache-control": "no-store" } }, + ); + } + return null; +} + +async function readSanitizedBody(request: NextRequest): Promise< + { ok: true; body?: string } | { ok: false; response: NextResponse } +> { + if (request.method.toUpperCase() === "GET") return { ok: true }; + + const raw = await request.text(); + if (raw.length > MAX_BODY_BYTES) { + return { + ok: false, + response: NextResponse.json( + { ok: false, error: "Request body too large", code: "BODY_TOO_LARGE" }, + { status: 413, headers: { "cache-control": "no-store" } }, + ), + }; + } + if (!raw.trim()) return { ok: true, body: undefined }; + + const contentType = request.headers.get("content-type")?.toLowerCase() || ""; + if (!contentType.includes("application/json")) { + return { + ok: false, + response: NextResponse.json( + { ok: false, error: "JSON body required", code: "INVALID_CONTENT_TYPE" }, + { status: 415, headers: { "cache-control": "no-store" } }, + ), + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return { + ok: false, + response: NextResponse.json( + { ok: false, error: "Invalid JSON body", code: "INVALID_JSON" }, + { status: 400, headers: { "cache-control": "no-store" } }, + ), + }; + } + + return { ok: true, body: JSON.stringify(stripTenantLike(parsed)) }; +} + +function responseHeaders(contract: DemoContract, upstream?: Response) { + const headers = new Headers({ + "cache-control": "no-store", + "x-inflow-lab-demo-contract": contract, + "x-inflow-lab-boundary": "public-demo", + }); + const contentType = upstream?.headers.get("content-type"); + if (contentType) headers.set("content-type", contentType); + return headers; +} + +async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] }>) { + if (!demoProxyEnabled()) { + return new NextResponse(null, { status: 404 }); + } + + const method = request.method.toUpperCase(); + if (!ALLOWED_METHODS.has(method)) { + return NextResponse.json( + { ok: false, error: "Method not allowed", code: "METHOD_NOT_ALLOWED" }, + { status: 405, headers: { "cache-control": "no-store" } }, + ); + } + + // Cross-origin GETs with an explicit foreign Origin/Referer are rejected + // even though CSRF only applies to mutations. + if (!sameOriginAllowed(request, false)) { + return NextResponse.json( + { ok: false, error: "Cross-origin request rejected", code: "CROSS_ORIGIN" }, + { status: 403, headers: { "cache-control": "no-store" } }, + ); + } + + const csrfError = await enforceCsrf(request); + if (csrfError) return csrfError; + + if (!allowIp(clientIp(request))) { + return NextResponse.json( + { ok: false, error: "Rate limit exceeded", code: "RATE_LIMITED" }, + { + status: 429, + headers: { + "cache-control": "no-store", + "Retry-After": String(Math.ceil(rateWindowMs() / 1000)), + }, + }, + ); + } + + const { path } = await params; + const relativePath = pathFromParams(path); + const contract = relativePath ? demoContract(relativePath, method) : null; + if (!relativePath || !contract) { + return NextResponse.json( + { + ok: false, + error: "Not found", + code: "NOT_ALLOWLISTED", + hint: "Public demo proxy allows healthz, simulate status/start/stop/reset, events, lineage, and demo-fixtures load only.", + }, + { status: 404, headers: { "cache-control": "no-store" } }, + ); + } + + const bodyResult = await readSanitizedBody(request); + if (!bodyResult.ok) return bodyResult.response; + + const upstreamUrl = new URL(`${serviceBaseUrl()}${relativePath}`); + copyAllowedSearchParams(request.nextUrl.searchParams, upstreamUrl.searchParams); + + // Fresh minimal headers only — never forward browser cookies, Authorization, + // or caller tokens. Server-held Basic auth is attached when configured. + const headers = new Headers(); + if (bodyResult.body !== undefined) { + headers.set("content-type", "application/json"); + } + const authorization = basicAuthHeader(); + if (authorization) headers.set("authorization", authorization); + + try { + const upstream = await fetch(upstreamUrl, { + method, + headers, + body: method === "GET" ? undefined : bodyResult.body, + cache: "no-store", + redirect: "manual", + // Explicitly omit credentials so no ambient cookie jar can attach. + credentials: "omit", + }); + + if (upstream.status >= 300 && upstream.status < 400) { + return NextResponse.json( + { ok: false, error: "Upstream redirect refused", code: "UPSTREAM_BAD_RESPONSE" }, + { status: 502, headers: responseHeaders(contract) }, + ); + } + + if (!isJsonResponse(upstream)) { + return NextResponse.json( + { + ok: false, + error: "Upstream returned a non-JSON response", + code: "UPSTREAM_BAD_RESPONSE", + detail: upstream.headers.get("content-type") || "missing content-type", + }, + { + status: upstream.ok ? 502 : upstream.status, + headers: responseHeaders(contract), + }, + ); + } + + const payload = await upstream.json().catch(() => null); + return NextResponse.json(payload, { + status: upstream.status, + headers: responseHeaders(contract, upstream), + }); + } catch (error) { + const correlationId = crypto.randomUUID(); + console.error("[inflow-lab-demo] Proxy request failed", { correlationId, error }); + return NextResponse.json( + { + ok: false, + error: "Inflow Lab demo service unavailable", + code: "SERVICE_UNAVAILABLE", + correlation_id: correlationId, + }, + { status: 502, headers: responseHeaders(contract) }, + ); + } +} + +export function GET(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { + return proxyDemo(request, context.params); +} + +export function POST(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { + return proxyDemo(request, context.params); +} diff --git a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx index 382880ded..4c8d796af 100644 --- a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx +++ b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx @@ -230,7 +230,7 @@ type WorkbenchRunResponse = { saved_at: string; }; -const INFLOW_API = "/api/inflow-lab"; +const INFLOW_API = "/api/inflow-lab-demo"; const WORKBENCH_API = "/api/ingestion/api/v1/inflow-workbench"; const REQUIRED_CTES = ["harvesting", "cooling", "initial_packing", "shipping", "receiving"]; const SANDBOX_CTE_TYPES = [ @@ -307,7 +307,7 @@ async function inflowJson(path: string, init?: RequestInit): Promise { if (!headers.has("content-type")) { headers.set("content-type", "application/json"); } - // Mutating /api/inflow-lab calls are CSRF-protected by proxy.ts; match workbenchJson. + // Mutating /api/inflow-lab-demo calls are CSRF-protected by proxy.ts + the demo route; match workbenchJson. const response = await fetchWithCsrf(servicePath(path), { cache: "no-store", ...init, From ee1fccba32f57aaacaaa480dc5cfe351b046feb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:59:15 +0000 Subject: [PATCH 2/4] fix copy gate findings in demo proxy Co-authored-by: PetrefiedThunder <131207452+PetrefiedThunder@users.noreply.github.com> --- frontend/src/app/api/inflow-lab-demo/[...path]/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts index 700bd0931..15ce9e55a 100644 --- a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts +++ b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts @@ -278,7 +278,7 @@ async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] ok: false, error: "Not found", code: "NOT_ALLOWLISTED", - hint: "Public demo proxy allows healthz, simulate status/start/stop/reset, events, lineage, and demo-fixtures load only.", + hint: "This endpoint allows healthz, simulate status/start/stop/reset, events, lineage, and fixture loading only.", }, { status: 404, headers: { "cache-control": "no-store" } }, ); @@ -343,7 +343,7 @@ async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] return NextResponse.json( { ok: false, - error: "Inflow Lab demo service unavailable", + error: "Inflow Lab service unavailable", code: "SERVICE_UNAVAILABLE", correlation_id: correlationId, }, From 986f32a81c0334e216ac500474e03fe6457abfca Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:12:30 -0800 Subject: [PATCH 3/4] fix(inflow-lab): harden public proxy for Codex FAIL #3797 Use platform-trusted client IP for rate limiting, strip tenant-like fields from proxied JSON responses, harden path-ID validation against encoded traversal, allowlist export preview GETs under the public base with safeHref, and clear remaining sample/demo copy in the tools page. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../api/inflow-lab-demo-proxy.test.ts | 216 ++++++++++++++++-- .../api/inflow-lab-demo/[...path]/route.ts | 146 ++++++++++-- .../inflow-lab/components/InflowLabClient.tsx | 8 +- 3 files changed, 317 insertions(+), 53 deletions(-) diff --git a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts index 4fb53a946..3597ff755 100644 --- a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts +++ b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { GET, POST, __resetInflowLabDemoRateLimitForTests } from '@/app/api/inflow-lab-demo/[...path]/route'; +import { + GET, + POST, + __resetInflowLabDemoRateLimitForTests, + trustedClientIp, + sanitizePathSegment, + stripTenantLike, +} from '@/app/api/inflow-lab-demo/[...path]/route'; import { CSRF_HEADER, CSRF_SIG_COOKIE, signCsrfToken } from '@/lib/csrf'; import type { NextRequest } from 'next/server'; @@ -29,7 +36,7 @@ function makeRequest({ } as unknown as NextRequest; } -async function csrfCookies(token = 'demo-csrf-token') { +async function csrfCookies(token = 'public-csrf-token') { const signature = await signCsrfToken(token); return { token, @@ -45,7 +52,7 @@ async function csrfCookies(token = 'demo-csrf-token') { }; } -describe('Inflow Lab public demo proxy', () => { +describe('Inflow Lab public proxy', () => { const mockFetch = vi.fn(); beforeEach(() => { @@ -53,7 +60,7 @@ describe('Inflow Lab public demo proxy', () => { __resetInflowLabDemoRateLimitForTests(); vi.stubEnv('NODE_ENV', 'test'); vi.stubEnv('INFLOW_LAB_SERVICE_URL', 'http://inflow.test'); - vi.stubEnv('CSRF_SECRET', 'test-csrf-secret-for-demo-proxy'); + vi.stubEnv('CSRF_SECRET', 'test-csrf-secret-for-public-proxy'); vi.stubEnv('INFLOW_LAB_DEMO_RATE_MAX', '100'); vi.stubEnv('INFLOW_LAB_DEMO_RATE_WINDOW_MS', '60000'); vi.stubGlobal('fetch', mockFetch); @@ -75,7 +82,6 @@ describe('Inflow Lab public demo proxy', () => { const response = await GET( makeRequest({ url: 'http://localhost/api/inflow-lab-demo/api/healthz', - // No session cookies — public demo. cookies: {}, }), { params: Promise.resolve({ path: ['api', 'healthz'] }) }, @@ -93,12 +99,12 @@ describe('Inflow Lab public demo proxy', () => { expect(init.credentials).toBe('omit'); }); - it('forwards allowlisted POST mutations with CSRF and strips tenant_id fields', async () => { + it('forwards allowlisted POST mutations with CSRF and strips tenant_id fields from the body', async () => { const csrf = await csrfCookies(); vi.stubEnv('INFLOW_LAB_BASIC_AUTH_USERNAME', 'service-user'); vi.stubEnv('INFLOW_LAB_BASIC_AUTH_PASSWORD', 'service-password'); mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ started: true }), { + new Response(JSON.stringify({ started: true, tenant_id: 'upstream-tenant' }), { status: 200, headers: { 'content-type': 'application/json' }, }), @@ -111,13 +117,12 @@ describe('Inflow Lab public demo proxy', () => { headers: csrf.headers, cookies: { ...csrf.cookies, - // Browser session cookies must never be forwarded upstream. __Host_regengine_session: 'opaque-session', re_access_token: 'should-not-forward', }, body: JSON.stringify({ config: { - source: 'demo', + source: 'fixture', delivery: { mode: 'local', tenant_id: 'tenant-should-strip', customer_id: 'cust' }, tenantId: 'also-strip', }, @@ -127,6 +132,8 @@ describe('Inflow Lab public demo proxy', () => { ); expect(response.status).toBe(200); + // Response tenant fields are stripped before reflection. + await expect(response.json()).resolves.toEqual({ started: true }); expect(mockFetch).toHaveBeenCalledTimes(1); const [url, init] = mockFetch.mock.calls[0]; expect(String(url)).toBe('http://inflow.test/api/simulate/start'); @@ -134,34 +141,98 @@ describe('Inflow Lab public demo proxy', () => { expect(headers.get('authorization')).toMatch(/^Basic /); expect(headers.get('cookie')).toBeNull(); expect(headers.get('x-regengine-tenant')).toBeNull(); - expect(headers.get('x-regengine-api-key')).toBeNull(); expect(JSON.parse(String(init.body))).toEqual({ config: { - source: 'demo', + source: 'fixture', delivery: { mode: 'local' }, }, }); }); - it('rejects non-allowlisted paths without contacting upstream', async () => { + it('strips nested tenant-like keys from proxied JSON responses', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + tenant_id: 'should-vanish', + delivery: { mode: 'local', tenantId: 'also-vanish', posted: 3 }, + events: [{ id: 'e1', customer_id: 'c1', lot: 'L1' }], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const response = await GET( - makeRequest({ - url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/fda-request', - }), - { params: Promise.resolve({ path: ['api', 'regengine', 'export', 'fda-request'] }) }, + makeRequest({ url: 'http://localhost/api/inflow-lab-demo/api/events' }), + { params: Promise.resolve({ path: ['api', 'events'] }) }, ); - expect(response.status).toBe(404); - await expect(response.json()).resolves.toMatchObject({ code: 'NOT_ALLOWLISTED' }); - expect(mockFetch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + delivery: { mode: 'local', posted: 3 }, + events: [{ id: 'e1', lot: 'L1' }], + }); + }); + + it('rejects non-allowlisted paths without contacting upstream', async () => { const scenarios = await GET( makeRequest({ url: 'http://localhost/api/inflow-lab-demo/api/scenarios' }), { params: Promise.resolve({ path: ['api', 'scenarios'] }) }, ); expect(scenarios.status).toBe(404); + await expect(scenarios.json()).resolves.toMatchObject({ code: 'NOT_ALLOWLISTED' }); + expect(mockFetch).not.toHaveBeenCalled(); + + const admin = await GET( + makeRequest({ url: 'http://localhost/api/inflow-lab-demo/api/admin/tenants' }), + { params: Promise.resolve({ path: ['api', 'admin', 'tenants'] }) }, + ); + expect(admin.status).toBe(404); expect(mockFetch).not.toHaveBeenCalled(); }); + it('allowlists export preview GETs under /api/inflow-lab-demo and streams the body', async () => { + mockFetch.mockResolvedValueOnce( + new Response('lot,qty\nL1,10\n', { + status: 200, + headers: { + 'content-type': 'text/csv', + 'content-disposition': 'attachment; filename="preview.csv"', + }, + }), + ); + + const response = await GET( + makeRequest({ + url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/fda-request?preset=all_records', + }), + { params: Promise.resolve({ path: ['api', 'regengine', 'export', 'fda-request'] }) }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/csv'); + expect(await response.text()).toBe('lot,qty\nL1,10\n'); + expect(String(mockFetch.mock.calls[0][0])).toContain( + 'http://inflow.test/api/regengine/export/fda-request', + ); + + mockFetch.mockResolvedValueOnce( + new Response('', { + status: 200, + headers: { 'content-type': 'application/xml' }, + }), + ); + const epcis = await GET( + makeRequest({ + url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/epcis', + }), + { params: Promise.resolve({ path: ['api', 'regengine', 'export', 'epcis'] }) }, + ); + expect(epcis.status).toBe(200); + expect(await epcis.text()).toBe(''); + }); + it('rejects cross-origin requests', async () => { const response = await GET( makeRequest({ @@ -220,7 +291,7 @@ describe('Inflow Lab public demo proxy', () => { expect(headers.get('cookie')).toBeNull(); }); - it('trips the per-IP rate limit', async () => { + it('keys the rate limit on trusted platform IP, not client-spoofed XFF', async () => { vi.stubEnv('INFLOW_LAB_DEMO_RATE_MAX', '2'); mockFetch.mockResolvedValue( new Response(JSON.stringify({ ok: true }), { @@ -229,27 +300,85 @@ describe('Inflow Lab public demo proxy', () => { }), ); + const trusted = { 'x-real-ip': '198.51.100.20', 'x-forwarded-for': '203.0.113.1' }; const first = await GET( - makeRequest({ headers: { 'x-forwarded-for': '203.0.113.9' } }), + makeRequest({ headers: trusted }), { params: Promise.resolve({ path: ['api', 'healthz'] }) }, ); const second = await GET( - makeRequest({ headers: { 'x-forwarded-for': '203.0.113.9' } }), + makeRequest({ headers: { ...trusted, 'x-forwarded-for': '203.0.113.2' } }), { params: Promise.resolve({ path: ['api', 'healthz'] }) }, ); + // Spoofing the left XFF hop must not reset the bucket keyed on x-real-ip. const third = await GET( - makeRequest({ headers: { 'x-forwarded-for': '203.0.113.9' } }), + makeRequest({ headers: { ...trusted, 'x-forwarded-for': '203.0.113.3' } }), { params: Promise.resolve({ path: ['api', 'healthz'] }) }, ); expect(first.status).toBe(200); expect(second.status).toBe(200); expect(third.status).toBe(429); - await expect(third.json()).resolves.toMatchObject({ code: 'RATE_LIMITED' }); expect(mockFetch).toHaveBeenCalledTimes(2); + + expect( + trustedClientIp( + makeRequest({ + headers: { + 'x-forwarded-for': '203.0.113.9, 198.51.100.20', + 'x-real-ip': '198.51.100.20', + }, + }), + ), + ).toBe('198.51.100.20'); + expect( + trustedClientIp( + makeRequest({ + headers: { 'x-vercel-forwarded-for': '198.51.100.44, 10.0.0.1' }, + }), + ), + ).toBe('198.51.100.44'); + expect( + trustedClientIp( + makeRequest({ + headers: { 'x-forwarded-for': '203.0.113.9, 198.51.100.77' }, + }), + ), + ).toBe('198.51.100.77'); + }); + + it('rejects encoded traversal and unsafe path IDs before contacting upstream', async () => { + const cases: Array<{ path: string[]; label: string }> = [ + { path: ['api', 'lineage', '..'], label: 'dotdot' }, + { path: ['api', 'lineage', '%2e%2e'], label: 'encoded-dotdot' }, + { path: ['api', 'lineage', '%2e%2e%2fetc'], label: 'encoded-slash' }, + { path: ['api', 'lineage', '%252e%252e'], label: 'double-encoded' }, + { path: ['api', 'lineage', 'foo%2fbar'], label: 'encoded-slash-mid' }, + { path: ['api', 'lineage', 'foo\\bar'], label: 'backslash' }, + { path: ['api', 'demo-fixtures', '..%2Fsecret', 'load'], label: 'fixture-traversal' }, + { path: ['api', 'lineage', '../secret'], label: 'slash-dotdot' }, + ]; + + for (const { path } of cases) { + mockFetch.mockClear(); + const response = await GET( + makeRequest({ + url: `http://localhost/api/inflow-lab-demo/${path.join('/')}`, + }), + { params: Promise.resolve({ path }) }, + ); + expect(response.status, path.join('/')).toBe(404); + expect(mockFetch, path.join('/')).not.toHaveBeenCalled(); + } + + expect(sanitizePathSegment('LOT-001')).toBe('LOT-001'); + expect(sanitizePathSegment('%2e%2e')).toBeNull(); + expect(sanitizePathSegment('%252e%252e')).toBeNull(); + expect(sanitizePathSegment('..\\windows')).toBeNull(); + expect(sanitizePathSegment('a/b')).toBeNull(); + expect(stripTenantLike({ tenant_id: 't', keep: 1 })).toEqual({ keep: 1 }); }); - it('returns exact 404 in production unless the demo proxy flag is enabled', async () => { + it('returns exact 404 in production unless the public proxy flag is enabled', async () => { vi.stubEnv('NODE_ENV', 'production'); vi.stubEnv('REGENGINE_ENABLE_INFLOW_LAB_DEMO_PROXY', ''); @@ -263,3 +392,40 @@ describe('Inflow Lab public demo proxy', () => { expect(mockFetch).not.toHaveBeenCalled(); }); }); + +describe('Inflow Lab client export hrefs use the public proxy base', () => { + it('documents the public base + safe relative export paths', async () => { + // Mirrors InflowLabClient: INFLOW_API + servicePath + safeHref. + const INFLOW_API = '/api/inflow-lab-demo'; + function servicePath(path: string) { + return `${INFLOW_API}${path}`; + } + function safeHref(raw: string): string { + try { + const parsed = new URL(raw, 'https://regengine.invalid'); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return raw.startsWith('/') + ? `${parsed.pathname}${parsed.search}${parsed.hash}` + : parsed.toString(); + } + } catch { + // fall through + } + return '#'; + } + + const csv = safeHref( + servicePath( + '/api/regengine/export/fda-request?preset=all_records&traceability_lot_code=L1', + ), + ); + const epcis = safeHref( + servicePath('/api/regengine/export/epcis?traceability_lot_code=L1'), + ); + + expect(csv.startsWith('/api/inflow-lab-demo/api/regengine/export/fda-request')).toBe(true); + expect(epcis.startsWith('/api/inflow-lab-demo/api/regengine/export/epcis')).toBe(true); + expect(csv.includes('://')).toBe(false); + expect(epcis.includes('://')).toBe(false); + }); +}); diff --git a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts index 15ce9e55a..2b660065d 100644 --- a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts +++ b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts @@ -14,6 +14,8 @@ const ALLOWED_METHODS = new Set(["GET", "POST"]); const JSON_CONTENT_TYPES = ["application/json", "application/problem+json"]; const MAX_BODY_BYTES = 32 * 1024; const TENANT_LIKE_KEY = /^(tenant(_?id)?|tenantId|customer(_?id)?|customerId|organization(_?id)?|org_id|orgId)$/i; +const TRAVERSAL_ENCODED = /%2e|%2f|%5c/i; +const SEGMENT_ALLOW = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; // Best-effort per-instance token bucket (same posture as /api/client-errors). const DEFAULT_WINDOW_MS = 60_000; @@ -29,7 +31,8 @@ type DemoContract = | "start" | "stop" | "reset" - | "fixture"; + | "fixture" + | "export"; function serviceBaseUrl() { return ( @@ -38,7 +41,7 @@ function serviceBaseUrl() { ).replace(/\/$/, ""); } -function demoProxyEnabled() { +function publicProxyEnabled() { if (process.env.NODE_ENV !== "production") return true; const value = process.env.REGENGINE_ENABLE_INFLOW_LAB_DEMO_PROXY?.trim().toLowerCase(); return value === "1" || value === "true"; @@ -66,10 +69,28 @@ export function __resetInflowLabDemoRateLimitForTests() { buckets.clear(); } -function clientIp(request: NextRequest): string { +/** + * Bucket key for rate limiting. Prefer platform-trusted client IP headers + * (Vercel). Never key on the client-controlled first X-Forwarded-For hop. + */ +export function trustedClientIp(request: NextRequest): string { + const vercelForwarded = request.headers.get("x-vercel-forwarded-for")?.trim(); + if (vercelForwarded) { + const hop = vercelForwarded.split(",")[0]?.trim(); + if (hop) return hop; + } + + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; + + // Rightmost XFF hop is the one closest to our edge / added by a trusted proxy. const xff = request.headers.get("x-forwarded-for"); - if (xff) return xff.split(",")[0]?.trim() || "unknown"; - return request.headers.get("x-real-ip") || "unknown"; + if (xff) { + const hops = xff.split(",").map((part) => part.trim()).filter(Boolean); + if (hops.length) return hops[hops.length - 1]!; + } + + return "unknown"; } function allowIp(ip: string): boolean { @@ -91,22 +112,87 @@ function allowIp(ip: string): boolean { return bucket.count <= max; } -function pathFromParams(path: string[] = []) { - if (path.some((segment) => segment === ".." || segment.includes("\0"))) { +/** Decode + harden a single path segment against encoded traversal. */ +export function sanitizePathSegment(raw: string): string | null { + if (!raw || raw.includes("\0")) return null; + // Reject encoded dots/slashes/backslashes and raw traversal before decode. + if (TRAVERSAL_ENCODED.test(raw) || raw.includes("..") || /[\\/]/.test(raw)) return null; + + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + return null; + } + + if ( + decoded.includes("\0") + || decoded.includes("..") + || /[\\/]/.test(decoded) + || TRAVERSAL_ENCODED.test(decoded) + ) { return null; } - return `/${path.map(encodeURIComponent).join("/")}`; + + // Double-encoded forms (%252e → %2e → .) must not survive. + try { + const twice = decodeURIComponent(decoded); + if ( + twice !== decoded + && ( + twice.includes("\0") + || twice.includes("..") + || /[\\/]/.test(twice) + || TRAVERSAL_ENCODED.test(twice) + ) + ) { + return null; + } + } catch { + return null; + } + + if (!SEGMENT_ALLOW.test(decoded)) return null; + return decoded; +} + +function pathFromParams(path: string[] = []): string | null { + if (!path.length) return null; + const cleaned: string[] = []; + for (const segment of path) { + const safe = sanitizePathSegment(segment); + if (!safe) return null; + cleaned.push(safe); + } + return `/${cleaned.join("/")}`; } -function demoContract(path: string, method: string): DemoContract | null { +function upstreamPathFor(relativePath: string): string { + return relativePath + .split("/") + .map((segment) => (segment === "" ? "" : encodeURIComponent(segment))) + .join("/"); +} + +function contractFor(path: string, method: string): DemoContract | null { if (method === "GET" && path === "/api/healthz") return "health"; if (method === "GET" && path === "/api/simulate/status") return "status"; if (method === "GET" && path === "/api/events") return "events"; - if (method === "GET" && /^\/api\/lineage\/[^/]+$/.test(path)) return "lineage"; + if (method === "GET" && /^\/api\/lineage\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(path)) { + return "lineage"; + } if (method === "POST" && path === "/api/simulate/start") return "start"; if (method === "POST" && path === "/api/simulate/stop") return "stop"; if (method === "POST" && path === "/api/simulate/reset") return "reset"; - if (method === "POST" && /^\/api\/demo-fixtures\/[^/]+\/load$/.test(path)) return "fixture"; + if ( + method === "POST" + && /^\/api\/demo-fixtures\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\/load$/.test(path) + ) { + return "fixture"; + } + // Preview export downloads (CSV / EPCIS Preview) for the public tools page. + if (method === "GET" && path === "/api/regengine/export/fda-request") return "export"; + if (method === "GET" && path === "/api/regengine/export/epcis") return "export"; return null; } @@ -124,7 +210,7 @@ function isJsonResponse(response: Response) { return JSON_CONTENT_TYPES.some((type) => contentType.includes(type)); } -function stripTenantLike(value: unknown): unknown { +export function stripTenantLike(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripTenantLike); if (value && typeof value === "object") { const out: Record = {}; @@ -223,16 +309,18 @@ async function readSanitizedBody(request: NextRequest): Promise< function responseHeaders(contract: DemoContract, upstream?: Response) { const headers = new Headers({ "cache-control": "no-store", - "x-inflow-lab-demo-contract": contract, - "x-inflow-lab-boundary": "public-demo", + "x-inflow-lab-public-contract": contract, + "x-inflow-lab-boundary": "public-preview", }); const contentType = upstream?.headers.get("content-type"); if (contentType) headers.set("content-type", contentType); + const contentDisposition = upstream?.headers.get("content-disposition"); + if (contentDisposition) headers.set("content-disposition", contentDisposition); return headers; } -async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] }>) { - if (!demoProxyEnabled()) { +async function proxyPublic(request: NextRequest, params: Promise<{ path?: string[] }>) { + if (!publicProxyEnabled()) { return new NextResponse(null, { status: 404 }); } @@ -256,7 +344,7 @@ async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] const csrfError = await enforceCsrf(request); if (csrfError) return csrfError; - if (!allowIp(clientIp(request))) { + if (!allowIp(trustedClientIp(request))) { return NextResponse.json( { ok: false, error: "Rate limit exceeded", code: "RATE_LIMITED" }, { @@ -271,14 +359,14 @@ async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] const { path } = await params; const relativePath = pathFromParams(path); - const contract = relativePath ? demoContract(relativePath, method) : null; + const contract = relativePath ? contractFor(relativePath, method) : null; if (!relativePath || !contract) { return NextResponse.json( { ok: false, error: "Not found", code: "NOT_ALLOWLISTED", - hint: "This endpoint allows healthz, simulate status/start/stop/reset, events, lineage, and fixture loading only.", + hint: "This endpoint allows healthz, simulate status/start/stop/reset, events, lineage, fixture loading, and export previews only.", }, { status: 404, headers: { "cache-control": "no-store" } }, ); @@ -287,7 +375,7 @@ async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] const bodyResult = await readSanitizedBody(request); if (!bodyResult.ok) return bodyResult.response; - const upstreamUrl = new URL(`${serviceBaseUrl()}${relativePath}`); + const upstreamUrl = new URL(`${serviceBaseUrl()}${upstreamPathFor(relativePath)}`); copyAllowedSearchParams(request.nextUrl.searchParams, upstreamUrl.searchParams); // Fresh minimal headers only — never forward browser cookies, Authorization, @@ -317,6 +405,14 @@ async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] ); } + // Export previews are streamed (CSV / EPCIS Preview), not JSON. + if (contract === "export" && upstream.ok) { + return new NextResponse(upstream.body, { + status: upstream.status, + headers: responseHeaders(contract, upstream), + }); + } + if (!isJsonResponse(upstream)) { return NextResponse.json( { @@ -333,13 +429,15 @@ async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] } const payload = await upstream.json().catch(() => null); - return NextResponse.json(payload, { + // Strip tenant-like keys from reflected upstream JSON before anonymous callers see it. + const sanitized = stripTenantLike(payload); + return NextResponse.json(sanitized, { status: upstream.status, headers: responseHeaders(contract, upstream), }); } catch (error) { const correlationId = crypto.randomUUID(); - console.error("[inflow-lab-demo] Proxy request failed", { correlationId, error }); + console.error("[inflow-lab-public] Proxy request failed", { correlationId, error }); return NextResponse.json( { ok: false, @@ -353,9 +451,9 @@ async function proxyDemo(request: NextRequest, params: Promise<{ path?: string[] } export function GET(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { - return proxyDemo(request, context.params); + return proxyPublic(request, context.params); } export function POST(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { - return proxyDemo(request, context.params); + return proxyPublic(request, context.params); } diff --git a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx index 4c8d796af..307f82150 100644 --- a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx +++ b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx @@ -307,7 +307,7 @@ async function inflowJson(path: string, init?: RequestInit): Promise { if (!headers.has("content-type")) { headers.set("content-type", "application/json"); } - // Mutating /api/inflow-lab-demo calls are CSRF-protected by proxy.ts + the demo route; match workbenchJson. + // Mutating /api/inflow-lab-demo calls are CSRF-protected by proxy.ts + the public route; match workbenchJson. const response = await fetchWithCsrf(servicePath(path), { cache: "no-store", ...init, @@ -1306,7 +1306,7 @@ export function InflowLabClient({ setActiveTab(isStandalone ? "Event log" : "Record log"); }; - // Rebuild steps each render (avoids react-hooks/refs on a demo callback). + // Rebuild steps each render (avoids react-hooks/refs on a tour callback). // Tutorial measures against currentStep + step.target, not step object // identity, so a fresh steps array no longer re-fires scrollIntoView. const tutorialSteps = buildInflowLabClientTutorialSteps({ @@ -2252,7 +2252,7 @@ export function InflowLabClient({

Replay and regression testing

- Scenarios are reusable preflight inputs for demos, supplier onboarding, implementation training, and contract regression checks before rules or mappings change. + Scenarios are reusable preflight inputs for walkthroughs, supplier onboarding, implementation training, and contract regression checks before rules or mappings change.

@@ -2646,7 +2646,7 @@ export function InflowLabClient({
-

Test EPCIS preview

+

Test EPCIS Preview

Uses the same lot and date filters for interoperability testing. It is not production evidence.

From 5bb0c3eebd017199e0e3198ba7f19089ca6c5397 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:24:22 -0800 Subject: [PATCH 4/4] fix(inflow-lab-demo): strip tenant fields from export responses Buffer JSON and JSON-LD export preview bodies with a size cap, run them through stripTenantLike before reflecting to anonymous callers, and leave CSV and other non-JSON exports streaming unchanged. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../api/inflow-lab-demo-proxy.test.ts | 43 +++++++++++++++++++ .../api/inflow-lab-demo/[...path]/route.ts | 42 +++++++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts index 3597ff755..4c333fe4c 100644 --- a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts +++ b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts @@ -233,6 +233,49 @@ describe('Inflow Lab public proxy', () => { expect(await epcis.text()).toBe(''); }); + it('strips tenant-like fields from JSON / JSON-LD export responses', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + '@context': 'https://ref.gs1.org/epcis/epcis-context.jsonld', + type: 'EPCISDocument', + tenant_id: 'should-vanish', + epcisBody: { + eventList: [ + { + type: 'ObjectEvent', + tenantId: 'also-vanish', + customer_id: 'c1', + bizStep: 'shipping', + }, + ], + }, + }), + { + status: 200, + headers: { 'content-type': 'application/ld+json' }, + }, + ), + ); + + const response = await GET( + makeRequest({ + url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/epcis?traceability_lot_code=L1', + }), + { params: Promise.resolve({ path: ['api', 'regengine', 'export', 'epcis'] }) }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toMatch(/json/i); + await expect(response.json()).resolves.toEqual({ + '@context': 'https://ref.gs1.org/epcis/epcis-context.jsonld', + type: 'EPCISDocument', + epcisBody: { + eventList: [{ type: 'ObjectEvent', bizStep: 'shipping' }], + }, + }); + }); + it('rejects cross-origin requests', async () => { const response = await GET( makeRequest({ diff --git a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts index 2b660065d..c299f74b1 100644 --- a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts +++ b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts @@ -11,8 +11,9 @@ export const runtime = "nodejs"; const DEFAULT_LOCAL_INFLOW_LAB_URL = "http://127.0.0.1:8000"; const ALLOWED_METHODS = new Set(["GET", "POST"]); -const JSON_CONTENT_TYPES = ["application/json", "application/problem+json"]; +const JSON_CONTENT_TYPES = ["application/json", "application/problem+json", "application/ld+json"]; const MAX_BODY_BYTES = 32 * 1024; +const MAX_EXPORT_JSON_BYTES = 2 * 1024 * 1024; const TENANT_LIKE_KEY = /^(tenant(_?id)?|tenantId|customer(_?id)?|customerId|organization(_?id)?|org_id|orgId)$/i; const TRAVERSAL_ENCODED = /%2e|%2f|%5c/i; const SEGMENT_ALLOW = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; @@ -405,8 +406,45 @@ async function proxyPublic(request: NextRequest, params: Promise<{ path?: string ); } - // Export previews are streamed (CSV / EPCIS Preview), not JSON. + // Export previews: CSV/XML stream as-is. JSON / JSON-LD is buffered + // (size-capped) and run through stripTenantLike before reflection. if (contract === "export" && upstream.ok) { + if (isJsonResponse(upstream)) { + const raw = await upstream.arrayBuffer(); + if (raw.byteLength > MAX_EXPORT_JSON_BYTES) { + return NextResponse.json( + { + ok: false, + error: "Export response too large", + code: "EXPORT_TOO_LARGE", + }, + { status: 502, headers: responseHeaders(contract) }, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8").decode(raw)) as unknown; + } catch { + return NextResponse.json( + { + ok: false, + error: "Upstream returned invalid JSON export", + code: "UPSTREAM_BAD_RESPONSE", + }, + { status: 502, headers: responseHeaders(contract) }, + ); + } + const sanitized = stripTenantLike(parsed); + const headers = responseHeaders(contract, upstream); + // Preserve upstream JSON / JSON-LD content-type when present. + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return new NextResponse(JSON.stringify(sanitized), { + status: upstream.status, + headers, + }); + } return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders(contract, upstream),