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..4c333fe4c --- /dev/null +++ b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts @@ -0,0 +1,474 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +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'; + +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 = 'public-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 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-public-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', + 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 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, tenant_id: 'upstream-tenant' }), { + 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, + __Host_regengine_session: 'opaque-session', + re_access_token: 'should-not-forward', + }, + body: JSON.stringify({ + config: { + source: 'fixture', + 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); + // 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'); + 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(JSON.parse(String(init.body))).toEqual({ + config: { + source: 'fixture', + delivery: { mode: 'local' }, + }, + }); + }); + + 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/events' }), + { params: Promise.resolve({ path: ['api', 'events'] }) }, + ); + + 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('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({ + 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('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 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const trusted = { 'x-real-ip': '198.51.100.20', 'x-forwarded-for': '203.0.113.1' }; + const first = await GET( + makeRequest({ headers: trusted }), + { params: Promise.resolve({ path: ['api', 'healthz'] }) }, + ); + const second = await GET( + 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: { ...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); + 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 public 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(); + }); +}); + +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 new file mode 100644 index 000000000..c299f74b1 --- /dev/null +++ b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts @@ -0,0 +1,497 @@ +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", "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}$/; + +// 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" + | "export"; + +function serviceBaseUrl() { + return ( + process.env.INFLOW_LAB_SERVICE_URL || + DEFAULT_LOCAL_INFLOW_LAB_URL + ).replace(/\/$/, ""); +} + +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"; +} + +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(); +} + +/** + * 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) { + 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 { + 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; +} + +/** 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; + } + + // 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 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\/[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\/[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; +} + +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)); +} + +export 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-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 proxyPublic(request: NextRequest, params: Promise<{ path?: string[] }>) { + if (!publicProxyEnabled()) { + 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(trustedClientIp(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 ? 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, fixture loading, and export previews only.", + }, + { status: 404, headers: { "cache-control": "no-store" } }, + ); + } + + const bodyResult = await readSanitizedBody(request); + if (!bodyResult.ok) return bodyResult.response; + + const upstreamUrl = new URL(`${serviceBaseUrl()}${upstreamPathFor(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) }, + ); + } + + // 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), + }); + } + + 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); + // 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-public] Proxy request failed", { correlationId, error }); + return NextResponse.json( + { + ok: false, + error: "Inflow Lab service unavailable", + code: "SERVICE_UNAVAILABLE", + correlation_id: correlationId, + }, + { status: 502, headers: responseHeaders(contract) }, + ); + } +} + +export function GET(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { + return proxyPublic(request, context.params); +} + +export function POST(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { + 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 382880ded..307f82150 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 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.