Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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: <value>
# 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)
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---
Expand Down Expand Up @@ -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).

---

Expand Down
145 changes: 54 additions & 91 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,141 +1,104 @@
# 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).

---

## Executive summary

| 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 | **Lowmedium** | 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.
25 changes: 25 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading