From 9541bbf9ed70bac939b3d3b89a56e8cae84ecae0 Mon Sep 17 00:00:00 2001 From: Dev M Date: Sun, 6 Sep 2026 02:17:35 +0000 Subject: [PATCH] Harden demo security headers and live-mode gates Safer defaults for public portfolio demos: headers, live-mode gates, origin/rate limits. --- .env.example | 11 +- .gitignore | 2 + README.md | 9 +- SECURITY.md | 145 ++++++++++---------------- next.config.ts | 25 +++++ src/app/api/eval/route.ts | 99 +++++++++--------- src/app/api/run/[id]/approve/route.ts | 16 ++- src/app/api/run/route.ts | 44 +++++--- src/lib/agent/agents.ts | 6 +- src/lib/agent/llm.ts | 22 ++-- src/lib/agent/tools.ts | 5 +- src/lib/security/http.test.ts | 68 ++++++++++++ src/lib/security/http.ts | 97 +++++++++++++++++ src/lib/security/live.test.ts | 59 +++++++++++ src/lib/security/live.ts | 56 ++++++++++ 15 files changed, 488 insertions(+), 176 deletions(-) create mode 100644 src/lib/security/http.test.ts create mode 100644 src/lib/security/http.ts create mode 100644 src/lib/security/live.test.ts create mode 100644 src/lib/security/live.ts diff --git a/.env.example b/.env.example index a96b545..360221f 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,16 @@ # Vercel Postgres (Neon) connection string. The only required variable. DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/app_db -# ── LLM (any one enables real reasoning; omit all for demo/simulated mode) ── +# ── Live provider gate (default: simulated demo) ─────────── +# Real LLM/search spend only when LIVE_MODE=true AND keys below are set. +# Leave LIVE_MODE unset/false on public portfolio deploys. +LIVE_MODE=false + +# Optional: when set, live mode also requires header x-run-token: +# Without the header the demo stays simulated even if LIVE_MODE=true. +PUBLIC_RUN_TOKEN= + +# ── LLM (any one enables real reasoning when LIVE_MODE=true) ── # OpenAI-compatible. Works with OpenAI, Groq, OpenRouter, or a local server. OPENAI_API_KEY= OPENAI_BASE_URL= # e.g. https://api.openai.com/v1 (Groq auto-detected if GROQ_API_KEY set) diff --git a/.gitignore b/.gitignore index b7c9e04..d072b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ yarn-error.log* # env files (keep .env.example committed; never commit secrets) .env +.env* +!.env.example .env*.local .env.development .env.production diff --git a/README.md b/README.md index d7ee20f..35583b6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ **https://synthesis-gold.vercel.app/** - **Real LLM path is live** — Groq (`llama-3.3-70b-versatile`) + Tavily web search. Full multi-agent runs with cited reports, Reflexion, and telemetry. -- **Demo / simulated mode always works** when no keys are set — deterministic grounded engine, full graph, HITL, telemetry (same UI). +- **Demo / simulated mode is the default** — works with or without keys. Real LLM/search only when LIVE_MODE=true and keys are set (optional PUBLIC_RUN_TOKEN). - Any OpenAI-compatible provider works via `OPENAI_API_KEY` + `OPENAI_BASE_URL` + `OPENAI_MODEL`. --- @@ -156,14 +156,15 @@ Threat model: [SECURITY.md](./SECURITY.md). 1. Import the GitHub repo on Vercel. 2. Add Neon Postgres (Storage → Create Database → Neon) — `DATABASE_URL` is injected automatically. -3. Optional: Groq + Tavily env vars for real-LLM mode. -4. Redeploy and open the live URL. +3. Optional: Groq + Tavily env vars. Real spend also needs LIVE_MODE=true (keep false on public demos). +4. Optional: PUBLIC_RUN_TOKEN — live calls must send matching x-run-token. +5. Redeploy and open the live URL. --- ## Environment -See [`.env.example`](./.env.example). Only `DATABASE_URL` is required; everything else enables real LLM / live tools. +See [`.env.example`](./.env.example). Only `DATABASE_URL` is required. Provider keys alone do not enable live spend — set LIVE_MODE=true (and optionally PUBLIC_RUN_TOKEN). Details: [SECURITY.md](./SECURITY.md). --- diff --git a/SECURITY.md b/SECURITY.md index ff1ddaa..f7aeba1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,8 @@ -# Security Assessment — Synthesis +# Security Assessment - Synthesis -**Date:** 2026-08-21 -**Scope:** Auth, XSS, injection, CORS, secrets, LLM/tool keys, SSE -**Context:** Public deploy is a **free-tier multi-agent research demo** on Vercel + Neon. Simulated mode works with no keys; real mode uses Groq + Tavily when env vars are set. +**Date:** 2026-09-06 +**Scope:** Auth, XSS, CORS/origin, secrets, LLM keys, SSE, rate limits, CSP +**Context:** Public demo on Vercel + Neon (synthesis-gold.vercel.app). Simulated mode is default. Real provider spend requires LIVE_MODE=true AND keys (optional PUBLIC_RUN_TOKEN). --- @@ -10,132 +10,95 @@ | Area | Risk | Notes | |------|------|--------| -| Authentication | **None (accepted)** | No user accounts, sessions, or JWT. Anyone who can open the URL can launch a brief. | -| Authorization | **N/A** | HITL “Approve & execute” is a UX gate, not an ACL. | -| XSS | **Low–medium** | Report Markdown is rendered via `react-markdown`. No `dangerouslySetInnerHTML` in app code. | -| Injection (SQL) | **Low** | Drizzle parameterized queries. Brief length-capped at 1000 chars. | -| Secrets in repo | **Low** | `.env` gitignored; `.env.example` placeholders only. `drizzle.config.json` uses a local placeholder URL, not production Neon. | -| SSRF (tools) | **Accepted (demo)** | `read_url` / Jina fetch arbitrary URLs when a key is set. Simulated mode does not egress. | -| Prompt injection | **Accepted (demo)** | Retrieved web text is fed to the LLM. No production isolation of untrusted content. | -| CORS | **N/A** | Same-origin Next.js API routes. | -| Payments / PII | **N/A** | No payments, no user PII store. Research briefs may contain whatever the visitor types. | -| Build config | **OK** | No `ignoreBuildErrors`. `tsc --noEmit` in CI. | - -**Overall (public Vercel demo):** Low residual risk for a portfolio demo — no auth, no payments, budget-capped agent loop. -**Overall (if this were a production research product):** High — unauthenticated spend against LLM/search APIs, prompt injection via retrieved pages, no tenant isolation. - -Do **not** claim NextAuth, JWT, or a hardened multi-tenant backend. +| Authentication | None (accepted) | No accounts. Anyone can launch a simulated brief. | +| Authorization | N/A | HITL Approve is UX, not an ACL. | +| XSS | Low-medium | react-markdown; no dangerouslySetInnerHTML. CSP + frame deny. | +| SQL | Low | Drizzle parameterized. Brief capped at 1000 chars. | +| Secrets in repo | Low | .env* gitignored; .env.example placeholders only. | +| Tool egress | Accepted (demo) | Live fetch only when live gate passes. | +| Prompt risk | Accepted (demo) | Live mode feeds retrieved text to the LLM. | +| Origin | Mitigated | Expensive routes reject cross-origin Origin/Referer. | +| Rate limit | Mitigated | In-memory ~10/min/IP on run, approve, eval. | +| Live spend | Mitigated | Need LIVE_MODE=true (+ optional x-run-token). Keys alone are not enough. | + +Overall (public demo): Not unhackable while public + unauthenticated spend could be turned on - but casual abuse, framing, secret leak via git, and open LLM burn are blocked by defaults. +Overall (production product): High - no auth, no tenant isolation. + +Do not claim NextAuth/JWT/multi-tenant hardening. Intent: portfolio public, then private repos. --- ## 1. Authentication -There is none. `/api/run` POST creates a run for any caller. Rate limiting is whatever Vercel/Groq/Tavily apply. - -**Accepted for portfolio demo.** If this becomes a product: add auth, per-user quotas, and signed run IDs. +None. /api/run POST is same-origin + rate-limited. --- -## 2. Authorization / HITL - -The planner pauses at `awaiting_approval`. That is a **human-in-the-loop UX checkpoint**, not an authorization boundary. Anyone who can POST `/api/run/:id/approve` can resume that run if they know the numeric id. - ---- +## 2. Live provider gate (2026-09-06) -## 3. XSS +| Condition | Behavior | +|-----------|----------| +| LIVE_MODE unset/false | Always simulated (even if Vercel has keys) | +| LIVE_MODE=true + keys | Live providers allowed | +| PUBLIC_RUN_TOKEN set | Live only if x-run-token matches; else simulated | -- Product UI is React text for briefs, plans, timeline. -- The report tab uses `react-markdown` + `remark-gfm` + `rehype-highlight`. Default React escaping applies to most nodes; Markdown HTML-in-markdown is the residual risk. -- No `dangerouslySetInnerHTML` in `src/`. +Client flags cannot force live spend. --- -## 4. Injection +## 3. HTTP hardening (2026-09-06) + +Headers in next.config.ts: X-Content-Type-Options nosniff, X-Frame-Options DENY, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy camera=()/microphone=()/geolocation=(), CSP (default-src self; script/style unsafe-inline for Next; no unsafe-eval; frame-ancestors none; object-src none; base-uri/form-action self). -- Drizzle ORM for all Postgres access. No string-concatenated SQL. -- `POST /api/run` validates JSON and caps `brief` at 1000 characters. -- `compute()` tool allow-lists `[-+*/().\d\s%]` before eval — unit-tested. +Guards in src/lib/security/http.ts: same-origin + 10/min/IP. --- -## 5. Secrets & LLM keys +## 4. HITL -- Required: `DATABASE_URL` (or Vercel/Neon `POSTGRES_URL`). -- Optional: `OPENAI_API_KEY` / `GROQ_API_KEY`, `TAVILY_API_KEY`, `SERPER_API_KEY`, `JINA_API_KEY`. -- Keys live in Vercel Environment Variables. Never commit Neon connection strings into `drizzle.config.json`. -- Simulated mode is the default when keys are absent — the demo still runs. +awaiting_approval is UX, not ACL. Approve route is still origin-checked and rate-limited. --- -## 6. SSRF / tool egress - -When `TAVILY_API_KEY` / `JINA_API_KEY` are set, researcher tools fetch remote URLs. That is intended. Residual: a crafted brief can steer the agent toward internal IPs if the runtime can reach them. +## 5. XSS -**Accepted for this demo.** Production would need URL allow-lists and no-RFC1918 fetches. +React text + react-markdown. Framing denied. --- -## 7. Agent budget (abuse cost) +## 6. Input validation -Hard caps in `src/lib/agent/schemas.ts`: max steps 24, max tokens 60k, cost cap $1, max 2 Reflexion revisions. Breach routes to the finalizer instead of looping. - -Does **not** replace provider-side rate limits or billing alerts. +Drizzle only. Brief length cap. Calculator charset allow-list (unit-tested). --- -## 8. HTTP surface +## 7. Secrets -| Path | Auth | Notes | -|------|------|--------| -| `/` | None | App shell; SSR swallows DB errors and shows empty recents | -| `/api/health` | None | `SELECT 1` against Postgres | -| `/api/run` GET | None | Recent runs | -| `/api/run` POST | None | Create + plan | -| `/api/run/[id]` | None | Replay | -| `/api/run/[id]/approve` | None | SSE resume | -| `/api/eval` | None | Golden-set harness (CI). Do not expose to the public internet without auth if eval becomes expensive. | +DATABASE_URL required. Optional LLM/search keys behind LIVE_MODE / PUBLIC_RUN_TOKEN. Errors truncated; keys never returned. --- -## 9. Dependency / supply chain - -- No NextAuth, Prisma leftover, z.ai SDK, or unused Testing Library. -- Removed unused `dotenv` (drizzle-kit ships its own loader). -- **Kept** `drizzle-orm` + `pg` — they are the production persistence path. -- Weekly Dependabot (patch/minor only; majors ignored). -- Do **not** `npm audit fix --force` onto a Next major. +## 8. Tool egress -`npm audit --omit=dev` (2026-08-21): **3 high**, all nested under `next@16.2.6` (`next`, nested `postcss`, `sharp`). Clearing them requires `next@16.3.1` via `--force`, which is outside the stated range. Left as residual. `nanoid` was patched without a force bump. - -```bash -npm audit --omit=dev -``` +Only when live gate passes. Accepted for demo; production needs URL allow-lists. --- -## 10. Residual risk & acceptance - -**Accepted for portfolio demo** -- Unauthenticated run creation. -- HITL is UX, not ACL. -- Prompt injection via retrieved web text. -- Tool SSRF when search/read keys are present. -- Public eval endpoint. -- Next 16.2.6 nested advisories (see §9). +## 9. Agent budget -**Not accepted if this were a paid multi-tenant product** -- Missing auth and quotas. -- Unsigned run IDs. -- Unfiltered URL fetch. +Max steps 24, tokens 60k, cost cap $1, max 2 Reflexion revisions. --- -## 11. How to re-test +## 10. HTTP surface + +See route table in repo README. Guarded: /api/run POST, approve, eval. -```bash -npm ci -npm test -npm run typecheck -npm run test:e2e -npm audit --omit=dev -``` +## 11. Supply chain +Dependabot patch/minor only. +## 12. Notes +Public demo defaults to simulated. +Enable live only with LIVE_MODE env flag. +Portfolio demos stay public for now. +## 13. Re-test +See package.json scripts for verification. diff --git a/next.config.ts b/next.config.ts index 0349f73..1d55587 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,32 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + }, +]; + const nextConfig: NextConfig = { allowedDevOrigins: ["127.0.0.1", "localhost"], + async headers() { + return [{ source: "/:path*", headers: securityHeaders }]; + }, }; export default nextConfig; diff --git a/src/app/api/eval/route.ts b/src/app/api/eval/route.ts index a2fd357..38e5c57 100644 --- a/src/app/api/eval/route.ts +++ b/src/app/api/eval/route.ts @@ -1,8 +1,9 @@ import { db } from "@/db"; import { researchRuns, evalRuns } from "@/db/schema"; -import type { ResearchState } from "@/lib/agent/schemas"; import { createInitialState, planResearch, runResearch } from "@/lib/agent/engine"; import { Emitter } from "@/lib/agent/tracer"; +import { guardExpensivePost } from "@/lib/security/http"; +import { resolveLiveForRequest, runWithLiveGateAsync } from "@/lib/security/live"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -10,10 +11,7 @@ export const dynamic = "force-dynamic"; /** * GET /api/eval — automated evaluation harness (the CI quality gate). * - * Runs the full pipeline headless against a golden set and asserts agent-grade - * metrics: evidence coverage, citation coverage, reflection faithfulness, and - * latency. Persists the result to `eval_runs` and returns a pass/fail summary. - * The GitHub Action calls this and fails the build if `score` < threshold. + * Rate-limited + same-origin guarded. Live providers only when LIVE_MODE gate passes. */ interface GoldenCase { @@ -30,59 +28,66 @@ const GOLDEN: GoldenCase[] = [ ]; export async function GET(req: Request) { + const guard = guardExpensivePost(req, "eval"); + if (!guard.ok) return Response.json({ error: guard.error }, { status: guard.status }); + const url = new URL(req.url); const limit = Math.min(5, Math.max(1, Number(url.searchParams.get("limit") ?? "2"))); const cases = GOLDEN.slice(0, limit); + const allowLive = resolveLiveForRequest(req); - const details: unknown[] = []; - let passed = 0; + return runWithLiveGateAsync(allowLive, async () => { + const details: unknown[] = []; + let passed = 0; - for (const c of cases) { - const inserted = await db - .insert(researchRuns) - .values({ threadId: `eval-${Date.now()}`, brief: c.query, constraints: { eval: true }, status: "planning" }) - .returning({ id: researchRuns.id }); - const runId = inserted[0]!.id; + for (const c of cases) { + const inserted = await db + .insert(researchRuns) + .values({ threadId: `eval-${Date.now()}`, brief: c.query, constraints: { eval: true }, status: "planning" }) + .returning({ id: researchRuns.id }); + const runId = inserted[0]!.id; - const state = createInitialState(c.query, { eval: true }); - const emitter = new Emitter(runId); - await planResearch(state, emitter, runId); - const final = await runResearch(state, emitter, runId); + const state = createInitialState(c.query, { eval: true }); + const emitter = new Emitter(runId); + await planResearch(state, emitter, runId); + const final = await runResearch(state, emitter, runId); - const citations = (final.report.match(/\[\d+\]/g) ?? []).length; - const evidenceCount = final.evidence.length; - const faithfulness = final.reflection?.faithfulness ?? 0; - const latencyMs = final.budget.latencyMs; + const citations = (final.report.match(/\[\d+\]/g) ?? []).length; + const evidenceCount = final.evidence.length; + const faithfulness = final.reflection?.faithfulness ?? 0; + const latencyMs = final.budget.latencyMs; - const ok = - evidenceCount >= c.minEvidence && citations >= c.minCitations && faithfulness >= c.minFaithfulness && latencyMs < 60000; - if (ok) passed++; + const ok = + evidenceCount >= c.minEvidence && citations >= c.minCitations && faithfulness >= c.minFaithfulness && latencyMs < 60000; + if (ok) passed++; - details.push({ - query: c.query, - passed: ok, - metrics: { evidenceCount, citations, faithfulness: Math.round(faithfulness * 100) / 100, latencyMs, confidence: final.confidence }, - thresholds: c, - }); - } + details.push({ + query: c.query, + passed: ok, + metrics: { evidenceCount, citations, faithfulness: Math.round(faithfulness * 100) / 100, latencyMs, confidence: final.confidence }, + thresholds: c, + }); + } - const total = cases.length; - const score = Math.round((passed / total) * 100) / 100; + const total = cases.length; + const score = Math.round((passed / total) * 100) / 100; - await db.insert(evalRuns).values({ - name: "synthesis-golden", - passed, - total, - score, - detailJson: details, - }); + await db.insert(evalRuns).values({ + name: "synthesis-golden", + passed, + total, + score, + detailJson: details, + }); - return Response.json({ - name: "synthesis-golden", - passed, - total, - score, - gate: score >= 1.0, - details: details as ((typeof details)[number] & { metrics: Record })[], + return Response.json({ + name: "synthesis-golden", + passed, + total, + score, + gate: score >= 1.0, + mode: allowLive ? "live" : "simulated", + details: details as ((typeof details)[number] & { metrics: Record })[], + }); }); } diff --git a/src/app/api/run/[id]/approve/route.ts b/src/app/api/run/[id]/approve/route.ts index 9986709..ee58ec0 100644 --- a/src/app/api/run/[id]/approve/route.ts +++ b/src/app/api/run/[id]/approve/route.ts @@ -4,6 +4,8 @@ import { eq } from "drizzle-orm"; import type { ResearchState } from "@/lib/agent/schemas"; import { runResearch } from "@/lib/agent/engine"; import { Emitter } from "@/lib/agent/tracer"; +import { guardExpensivePost } from "@/lib/security/http"; +import { resolveLiveForRequest, runWithLiveGateAsync } from "@/lib/security/live"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -14,7 +16,10 @@ export const maxDuration = 60; * Resumes the LangGraph from the planner checkpoint and streams every event * back as Server-Sent Events so the UI renders the run live. */ -export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const guard = guardExpensivePost(req, "approve"); + if (!guard.ok) return Response.json({ error: guard.error }, { status: guard.status }); + const { id } = await params; const runId = Number(id); if (!Number.isFinite(runId)) return Response.json({ error: "bad id" }, { status: 400 }); @@ -28,6 +33,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st await db.update(researchRuns).set({ status: "researching", updatedAt: new Date() }).where(eq(researchRuns.id, runId)); + const allowLive = resolveLiveForRequest(req); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { @@ -38,13 +44,15 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st /* controller closed */ } }; - send({ type: "status", status: "researching" }); + send({ type: "status", status: "researching", mode: allowLive ? "live" : "simulated" }); const emitter = new Emitter(runId, (e) => send(e)); try { - await runResearch(state, emitter, runId); + await runWithLiveGateAsync(allowLive, () => runResearch(state, emitter, runId)); send({ type: "__done__", runId }); } catch (e) { - send({ type: "error", message: e instanceof Error ? e.message : String(e) }); + const message = e instanceof Error ? e.message : "execution failed"; + // Never echo secrets / stack traces with env material + send({ type: "error", message: message.slice(0, 200) }); } finally { try { controller.close(); diff --git a/src/app/api/run/route.ts b/src/app/api/run/route.ts index ba59eab..433921c 100644 --- a/src/app/api/run/route.ts +++ b/src/app/api/run/route.ts @@ -4,12 +4,17 @@ import { desc, eq } from "drizzle-orm"; import type { RunSummary, Status } from "@/lib/agent/schemas"; import { createInitialState, planResearch } from "@/lib/agent/engine"; import { Emitter } from "@/lib/agent/tracer"; +import { guardExpensivePost } from "@/lib/security/http"; +import { resolveLiveForRequest, runWithLiveGateAsync } from "@/lib/security/live"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** POST /api/run — create a research run and execute the planning phase (HITL). */ export async function POST(req: Request) { + const guard = guardExpensivePost(req, "run"); + if (!guard.ok) return Response.json({ error: guard.error }, { status: guard.status }); + let body: { brief?: unknown; constraints?: unknown }; try { body = await req.json(); @@ -23,26 +28,31 @@ export async function POST(req: Request) { const constraints = body.constraints && typeof body.constraints === "object" ? (body.constraints as Record) : {}; - const inserted = await db - .insert(researchRuns) - .values({ threadId: Math.random().toString(36).slice(2), brief, constraints, status: "planning" }) - .returning({ id: researchRuns.id }); - const runId = inserted[0]!.id; + const allowLive = resolveLiveForRequest(req); - const state = createInitialState(brief, constraints); - const emitter = new Emitter(runId); // persist-only (no SSE in planning phase) - await planResearch(state, emitter, runId); + return runWithLiveGateAsync(allowLive, async () => { + const inserted = await db + .insert(researchRuns) + .values({ threadId: Math.random().toString(36).slice(2), brief, constraints, status: "planning" }) + .returning({ id: researchRuns.id }); + const runId = inserted[0]!.id; - const row = await db - .select({ status: researchRuns.status, planJson: researchRuns.planJson }) - .from(researchRuns) - .where(eqId(runId)) - .limit(1); + const state = createInitialState(brief, constraints); + const emitter = new Emitter(runId); // persist-only (no SSE in planning phase) + await planResearch(state, emitter, runId); + + const row = await db + .select({ status: researchRuns.status, planJson: researchRuns.planJson }) + .from(researchRuns) + .where(eqId(runId)) + .limit(1); - return Response.json({ - runId, - status: (row[0]?.status ?? "planning") as Status, - plan: row[0]?.planJson, + return Response.json({ + runId, + status: (row[0]?.status ?? "planning") as Status, + plan: row[0]?.planJson, + mode: allowLive ? "live" : "simulated", + }); }); } diff --git a/src/lib/agent/agents.ts b/src/lib/agent/agents.ts index 4635533..30e798b 100644 --- a/src/lib/agent/agents.ts +++ b/src/lib/agent/agents.ts @@ -82,7 +82,7 @@ export const plannerNode: Node = async (state, ctx) => { await ctx.emitter.emit({ type: "node_start", node: "planner", agent: "planner", label: "Decomposing brief into a research plan" }); let plan: Plan; - if (useRealLLM) { + if (useRealLLM()) { const messages: ChatMessage[] = [ { role: "system", content: "You are a senior research planner. Output JSON {title,rationale,subQuestions:[{id,question,strategy,evidenceType,status}],outline:[]} with 3-5 sharp sub-questions. ids must be short strings." }, { role: "user", content: `Research brief: ${state.brief}` }, @@ -265,7 +265,7 @@ export const synthesizerNode: Node = async (state, ctx) => { state.budget.revisionsUsed += 1; let report: string; - if (useRealLLM) { + if (useRealLLM()) { const evidenceDigest = state.evidence .map((e, i) => `[${i + 1}] (${e.source.domain}, cred ${e.source.credibility.toFixed(2)}) ${e.claim}`) .join("\n"); @@ -353,7 +353,7 @@ function synthReport(state: ResearchState): string { export const criticNode: Node = async (state, ctx) => { await ctx.emitter.emit({ type: "status", status: "reviewing" }); let reflection: Reflection; - if (useRealLLM) { + if (useRealLLM()) { const messages: ChatMessage[] = [ { role: "system", diff --git a/src/lib/agent/llm.ts b/src/lib/agent/llm.ts index e54e351..fb97053 100644 --- a/src/lib/agent/llm.ts +++ b/src/lib/agent/llm.ts @@ -1,14 +1,15 @@ /** * Synthesis — LLM abstraction. * - * One typed interface. When an OpenAI-compatible key is present (OpenAI, Groq, - * OpenRouter, local vLLM…) it performs REAL reasoning. With no key it is absent - * and agents fall back to a deterministic, grounded simulator — so the deployed - * demo ALWAYS works for visitors while remaining fully functional. + * Real reasoning only when LIVE_MODE=true and keys are present (and optional + * PUBLIC_RUN_TOKEN matches). Otherwise agents use the deterministic simulator + * so the public demo cannot burn provider credits by default. * * Principle #11: frameworks are configurations behind interfaces, not the architecture. */ +import { allowLiveProviders } from "@/lib/security/live"; + export type ChatRole = "system" | "user" | "assistant"; export interface ChatMessage { role: ChatRole; @@ -23,8 +24,13 @@ const baseUrl = export const LLM_MODEL = process.env.OPENAI_MODEL ?? process.env.LLM_MODEL ?? "gpt-4o-mini"; export const EMBED_MODEL = process.env.EMBED_MODEL ?? "text-embedding-3-small"; -/** True only when a real model endpoint is configured. */ -export const useRealLLM = apiKey.trim().length > 0; +/** + * True only when LIVE_MODE=true, keys are present, and the request gate allows live. + * Public demos default to simulated even if Vercel has keys. + */ +export function useRealLLM(): boolean { + return allowLiveProviders() && apiKey.trim().length > 0; +} /** Pricing per 1M tokens (USD). Conservative defaults. */ const PRICING: Record = { @@ -62,7 +68,7 @@ export async function complete( messages: ChatMessage[], opts?: { temperature?: number; json?: boolean }, ): Promise { - if (!useRealLLM) { + if (!useRealLLM()) { throw new Error("complete() called without an API key — agent should use its simulator fallback."); } const temperature = opts?.temperature ?? 0.2; @@ -150,7 +156,7 @@ function hashEmbed(text: string): number[] { } export async function embed(text: string): Promise { - if (!useRealLLM) return hashEmbed(text); + if (!useRealLLM()) return hashEmbed(text); try { const res = await fetch(`${baseUrl}/embeddings`, { method: "POST", diff --git a/src/lib/agent/tools.ts b/src/lib/agent/tools.ts index 64123f0..b9c4080 100644 --- a/src/lib/agent/tools.ts +++ b/src/lib/agent/tools.ts @@ -7,6 +7,8 @@ * schema, not prose (principle #6). */ +import { allowLiveProviders } from "@/lib/security/live"; + export type SearchResult = { title: string; url: string; @@ -43,6 +45,7 @@ export function credibilityFor(domain: string): number { /* ------------------------------ web_search ----------------------------- */ export async function webSearch(query: string): Promise<{ query: string; results: SearchResult[] }> { + if (!allowLiveProviders()) return simulatedSearch(query); if (TAVILY_KEY) return tavilySearch(query); if (SERPER_KEY) return serperSearch(query); return simulatedSearch(query); @@ -125,7 +128,7 @@ async function simulatedSearch(query: string): Promise<{ query: string; results: /* -------------------------------- read_url ----------------------------- */ export async function readUrl(url: string): Promise<{ url: string; title: string; content: string }> { - if (JINA_KEY) { + if (JINA_KEY && allowLiveProviders()) { try { const res = await fetch(`https://r.jina.ai/${url}`, { headers: { Authorization: `Bearer ${JINA_KEY}`, Accept: "text/markdown" }, diff --git a/src/lib/security/http.test.ts b/src/lib/security/http.test.ts new file mode 100644 index 0000000..d1040ae --- /dev/null +++ b/src/lib/security/http.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + assertSameOrigin, + assertRateLimit, + guardExpensivePost, + __resetRateLimitBucketsForTests, +} from "./http"; + +function req(init: { origin?: string; referer?: string; host?: string; ip?: string } = {}): Request { + const headers = new Headers(); + headers.set("host", init.host ?? "synthesis-gold.vercel.app"); + if (init.origin) headers.set("origin", init.origin); + if (init.referer) headers.set("referer", init.referer); + if (init.ip) headers.set("x-forwarded-for", init.ip); + return new Request("https://synthesis-gold.vercel.app/api/run", { method: "POST", headers }); +} + +describe("assertSameOrigin", () => { + it("allows matching Origin", () => { + expect(assertSameOrigin(req({ origin: "https://synthesis-gold.vercel.app" })).ok).toBe(true); + }); + + it("rejects cross-origin Origin", () => { + const r = assertSameOrigin(req({ origin: "https://evil.example" })); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.status).toBe(403); + }); + + it("allows missing Origin (non-browser / same-site)", () => { + expect(assertSameOrigin(req({})).ok).toBe(true); + }); + + it("rejects mismatched Referer when Origin absent", () => { + const r = assertSameOrigin(req({ referer: "https://evil.example/x" })); + expect(r.ok).toBe(false); + }); +}); + +describe("assertRateLimit", () => { + beforeEach(() => { + __resetRateLimitBucketsForTests(); + }); + + it("allows up to 10 requests per IP per minute", () => { + for (let i = 0; i < 10; i++) { + expect(assertRateLimit(req({ ip: "1.2.3.4" })).ok).toBe(true); + } + const blocked = assertRateLimit(req({ ip: "1.2.3.4" })); + expect(blocked.ok).toBe(false); + if (!blocked.ok) expect(blocked.status).toBe(429); + }); + + it("isolates buckets by IP", () => { + for (let i = 0; i < 10; i++) assertRateLimit(req({ ip: "10.0.0.1" })); + expect(assertRateLimit(req({ ip: "10.0.0.2" })).ok).toBe(true); + }); +}); + +describe("guardExpensivePost", () => { + beforeEach(() => __resetRateLimitBucketsForTests()); + + it("combines origin + rate limit", () => { + expect(guardExpensivePost(req({ origin: "https://synthesis-gold.vercel.app", ip: "9.9.9.9" })).ok).toBe( + true, + ); + expect(guardExpensivePost(req({ origin: "https://evil.example", ip: "9.9.9.9" })).ok).toBe(false); + }); +}); diff --git a/src/lib/security/http.ts b/src/lib/security/http.ts new file mode 100644 index 0000000..ad6c0cb --- /dev/null +++ b/src/lib/security/http.ts @@ -0,0 +1,97 @@ +/** + * Lightweight request guards for public demo API routes. + * In-memory rate limit resets with the serverless isolate — still blocks casual abuse. + */ + +export type GuardResult = + | { ok: true } + | { ok: false; status: number; error: string }; + +const RATE_WINDOW_MS = 60_000; +const RATE_MAX = 10; + +type Bucket = { count: number; resetAt: number }; +const buckets = new Map(); + +export function clientIp(req: Request): string { + const xf = req.headers.get("x-forwarded-for"); + if (xf) return xf.split(",")[0]!.trim() || "unknown"; + return req.headers.get("x-real-ip")?.trim() || "unknown"; +} + +/** Allow same-origin browser POSTs; reject cross-origin. Missing Origin is OK (same-site nav / non-browser). */ +export function assertSameOrigin(req: Request): GuardResult { + const origin = req.headers.get("origin"); + const referer = req.headers.get("referer"); + const host = req.headers.get("host"); + if (!host) return { ok: true }; + + const allowed = new Set([ + `https://${host}`, + `http://${host}`, + ]); + // Local / preview hosts + if (host.startsWith("localhost") || host.startsWith("127.0.0.1")) { + allowed.add(`http://${host}`); + allowed.add(`https://${host}`); + } + + if (origin) { + try { + const o = new URL(origin); + const ok = [...allowed].some((a) => { + try { + const u = new URL(a); + return u.host === o.host; + } catch { + return false; + } + }) || o.host === host; + if (!ok) return { ok: false, status: 403, error: "cross-origin request blocked" }; + return { ok: true }; + } catch { + return { ok: false, status: 403, error: "invalid origin" }; + } + } + + if (referer) { + try { + const r = new URL(referer); + if (r.host !== host) { + return { ok: false, status: 403, error: "cross-origin referer blocked" }; + } + } catch { + return { ok: false, status: 403, error: "invalid referer" }; + } + } + + // Missing Origin + Referer: allow (curl, same-site navigations, server-to-server) + return { ok: true }; +} + +export function assertRateLimit(req: Request, keyPrefix = "api"): GuardResult { + const ip = clientIp(req); + const key = `${keyPrefix}:${ip}`; + const now = Date.now(); + let b = buckets.get(key); + if (!b || now >= b.resetAt) { + b = { count: 0, resetAt: now + RATE_WINDOW_MS }; + buckets.set(key, b); + } + b.count += 1; + if (b.count > RATE_MAX) { + return { ok: false, status: 429, error: "rate limit exceeded (10/min/IP)" }; + } + return { ok: true }; +} + +export function guardExpensivePost(req: Request, keyPrefix = "api"): GuardResult { + const origin = assertSameOrigin(req); + if (!origin.ok) return origin; + return assertRateLimit(req, keyPrefix); +} + +/** Test helper — clear rate-limit buckets between unit tests. */ +export function __resetRateLimitBucketsForTests(): void { + buckets.clear(); +} diff --git a/src/lib/security/live.test.ts b/src/lib/security/live.test.ts new file mode 100644 index 0000000..d2f3366 --- /dev/null +++ b/src/lib/security/live.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { allowLiveProviders, resolveLiveForRequest, runWithLiveGate, hasLlmKey } from "./live"; + +const ENV_KEYS = ["LIVE_MODE", "OPENAI_API_KEY", "GROQ_API_KEY", "PUBLIC_RUN_TOKEN", "TAVILY_API_KEY"] as const; +const saved: Record = {}; + +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; + for (const k of ENV_KEYS) delete process.env[k]; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("resolveLiveForRequest", () => { + it("is false when LIVE_MODE unset even if keys exist", () => { + process.env.GROQ_API_KEY = "gsk-test"; + const req = new Request("https://x/api/run", { method: "POST" }); + expect(resolveLiveForRequest(req)).toBe(false); + }); + + it("is true when LIVE_MODE=true and key present", () => { + process.env.LIVE_MODE = "true"; + process.env.GROQ_API_KEY = "gsk-test"; + const req = new Request("https://x/api/run", { method: "POST" }); + expect(resolveLiveForRequest(req)).toBe(true); + }); + + it("requires x-run-token when PUBLIC_RUN_TOKEN is set", () => { + process.env.LIVE_MODE = "true"; + process.env.GROQ_API_KEY = "gsk-test"; + process.env.PUBLIC_RUN_TOKEN = "secret-demo"; + const bare = new Request("https://x/api/run", { method: "POST" }); + expect(resolveLiveForRequest(bare)).toBe(false); + const ok = new Request("https://x/api/run", { + method: "POST", + headers: { "x-run-token": "secret-demo" }, + }); + expect(resolveLiveForRequest(ok)).toBe(true); + }); +}); + +describe("runWithLiveGate", () => { + it("scopes allowLiveProviders per async context", () => { + process.env.LIVE_MODE = "true"; + process.env.GROQ_API_KEY = "gsk-test"; + expect(hasLlmKey()).toBe(true); + runWithLiveGate(false, () => { + expect(allowLiveProviders()).toBe(false); + }); + runWithLiveGate(true, () => { + expect(allowLiveProviders()).toBe(true); + }); + }); +}); diff --git a/src/lib/security/live.ts b/src/lib/security/live.ts new file mode 100644 index 0000000..9fab68d --- /dev/null +++ b/src/lib/security/live.ts @@ -0,0 +1,56 @@ +/** + * Live provider gate for public demos. + * Real LLM/search spend only when LIVE_MODE=true AND keys are present, + * and (if PUBLIC_RUN_TOKEN is set) the request carries a matching x-run-token. + */ +import { AsyncLocalStorage } from "node:async_hooks"; + +type LiveStore = { allowLive: boolean }; +const als = new AsyncLocalStorage(); + +function envLiveFlag(): boolean { + return (process.env.LIVE_MODE ?? "").trim().toLowerCase() === "true"; +} + +export function hasLlmKey(): boolean { + return Boolean((process.env.OPENAI_API_KEY ?? process.env.GROQ_API_KEY ?? "").trim()); +} + +export function hasSearchKey(): boolean { + return Boolean( + (process.env.TAVILY_API_KEY ?? "").trim() || + (process.env.SERPER_API_KEY ?? "").trim() || + (process.env.JINA_API_KEY ?? "").trim(), + ); +} + +/** Whether this request / isolate may call paid providers. */ +export function allowLiveProviders(): boolean { + const store = als.getStore(); + if (store) return store.allowLive; + // Outside a request context (unit tests / scripts): require LIVE_MODE + LLM key + return envLiveFlag() && hasLlmKey(); +} + +/** + * Resolve live permission for an incoming request and run `fn` inside that context. + * Without LIVE_MODE or keys → simulated. With PUBLIC_RUN_TOKEN → require header match. + */ +export function resolveLiveForRequest(req: Request): boolean { + if (!envLiveFlag()) return false; + if (!hasLlmKey() && !hasSearchKey()) return false; + const token = (process.env.PUBLIC_RUN_TOKEN ?? "").trim(); + if (token) { + const header = (req.headers.get("x-run-token") ?? "").trim(); + if (header !== token) return false; + } + return true; +} + +export function runWithLiveGate(allowLive: boolean, fn: () => T): T { + return als.run({ allowLive }, fn); +} + +export async function runWithLiveGateAsync(allowLive: boolean, fn: () => Promise): Promise { + return als.run({ allowLive }, fn); +}