v1.1.0:8 — admin-gate whole-DB routes + AI custom-URL providers; SSRF guard
Multi-user authorization hardening from a full security evaluation (EVALUATION.md):
- P0: /api/settings/{export,import}-db are now admin-only. Previously any signed-in user could download the whole instance DB (all bcrypt hashes + plaintext AI keys) or replace it wholesale. Per-user CSV export/import stays open.
- AI custom-URL providers (Ollama, OpenAI-compatible) are now admin-only, and every server fetch to a user-supplied URL passes through assertSafeProviderUrl (blocks link-local/cloud-metadata; private LAN allowed by design). Fixed-URL cloud providers stay per-user. Removed the dead legacy /api/ai/config route.
- Dev: fixed broken quick-start (added npm run create-admin; rewrote README; dropped dead CLAUDE_API_KEY) and the export-db 0-byte path resolution (resolveDatabasePath now matches Prisma).
ExVer bumped to 1.1.0:8 (no schema/data migration). Tests 197 pass, build green, tsc clean.
This commit is contained in:
@@ -47,6 +47,8 @@ npx vitest run tests/ai-pricing.test.ts # single file
|
||||
npx vitest run -t "findPrice" # single test by name
|
||||
npx tsc --noEmit # typecheck only
|
||||
npx prisma generate # REQUIRED after editing schema.prisma (else TS can't see new fields)
|
||||
npm run db:seed # seed InstanceSettings singleton (NO users/library — see below)
|
||||
npm run create-admin -- you@example.com pw "Name" # create first admin locally (--force resets existing)
|
||||
```
|
||||
|
||||
Build/sideload the s9pk (from `start9/0.4/`): `make x86` then `make install`. Targets come from `s9pk.mk` (the wrapper `Makefile` just sets `ARCHES := x86`):
|
||||
@@ -60,7 +62,7 @@ Both `install` and `publish` read host/registry config from `~/.startos/config.y
|
||||
|
||||
Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds, uploads to FileBrowser, registers) — separate from the generic `make publish`. Unpublish: `~/.proof-of-work/unpublish.sh`.
|
||||
|
||||
`npm run db:seed` (= `tsx prisma/seed.ts`) seeds the `InstanceSettings` singleton + curated library; it is **live, not dead** — invoked at Docker image-build time (`start9/0.4/Dockerfile`) to bake the library into the image, and also the local-dev first-run path (`proof-of-work/README.md`). Runtime first-boot/upgrade seeding is handled separately by `docker_entrypoint.sh`.
|
||||
`npm run db:seed` (= `tsx prisma/seed.ts`) seeds **only the `InstanceSettings` singleton** — deliberately NO users and NO curated library (the library attaches at admin-creation and via the boot-time ensure). It is **live, not dead** — invoked at Docker image-build time (`start9/0.4/Dockerfile`) and the local-dev first-run path. `npm run create-admin` (= `tsx scripts/create-admin.ts`) is the local-dev equivalent of the StartOS "Set admin credentials" action: creates the first admin + seeds their library; `--force` to reset/promote an existing account. Runtime first-boot/upgrade seeding is handled separately by `docker_entrypoint.sh`.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -69,6 +71,7 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
|
||||
- **Schema changes are additive ALTERs in `docker_entrypoint.sh`**, guarded by `PRAGMA table_info` checks. Keep `schema.prisma` in sync as the mirror, but the entrypoint is what migrates live `/data`. Never write a destructive migration.
|
||||
- **Commit subject** = `vX.Y.Z:N — short summary`, imperative, body explains the *why*.
|
||||
- **Git remote is self-hosted** (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in `~/.proof-of-work/{publish,unpublish}.sh`; FileBrowser creds live in `~/.keysat/filebrowser.env` (outside the repo, gitignored). Default branch is `master`.
|
||||
- **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) are **admin-only** (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI.
|
||||
- Tests live in `proof-of-work/tests/`; mock server-action deps with `vi.hoisted()` + `vi.mock`.
|
||||
- **Before editing the AI subsystem (`proof-of-work/lib/ai/**` or the generate/generations routes), read `docs/guides/ai-subsystem.md`** — provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.
|
||||
|
||||
@@ -93,18 +96,18 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
|
||||
|
||||
## Current state
|
||||
|
||||
Latest version is **1.1.0:7** (built locally, installed on the StartOS server). The registry is currently **empty** — all versions were unpublished; nothing is downloadable until `publish.sh` runs again.
|
||||
Latest version is **1.1.0:8** — **built and sideloaded** to the StartOS server (2026-06-13). Registry is empty and **publishing is parked** (sideload-only via `make install`).
|
||||
|
||||
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
|
||||
|
||||
In progress: none — repo is at a clean checkpoint.
|
||||
Done this session (2026-06-13 full-eval security batch, on `master`): **P0** whole-instance DB export/import now admin-only (+UI +test); **P1** SSRF guard (`lib/ai/safeUrl.ts`, allows private-LAN by design) + custom-URL AI providers made admin-only + dead legacy `ai/config` route removed; **P1** dev quick-start fixed (`npm run create-admin`, README, `.env.example`); **P1** `export-db` 0-byte dev bug (`resolveDatabasePath` now matches Prisma). Full report in `EVALUATION.md`. Tests **197 pass**, build green, tsc clean. Secrets decision: no at-rest encryption (can't protect users from the operator — structural; threat model stands).
|
||||
|
||||
Decided but not implemented: tiered AI prompt formatting — JSON-Schema enforcement (Ollama `format` / OpenAI `response_format`), pipe-separated library, XML-tagged sections, Ollama-only few-shot. Targets local-model output quality; would ship as 1.1.0:8.
|
||||
In progress: none.
|
||||
|
||||
Git remote: `origin` → self-hosted Gitea at `ssh://git@immense-voyage.local:59916/grant/proof-of-work.git`; `master` is pushed and tracking. (The `~/.proof-of-work/{publish,unpublish}.sh` registry/FileBrowser hosts are separate from this code remote.)
|
||||
Next steps (priority order):
|
||||
1. **Next.js 14→15 major bump** (the remaining P1 — CVEs) as its own tested change. Then the P2/P3 hardening backlog → see `ROADMAP.md` → Security & hardening.
|
||||
2. Tiered AI prompt formatting (`ROADMAP.md` → AI quality) once the security queue is clear.
|
||||
|
||||
Known issues: publish.sh Step 3 (registry register) silently no-op'd on 1.1.0:6 and :7 — uploaded the file but didn't register; investigate before relying on those versions appearing in the registry. (The `Co-Authored-By` trailer scrub on `8f149d3`–`5b0535f` is **done** — history was rewritten and force-pushed; those SHAs are now stale.)
|
||||
Open/parked: `publish.sh` Step-3 registry-register silent no-op on 1.1.0:6/:7 (parked with publishing). Community-registry submission has 4 blockers (see `ROADMAP.md` → Packaging).
|
||||
|
||||
Next steps:
|
||||
1. Re-publish current version once the Step-3 registry-register failure is diagnosed.
|
||||
2. Implement the tiered AI prompt formatting (1.1.0:8).
|
||||
Git remote: `origin` → self-hosted Gitea at `ssh://git@immense-voyage.local:59916/grant/proof-of-work.git`; `master` tracks it. (`~/.proof-of-work/{publish,unpublish}.sh` registry/FileBrowser hosts are separate from this code remote.)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Evaluation — proof-of-work (Workout-log) — 2026-06-13
|
||||
|
||||
Intent: A self-hosted, multi-user workout planner and logger (Next.js 14 App Router + server actions/SSE, Prisma/SQLite, bcrypt auth) with an AI program-suggestion subsystem (5 LLM providers, background generation), packaged as a StartOS 0.4 s9pk.
|
||||
|
||||
Agents run: evaluator, security-auditor, exerciser, start9-spec-checker. (reviewer skipped — working tree is clean, no uncommitted diff to review.)
|
||||
|
||||
## Verdict
|
||||
|
||||
This is a well-built, genuinely sophisticated app — clean provider abstraction, careful idempotent StartOS migrations, 177 passing tests, green build/tsc/lint, and consistent per-object authorization across the domain routes. But it ships **one release-blocking multi-tenant breach with three independent confirmations**: `/api/settings/export-db` and `/api/settings/import-db` operate on the *entire instance database* yet are gated only by "is logged in," so any ordinary user can download every other user's data plus all bcrypt hashes and plaintext AI keys, or overwrite the whole DB to mint themselves an admin. It is harmless in single-user mode and catastrophic the moment a second user exists — which is a first-class advertised feature. Below that sit a real authenticated SSRF, a vulnerable Next.js version, several uncaught-input 500s, and a broken README quick-start. Community-registry packaging is separately BLOCKED on four items, but the user has parked registry submission, so those are deferred rather than urgent.
|
||||
|
||||
## Cross-referenced findings
|
||||
|
||||
- **Whole-instance DB export/import un-gated (the P0).** Flagged by all three code agents: evaluator (two P0s), security-auditor (two P0s, with the import path traced to full admin takeover via an injected known-hash User row), and exerciser (observed `/api/settings/export-db` returns 200 to a non-admin, plus a separate dev-only 0-byte bug at the same endpoint). One root cause, two endpoints, three kinds of evidence → kept as the top two P0s.
|
||||
- **SSRF via user-controlled provider `baseUrl`.** Both evaluator (P2) and security-auditor (P1) independently found `lib/ai/providers/{ollama,openai}.ts` fetch an arbitrary URL reachable from `/api/ai/test` and `/api/ai/ollama/models` with no private-range blocking. Merged; severity reconciled to P1 (see Disagreements).
|
||||
- **Login enumeration / timing + CSP `unsafe-eval`.** Both evaluator and security-auditor noted the unknown-email login path lacks a dummy bcrypt compare (timing oracle) and that the shipped CSP allows `unsafe-eval` while the in-repo comment only justifies `unsafe-inline`. Merged into single P3s.
|
||||
- **`/api/health` info disclosure.** Security-auditor (P3) and exerciser both noted the health endpoint returns user count + signupsOpen unauthenticated. Merged.
|
||||
- **Stale install alert / "0.3.5 data snapshot" copy.** start9-spec-checker flagged this in both the manifest install alert and the long description; the evaluator separately confirmed the seed ships zero users. Merged into one packaging item.
|
||||
|
||||
## Priority queue
|
||||
|
||||
- [P0] Any authenticated user can GET `/api/settings/export-db` and download the whole instance DB (all bcrypt hashes + plaintext AI API keys) — `app/api/settings/export-db/route.ts:15`, UI shown to all at `components/settings/SettingsForm.tsx:260`, `app/main/settings/page.tsx:33` — evaluator, security-auditor, exerciser
|
||||
- [P0] Any authenticated user can POST `/api/settings/import-db` and replace the whole instance DB → admin takeover + data destruction — `app/api/settings/import-db/route.ts:18`, replace at `:135` — evaluator, security-auditor
|
||||
- [P1] Authenticated SSRF via attacker-controlled provider `baseUrl` (probe LAN / Start9 services / metadata) — `lib/ai/providers/openai.ts:24`, `lib/ai/providers/ollama.ts:22`, reached via `app/api/ai/test/route.ts:66` and `app/api/ai/ollama/models/route.ts:39` — security-auditor (P1), evaluator (P2)
|
||||
- [P1] Vulnerable Next.js 14.2.35 (RSC DoS, WS-upgrade SSRF, App Router XSS, cache-poisoning) — `proof-of-work/package-lock.json`; fixes land in 15.5.16+ (major bump, test first) — security-auditor
|
||||
- [P1] README quick-start login is broken — `admin@local` fails Zod `.email()` (no TLD) → HTTP 400; seed ships zero users so fresh clone cannot log in via documented creds — `proof-of-work/README.md:23` — exerciser
|
||||
- [P1] `GET /api/settings/export-db` returns a 0-byte file in dev — `resolveDatabasePath()` picks the empty `data/app.db` created by `prisma db push` over the live `prisma/data/app.db` — `proof-of-work/lib/db-file.ts` — exerciser
|
||||
- [P2] No rate limiting on `POST /api/auth` (raw API endpoint) — credential brute-force bypasses the UI server-action's 10/15min cap — `app/api/auth/route.ts` — exerciser
|
||||
- [P2] Login/signup rate limit defeatable via spoofed leftmost `X-Forwarded-For` — `lib/rateLimit.ts:53` (mitigated only if the StartOS proxy overwrites XFF) — security-auditor
|
||||
- [P2] Invalid `date` string in `POST /api/workouts` → HTTP 500 instead of 400 (Prisma throws, generic catch) — `app/api/workouts/route.ts` — exerciser
|
||||
- [P2] Malformed JSON body → HTTP 500 instead of 400 (`request.json()` SyntaxError uncaught; also `/api/import/exercises/seed`) — exerciser
|
||||
- [P2] Negative pagination `offset=-5` on `GET /api/workouts` → HTTP 500 (Prisma rejects negative skip) — exerciser
|
||||
- [P2] Container runs as root — no `USER nextjs` directive before entrypoint, maximizing RCE blast radius — `start9/0.4/Dockerfile` — security-auditor
|
||||
- [P2] Packaging blocker: `license` is `"Proprietary"`, not a valid SPDX id; `LICENSE` file mismatches — `start9/0.4/startos/manifest/index.ts:23`, `start9/0.4/LICENSE` — start9-spec-checker
|
||||
- [P2] Packaging blocker: `instructions.md` absent (required; build fails / user-facing in UI) — `start9/0.4/` — start9-spec-checker
|
||||
- [P2] Packaging blocker: `packageRepo` + `upstreamRepo` both 404 (`github.com/keysat-xyz/proof-of-work`) — `start9/0.4/startos/manifest/index.ts:23-24` — start9-spec-checker
|
||||
- [P2] Packaging blocker: stale install alert + long description claim a "one-time 0.3.5 /data snapshot baked into the image" that no longer happens — `start9/0.4/startos/manifest/i18n.ts:22-33` — start9-spec-checker
|
||||
- [P3] Login timing oracle — unknown-email path returns early with no dummy bcrypt compare, leaking account existence — `app/auth/login/actions.ts:27` — security-auditor, evaluator
|
||||
- [P3] CSP ships `script-src 'unsafe-eval'` while the in-repo comment only justifies `'unsafe-inline'` — `proof-of-work/next.config.js:19` — evaluator, security-auditor
|
||||
- [P3] `/api/health` returns user count + `signupsOpen` to unauthenticated callers — `app/api/health/route.ts:44` — security-auditor, exerciser
|
||||
- [P3] Rate-limit map never evicts fully-expired keys — unbounded distinct-IP growth over process lifetime — `lib/rateLimit.ts:24` — evaluator
|
||||
- [P3] `workout PATCH` / `sets POST` accept `exerciseId` without verifying caller ownership (unlike `programs PATCH`) — `app/api/workouts/[id]/route.ts:128`, `app/api/workouts/[id]/sets/route.ts:61` — security-auditor
|
||||
- [P3] Session tokens valid 30 days, no idle timeout, no rotation on privilege change — `lib/auth.ts` — security-auditor
|
||||
- [P3] No max-length validation on any text field (10KB/100KB names accepted) — exerciser
|
||||
- [P3] `defaultRestSeconds` silently dropped by `POST /api/preferences` though it appears in Settings UI — exerciser
|
||||
- [P3] WAL not enabled in local dev (health-check warns `journal_mode='delete'`) — degraded backup safety for dev only — exerciser
|
||||
- [P3] Stale `.env.example` lists dead `CLAUDE_API_KEY` (keys live per-user in DB) — `proof-of-work/.env.example` — evaluator
|
||||
- [P3] Drifted docs: AGENTS.md/CHANGELOG say "34 tests"; suite is now 177 — evaluator
|
||||
- [P3] Git history (initial commit `1b64c45`) contains a committed SQLite DB with a default admin bcrypt hash — purge before ever making history public — security-auditor
|
||||
- [P3] Packaging warnings: icon is PNG vs template SVG; README is migration-era prose missing spec sections; no `.github/workflows/`; `docsUrls` points at generic Start9 docs; Dockerfile on Node 20 vs LTS 22 — start9-spec-checker
|
||||
- [P3] Repo cruft: legacy `start9/0.4/workout-log_x86_64.s9pk` artifact not removed by `make clean`; `bcryptjs` listed in `start9/0.4/package.json` appears unused (app uses native `bcrypt`) — start9-spec-checker
|
||||
|
||||
## Scorecard
|
||||
|
||||
| Lens | Score /5 | Justification |
|
||||
|---|---|---|
|
||||
| Architecture | 4 | Clean provider abstraction (`lib/ai/providers/index.ts:7`), helpers split out of routes, sound schema with intentional composite indexes, process-local background bus by design. |
|
||||
| Security | 2 | Two P0 instance-DB routes un-gated; otherwise strong (CSPRNG sessions, double-gated admin actions, no signup enumeration, redacted keys). Corroborated by auditor — no adjustment. |
|
||||
| Performance | 4 | Hot paths indexed, WAL tuning at boot, throttled progress flush; only an unbounded rate-limiter map. |
|
||||
| Testing | 4 | 177 tests pass in ~4s covering AI parsing, auth, admin guards, route CRUD, idempotency. *Note:* exerciser found multiple uncaught-input 500s the suite doesn't cover, and no test asserts the instance-DB admin gate — evidence the suite has an input-validation/authz-regression blind spot, but the existing coverage is real, so held at 4. |
|
||||
| Code quality | 4 | Consistent route shape, thorough "why" comments, tsc/lint clean; two minor validation styles coexist. |
|
||||
| Documentation | 4 | README/AGENTS/CHANGELOG detailed and mostly true; broken quick-start creds, stale `.env.example`, drifted test counts, migration-era packaging README. |
|
||||
|
||||
## Disagreements & gaps
|
||||
|
||||
- **SSRF severity (P1 vs P2).** security-auditor rated it P1, evaluator P2 — the split is purely about the self-hosted threat model (evaluator weighted "operator owns the box" down; auditor weighted "real in multi-user mode" up). Resolved to **P1**: the same multi-user reality that makes the DB-export a P0 also makes an authenticated user reaching the operator's LAN a real cross-tenant capability. Noted rather than averaged.
|
||||
- **Packaging "BLOCKER" → P2 mapping.** start9-spec-checker correctly reports the package as BLOCKED for *community-registry submission*. The user has explicitly parked registry publishing (sideload via `make install` only), so these are mapped to P2 (must-fix-before-submit, not on the current critical path) rather than P0. If/when community submission is back on the table, treat all four as hard blockers.
|
||||
- **Shared blind spot — no live/multi-user runtime test.** Every code agent assessed multi-user authz and the AI subsystem *from source*; the exerciser couldn't test live AI generation (no API keys) or true concurrent multi-user load, and nobody exercised the StartOS proxy. So two severity calls (the XFF rate-limit bypass, secure-cookie/HTTPS posture) rest on whether the StartOS reverse proxy rewrites headers — unverified here.
|
||||
- **Untested by design:** live LLM generation end-to-end, PWA/service-worker, StartOS Actions/backup-restore on a real box, and the `import-db` restore path with a valid .db file.
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. **Close the P0 first.** Gate `export-db` + `import-db` on `user.isAdmin` (return 403) in both route handlers *and* hide them in `SettingsForm`/`page.tsx`; better, move whole-DB replace into the StartOS operator action layer entirely. Per-user export already exists at `/api/me/export`.
|
||||
2. **Add a regression test** asserting a non-admin gets 403 from both instance-DB routes — this is the exact gap the suite has today, and it locks the P0 fix.
|
||||
3. **Fix the P1 SSRF** — resolve provider `baseUrl` hosts and reject loopback/link-local/private/reserved ranges; restrict scheme to http/https (or limit provider config to admins).
|
||||
4. **Fix the developer-facing P1s** — correct the README quick-start (the seed ships zero users; document the first-run setup flow instead of `admin@local`), and fix `resolveDatabasePath()` so export doesn't pick an empty 0-byte DB.
|
||||
5. **Sweep the input-validation 500s together** — wrap `request.json()` in try/catch→400 and add Zod guards for date/offset across the workouts routes (one shared pattern fixes several P2s); add rate limiting to `POST /api/auth`.
|
||||
6. **Harden the container** — add `USER nextjs` to the Dockerfile; plan the Next.js 15 upgrade as its own tested change.
|
||||
7. **Defer packaging fixes** until registry submission is back on — then clear all four blockers (SPDX license, `instructions.md`, repo URLs, stale install alert) plus the warnings in one pass. Quick wins anytime: delete the legacy `workout-log_x86_64.s9pk`, drop dead `CLAUDE_API_KEY`/`bcryptjs`, fix drifted test counts.
|
||||
+11
-2
@@ -7,11 +7,20 @@ Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Curren
|
||||
- Tiered prompt formatting (also the immediate next step): JSON-Schema output enforcement via Ollama `format` and OpenAI `response_format`; pipe-separated library rows; XML-tagged prompt sections; Ollama-only few-shot example; stable prefix first for prompt-cache hits.
|
||||
- Keep `MODEL_MENU` / `PRICES` current as providers ship new models.
|
||||
|
||||
## Security & hardening (from 2026-06-13 full-eval; full detail + file:line in `EVALUATION.md`)
|
||||
|
||||
- **Next.js 14→15 major bump** (CVEs: RSC DoS, WS-upgrade SSRF, App Router XSS). Own tested change — breaking App Router/caching semantics, needs its own build + sideload verification.
|
||||
- Input-validation 500s → should be 400: invalid `date`, malformed JSON body, negative pagination `offset` on `/api/workouts` (+ `import/exercises/seed`). One shared `try{json}→400` + Zod guard fixes the set.
|
||||
- `POST /api/auth` has no rate limiting (the UI server-action is capped; the raw API isn't) → brute-forceable.
|
||||
- Rate limiter trusts the spoofable leftmost `X-Forwarded-For` (`lib/rateLimit.ts`) — verify whether the StartOS proxy overwrites XFF on the live box.
|
||||
- Container runs as **root** — add `USER nextjs` to `start9/0.4/Dockerfile`.
|
||||
- P3 hardening batch: login timing oracle (dummy bcrypt on unknown email), CSP `unsafe-eval` vs comment, `/api/health` info disclosure, rate-limit map leak, `exerciseId` ownership unchecked on workout PATCH/sets POST, 30-day sessions, no text max-length.
|
||||
|
||||
## Packaging / distribution
|
||||
|
||||
- Diagnose and fix the `publish.sh` Step-3 registry-register silent no-op.
|
||||
- Build for `arm` / additional arches once StartOS 0.4 supports them on the host.
|
||||
- Consider submission to the Start9 community registry (use the start9-spec-checker agent first).
|
||||
- Consider submission to the Start9 community registry (use the start9-spec-checker agent first). Blockers found 2026-06-13: non-SPDX `"Proprietary"` license, missing `instructions.md`, 404 `packageRepo`/`upstreamRepo` URLs, stale "0.3.5 data snapshot" install alert + long description; plus warnings (PNG vs SVG icon, migration-era README, no `.github/workflows`, generic `docsUrls`, Node 20 vs 22).
|
||||
|
||||
## Product
|
||||
|
||||
@@ -21,5 +30,5 @@ Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Curren
|
||||
|
||||
## Hygiene
|
||||
|
||||
- Scrub `Co-Authored-By` trailers from git history.
|
||||
- Delete the legacy `start9/0.4/workout-log_x86_64.s9pk` build artifact; drop unused `bcryptjs` from `start9/0.4/package.json`.
|
||||
- Revisit `workout-planner/` scratch dir — remove if truly unused.
|
||||
|
||||
@@ -29,3 +29,17 @@ generate/generations route handlers). Whole-repo rules live in `AGENTS.md`.
|
||||
- Streaming AI uses SSE; partial JSON is recovered with `lib/ai/lenientJson.ts`.
|
||||
- Pricing/model menus live in `lib/ai/pricing.ts` (`PRICES`, `MODEL_MENU`) — keep them
|
||||
paired so every menu model has a price entry (there's a test enforcing this).
|
||||
|
||||
## SSRF / provider-URL safety
|
||||
|
||||
- Any `fetch` to a user-supplied provider base URL MUST go through
|
||||
`assertSafeProviderUrl` (`lib/ai/safeUrl.ts`) first — it enforces http(s) and blocks
|
||||
link-local/cloud-metadata (169.254/16, fe80::/10) + unspecified. **Private-LAN +
|
||||
loopback are allowed on purpose** (reaching `ollama.startos`/LAN gateways is the
|
||||
feature). Currently wired into `providers/ollama.ts`, the `openai-compatible` path in
|
||||
`providers/openai.ts` (NOT the fixed `api.openai.com` path), and the `ai/ollama/models`
|
||||
probe. Add the guard to any new user-URL fetch path.
|
||||
- Custom-URL providers (those with `requiresBaseUrl`: ollama, openai-compatible) are
|
||||
**admin-only** — `isCustomUrlProvider` gates `ai/configs` POST + `[id]` PATCH + `ai/test`,
|
||||
and `ai/ollama/models` is fully admin-only. The Settings UI hides them from non-admins.
|
||||
This is a second defense layer on top of the IP block; keep both when adding routes.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Database
|
||||
DATABASE_URL=file:./data/app.db
|
||||
|
||||
# API Keys
|
||||
CLAUDE_API_KEY=your_claude_api_key_here
|
||||
# AI provider API keys are NOT configured here — each user sets their own
|
||||
# key per provider in the app (Settings → AI), stored in the database.
|
||||
|
||||
+12
-3
@@ -11,16 +11,25 @@ npm install
|
||||
# Set up the database
|
||||
npx prisma db push
|
||||
|
||||
# Seed with exercises and default user
|
||||
# Seed the InstanceSettings singleton
|
||||
npm run db:seed
|
||||
|
||||
# Create the first admin (fresh installs ship with NO users — see below).
|
||||
# Use a real-looking email; "admin@local" is rejected (no TLD).
|
||||
npm run create-admin -- you@example.com yourpassword "Your Name"
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
Open [http://localhost:3000](http://localhost:3000) and log in with the
|
||||
email/password you just created.
|
||||
|
||||
**Default login:** `admin@local` / `workout123`
|
||||
**No default account.** Fresh installs ship with zero users on purpose, so there
|
||||
are no default credentials to forget and leak. In production (StartOS) the
|
||||
operator creates the first admin via the **Actions → Set admin credentials**
|
||||
action; locally, `npm run create-admin` is the equivalent. Once an admin exists,
|
||||
additional users sign up at `/auth/signup` (if sign-ups are enabled in Settings).
|
||||
|
||||
## Access from Other Devices
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
/**
|
||||
* GET /api/ai/config — read this user's AI provider config.
|
||||
* API key is NOT returned in plaintext (only
|
||||
* a "configured: true|false" flag) so it
|
||||
* doesn't leak via Settings page reload.
|
||||
* POST /api/ai/config — update. Pass null/empty to clear a field.
|
||||
*/
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const prefs = await prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
|
||||
});
|
||||
return NextResponse.json({
|
||||
aiProvider: prefs?.aiProvider ?? null,
|
||||
aiModel: prefs?.aiModel ?? null,
|
||||
aiBaseUrl: prefs?.aiBaseUrl ?? null,
|
||||
aiKeyConfigured: !!prefs?.aiApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
const bodySchema = z.object({
|
||||
aiProvider: z
|
||||
.enum(['claude', 'openai', 'openai-compatible', 'gemini', 'ollama'])
|
||||
.nullable()
|
||||
.optional(),
|
||||
aiModel: z.string().nullable().optional(),
|
||||
aiBaseUrl: z.string().url().nullable().optional().or(z.literal('')),
|
||||
aiApiKey: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid body', details: parsed.error.errors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Empty string -> null (UI sometimes sends "")
|
||||
const norm = (v: string | null | undefined) =>
|
||||
v === '' || v == null ? null : v;
|
||||
|
||||
const data: Record<string, string | null> = {};
|
||||
if (parsed.data.aiProvider !== undefined)
|
||||
data.aiProvider = parsed.data.aiProvider ?? null;
|
||||
if (parsed.data.aiModel !== undefined) data.aiModel = norm(parsed.data.aiModel);
|
||||
if (parsed.data.aiBaseUrl !== undefined)
|
||||
data.aiBaseUrl = norm(parsed.data.aiBaseUrl);
|
||||
if (parsed.data.aiApiKey !== undefined)
|
||||
data.aiApiKey = norm(parsed.data.aiApiKey);
|
||||
|
||||
// Make sure the prefs row exists.
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { userId: user.id },
|
||||
update: data,
|
||||
create: {
|
||||
userId: user.id,
|
||||
theme: 'system',
|
||||
defaultWeightUnit: 'lbs',
|
||||
defaultRestSeconds: 90,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { activate } from '@/lib/ai/activateConfig';
|
||||
import { isCustomUrlProvider } from '@/lib/ai/providers';
|
||||
|
||||
/**
|
||||
* GET /api/ai/configs/[id] Single config (apiKey redacted).
|
||||
@@ -72,6 +73,21 @@ export async function PATCH(
|
||||
});
|
||||
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
// Admin-only custom-URL surface (see configs POST). Blocks a non-admin from
|
||||
// setting a base URL, or editing a custom-URL provider config at all.
|
||||
if (
|
||||
!user.isAdmin &&
|
||||
(parsed.data.baseUrl || isCustomUrlProvider(existing.provider))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Only an admin can configure providers with a custom base URL (Ollama / OpenAI-compatible).',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const data: Record<string, string | null> = {};
|
||||
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
||||
if (parsed.data.model !== undefined) data.model = parsed.data.model;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { activate } from '@/lib/ai/activateConfig';
|
||||
import { isCustomUrlProvider } from '@/lib/ai/providers';
|
||||
|
||||
/**
|
||||
* v1.1.0:4 — Multi-config CRUD.
|
||||
@@ -80,6 +81,20 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data;
|
||||
|
||||
// Custom-URL providers (Ollama / OpenAI-compatible) are admin-only — a
|
||||
// non-admin pointing the server at an arbitrary URL is the SSRF actor
|
||||
// vector. Fixed-URL cloud providers stay per-user.
|
||||
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Only an admin can configure providers with a custom base URL (Ollama / OpenAI-compatible).',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const profile = await prisma.aIConfigProfile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { assertSafeProviderUrl } from '@/lib/ai/safeUrl';
|
||||
|
||||
/**
|
||||
* GET /api/ai/ollama/models?baseUrl=...
|
||||
@@ -29,6 +30,10 @@ const DEFAULT_CANDIDATES = [
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
|
||||
// Probing Ollama URLs is the admin-only custom-URL surface (EVALUATION.md
|
||||
// P1) — a non-admin shouldn't be able to fingerprint the local network.
|
||||
if (!user.isAdmin)
|
||||
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const explicit = url.searchParams.get('baseUrl');
|
||||
@@ -54,10 +59,21 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
async function probe(baseUrl: string) {
|
||||
const t0 = Date.now();
|
||||
const url = baseUrl.replace(/\/$/, '') + '/api/tags';
|
||||
try {
|
||||
await assertSafeProviderUrl(url);
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false as const,
|
||||
baseUrl,
|
||||
error: (e as Error).message,
|
||||
ms: Date.now() - t0,
|
||||
};
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(baseUrl.replace(/\/$/, '') + '/api/tags', {
|
||||
const res = await fetch(url, {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getProvider } from '@/lib/ai/providers';
|
||||
import { getProvider, isCustomUrlProvider } from '@/lib/ai/providers';
|
||||
|
||||
/**
|
||||
* POST /api/ai/test
|
||||
@@ -112,6 +112,19 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Testing an arbitrary base URL is the same SSRF surface as configuring
|
||||
// one — admin-only. Non-admins may only test fixed-URL cloud providers.
|
||||
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
'Only an admin can test providers with a custom base URL (Ollama / OpenAI-compatible).',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const providerImpl = getProvider(provider);
|
||||
if (!providerImpl) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -16,6 +16,11 @@ export async function GET() {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Whole-instance operation: the file contains every user's data and
|
||||
// password hashes. Admin-only — regular users use /api/me/export.
|
||||
if (!user.isAdmin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const dbPath = resolveDatabasePath();
|
||||
const data = await readFile(dbPath);
|
||||
|
||||
@@ -18,6 +18,11 @@ export async function POST(request: NextRequest) {
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Replaces the entire instance database — admin-only. Without this a
|
||||
// regular user could overwrite the DB to mint themselves an admin.
|
||||
if (!user.isAdmin) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("database") as File | null;
|
||||
|
||||
@@ -33,7 +33,7 @@ export default async function SettingsPage() {
|
||||
<div id="general"><SettingsForm user={user} /></div>
|
||||
<div id="password"><ChangePasswordForm /></div>
|
||||
<div id="sessions"><SessionsList /></div>
|
||||
<div id="ai"><AIIntegration /></div>
|
||||
<div id="ai"><AIIntegration isAdmin={user.isAdmin} /></div>
|
||||
<div id="data"><ExportMyData /></div>
|
||||
{user.isAdmin && instanceSettings && (
|
||||
<div id="instance">
|
||||
|
||||
@@ -15,6 +15,9 @@ import { MODEL_MENU } from '@/lib/ai/pricing';
|
||||
* dropdown of installed models when reachable.
|
||||
*/
|
||||
|
||||
// UI-side provider metadata. `requiresUrl` mirrors the `requiresBaseUrl` flag
|
||||
// on the server providers (lib/ai/providers); keep the two in sync when adding
|
||||
// a provider. `requiresUrl: true` ⇒ custom-URL ⇒ admin-only (see configs API).
|
||||
const PROVIDERS = [
|
||||
{ id: 'claude', label: 'Anthropic Claude', requiresKey: true, requiresUrl: false },
|
||||
{ id: 'openai', label: 'OpenAI', requiresKey: true, requiresUrl: false },
|
||||
@@ -44,7 +47,7 @@ type TestResult =
|
||||
| { ok: true; sample: string; tokensIn?: number; tokensOut?: number; ms: number }
|
||||
| { ok: false; error: string; ms?: number };
|
||||
|
||||
export default function AIIntegration() {
|
||||
export default function AIIntegration({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [configs, setConfigs] = useState<SavedConfig[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -121,6 +124,7 @@ export default function AIIntegration() {
|
||||
<ConfigRow
|
||||
key={c.id}
|
||||
cfg={c}
|
||||
isAdmin={isAdmin}
|
||||
isActive={c.id === activeId}
|
||||
isEditing={editingId === c.id}
|
||||
onActivate={() => handleActivate(c.id)}
|
||||
@@ -137,6 +141,7 @@ export default function AIIntegration() {
|
||||
|
||||
{showForm ? (
|
||||
<ConfigForm
|
||||
isAdmin={isAdmin}
|
||||
onCancel={() => setShowForm(false)}
|
||||
onCreated={() => {
|
||||
setShowForm(false);
|
||||
@@ -167,6 +172,7 @@ export default function AIIntegration() {
|
||||
*/
|
||||
function ConfigRow({
|
||||
cfg,
|
||||
isAdmin,
|
||||
isActive,
|
||||
isEditing,
|
||||
onActivate,
|
||||
@@ -175,6 +181,7 @@ function ConfigRow({
|
||||
onSaved,
|
||||
}: {
|
||||
cfg: SavedConfig;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
isEditing: boolean;
|
||||
onActivate: () => void;
|
||||
@@ -312,6 +319,7 @@ function ConfigRow({
|
||||
{isEditing && (
|
||||
<div className="border-t border-zinc-800 pt-3">
|
||||
<ConfigForm
|
||||
isAdmin={isAdmin}
|
||||
initial={cfg}
|
||||
onCancel={onEdit}
|
||||
onCreated={onSaved}
|
||||
@@ -325,6 +333,8 @@ function ConfigRow({
|
||||
interface ConfigFormProps {
|
||||
/** When set: editing this saved config (PATCH). Otherwise: creating new (POST). */
|
||||
initial?: SavedConfig;
|
||||
/** Custom-URL providers (Ollama / OpenAI-compatible) are admin-only. */
|
||||
isAdmin: boolean;
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
@@ -343,8 +353,13 @@ interface ConfigFormProps {
|
||||
* tests the in-progress form values without saving — handy for
|
||||
* checking a key before committing.
|
||||
*/
|
||||
function ConfigForm({ initial, onCancel, onCreated }: ConfigFormProps) {
|
||||
function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps) {
|
||||
const isEdit = !!initial;
|
||||
// Non-admins can't configure custom-URL providers — hide them from the
|
||||
// dropdown (the server enforces this too; see app/api/ai/configs).
|
||||
const availableProviders = isAdmin
|
||||
? PROVIDERS
|
||||
: PROVIDERS.filter((p) => !p.requiresUrl);
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [provider, setProvider] = useState<ProviderId>(initial?.provider ?? 'claude');
|
||||
const [model, setModel] = useState(initial?.model ?? '');
|
||||
@@ -500,7 +515,7 @@ function ConfigForm({ initial, onCancel, onCreated }: ConfigFormProps) {
|
||||
className={inputClass}
|
||||
disabled={isEdit}
|
||||
>
|
||||
{PROVIDERS.map((p) => (
|
||||
{availableProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
|
||||
@@ -191,15 +191,17 @@ export default function SettingsForm({ user }: { user: User }) {
|
||||
</button>
|
||||
|
||||
{/* Database Import Section */}
|
||||
<DatabaseExport />
|
||||
<DatabaseExport isAdmin={user.isAdmin} />
|
||||
<WorkoutCsvImportShortcut />
|
||||
<DatabaseImport />
|
||||
{/* Whole-instance DB replace is admin-only (it overwrites every
|
||||
user's data); the per-user CSV import above stays for everyone. */}
|
||||
{user.isAdmin && <DatabaseImport />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Database Export Component ----------
|
||||
function DatabaseExport() {
|
||||
function DatabaseExport({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [exportingDb, setExportingDb] = useState(false);
|
||||
const [exportingCsv, setExportingCsv] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
@@ -246,7 +248,9 @@ function DatabaseExport() {
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-1">Export Backups</h2>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
Download a full database backup or a CSV export of workout logs.
|
||||
{isAdmin
|
||||
? "Download a full database backup or a CSV export of workout logs."
|
||||
: "Download a CSV export of your workout logs."}
|
||||
</p>
|
||||
|
||||
{exportError && (
|
||||
@@ -257,30 +261,32 @@ function DatabaseExport() {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
"/api/settings/export-db",
|
||||
"proof-of-work-backup.db",
|
||||
setExportingDb
|
||||
)
|
||||
}
|
||||
disabled={exportingDb || exportingCsv}
|
||||
className="py-3 border border-zinc-700 rounded-lg text-zinc-300 text-sm font-medium hover:text-white hover:border-zinc-500 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
{exportingDb ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Exporting DB...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export SQLite (.db)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
"/api/settings/export-db",
|
||||
"proof-of-work-backup.db",
|
||||
setExportingDb
|
||||
)
|
||||
}
|
||||
disabled={exportingDb || exportingCsv}
|
||||
className="py-3 border border-zinc-700 rounded-lg text-zinc-300 text-sm font-medium hover:text-white hover:border-zinc-500 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
{exportingDb ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Exporting DB...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export SQLite (.db)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -16,6 +16,16 @@ export function getProvider(id: string): LLMProvider | null {
|
||||
return (ALL as Record<string, LLMProvider | undefined>)[id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for providers that take a user-supplied base URL (Ollama,
|
||||
* OpenAI-compatible). Configuring these is admin-only — a non-admin pointing
|
||||
* the server at an arbitrary URL is the SSRF actor vector (EVALUATION.md P1).
|
||||
* The fixed-URL cloud providers (claude/openai/gemini) stay per-user.
|
||||
*/
|
||||
export function isCustomUrlProvider(id: string): boolean {
|
||||
return !!getProvider(id)?.requiresBaseUrl;
|
||||
}
|
||||
|
||||
/** Stable list for UI dropdowns. Order matches the Settings select. */
|
||||
export const PROVIDER_ORDER: ProviderId[] = [
|
||||
'claude',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||
import { ndjsonLines } from '../sse';
|
||||
import { assertSafeProviderUrl } from '../safeUrl';
|
||||
|
||||
/**
|
||||
* Ollama: streaming NDJSON over POST /api/chat.
|
||||
@@ -20,6 +21,12 @@ export const ollama: LLMProvider = {
|
||||
return;
|
||||
}
|
||||
const url = opts.baseUrl.replace(/\/$/, '') + '/api/chat';
|
||||
try {
|
||||
await assertSafeProviderUrl(url);
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: (e as Error).message };
|
||||
return;
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||
import { sseLines } from '../sse';
|
||||
import { assertSafeProviderUrl } from '../safeUrl';
|
||||
|
||||
/**
|
||||
* Generic chat-completions streamer used by both OpenAI and the
|
||||
@@ -110,6 +111,14 @@ export const openaiCompatible: LLMProvider = {
|
||||
};
|
||||
return;
|
||||
}
|
||||
// User-supplied base URL → SSRF guard (the fixed-URL `openai` provider
|
||||
// above skips this since api.openai.com is not attacker-controlled).
|
||||
try {
|
||||
await assertSafeProviderUrl(opts.baseUrl);
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: `OpenAI-compatible: ${(e as Error).message}` };
|
||||
return;
|
||||
}
|
||||
yield* generateOpenAIStyle(opts, opts.baseUrl, 'OpenAI-compatible');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
|
||||
/**
|
||||
* SSRF guard for user-supplied AI provider base URLs.
|
||||
*
|
||||
* On a self-hosted box, pointing a provider at a private-LAN service — Ollama
|
||||
* at ollama.startos:11434, a LiteLLM/vLLM gateway on 192.168.x, localhost in
|
||||
* dev — is a *feature*, so we deliberately ALLOW private and loopback ranges.
|
||||
* We block only targets that are never a legitimate provider and are valuable
|
||||
* to an attacker: cloud-metadata / link-local (169.254.0.0/16, fe80::/10) and
|
||||
* unspecified addresses (0.0.0.0, ::), plus any non-http(s) scheme.
|
||||
*
|
||||
* We resolve the hostname and check every address it maps to, so a public
|
||||
* name that resolves to a blocked range is caught too. (Note: this is a
|
||||
* pre-flight check; it does not pin the resolved IP, so a DNS-rebinding race
|
||||
* is out of scope — acceptable here since private ranges are allowed anyway.)
|
||||
*/
|
||||
export async function assertSafeProviderUrl(rawUrl: string): Promise<void> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error("Invalid URL.");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Only http and https URLs are allowed.");
|
||||
}
|
||||
|
||||
// URL.hostname keeps the brackets on IPv6 literals ([::1]); strip BOTH
|
||||
// (the /g matters — without it only the leading bracket goes, leaving a
|
||||
// trailing ] that fails net.isIP and falls through to a bogus DNS lookup).
|
||||
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||
|
||||
let addresses: string[];
|
||||
if (net.isIP(hostname)) {
|
||||
addresses = [hostname];
|
||||
} else {
|
||||
try {
|
||||
const resolved = await lookup(hostname, { all: true });
|
||||
addresses = resolved.map((r) => r.address);
|
||||
} catch {
|
||||
throw new Error(`Could not resolve host: ${hostname}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const address of addresses) {
|
||||
if (isBlockedAddress(address)) {
|
||||
throw new Error(
|
||||
`Refusing to connect to ${hostname} (${address}): ` +
|
||||
"link-local / cloud-metadata addresses are not allowed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for addresses that are never a legitimate provider target: the
|
||||
* unspecified address and the link-local / cloud-metadata range. Private
|
||||
* (RFC1918) and loopback addresses return false on purpose — see the module
|
||||
* comment. Exported for unit testing.
|
||||
*/
|
||||
export function isBlockedAddress(ip: string): boolean {
|
||||
const family = net.isIP(ip);
|
||||
if (family === 4) {
|
||||
const o = ip.split(".").map(Number);
|
||||
if (o[0] === 0) return true; // 0.0.0.0/8 ("this network" — never a valid target)
|
||||
if (o[0] === 169 && o[1] === 254) return true; // 169.254.0.0/16 link-local + metadata
|
||||
return false;
|
||||
}
|
||||
if (family === 6) {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === "::") return true; // unspecified
|
||||
// fe80::/10 link-local spans fe80..febf
|
||||
if (/^fe[89ab]/.test(lower)) return true;
|
||||
// IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4 address
|
||||
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped) return isBlockedAddress(mapped[1]);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
export function resolveDatabasePath(): string {
|
||||
const dbUrl = process.env.DATABASE_URL || "file:./data/app.db";
|
||||
@@ -14,14 +13,13 @@ export function resolveDatabasePath(): string {
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
// Prisma resolves a relative `file:` URL against the schema directory
|
||||
// (prisma/), NOT the cwd. We must resolve it the same way, otherwise we
|
||||
// can hand back a stray empty ./data/app.db (created by a cwd-relative
|
||||
// `prisma db push`) while the live DB Prisma actually uses sits under
|
||||
// prisma/data/ — which made export-db stream a 0-byte file in dev.
|
||||
const normalized = rawPath.replace(/^\.\//, "");
|
||||
const directPath = path.resolve(process.cwd(), normalized);
|
||||
if (existsSync(directPath)) {
|
||||
return directPath;
|
||||
}
|
||||
|
||||
const prismaPath = path.resolve(process.cwd(), "prisma", normalized);
|
||||
return prismaPath;
|
||||
return path.resolve(process.cwd(), "prisma", normalized);
|
||||
}
|
||||
|
||||
export function getTimestampFileSuffix(now: Date = new Date()): string {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"lint": "next lint",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "npx tsx prisma/seed.ts",
|
||||
"create-admin": "npx tsx scripts/create-admin.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"sync-library": "node scripts/sync-library.cjs",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* create-admin — local-dev helper to create the first admin user.
|
||||
*
|
||||
* Fresh installs ship with ZERO users by design (see prisma/seed.ts): in
|
||||
* production the operator creates the first admin via the StartOS Action
|
||||
* "Set admin credentials". Local dev has no StartOS, so this script is the
|
||||
* equivalent — it creates an admin and seeds their curated library, exactly
|
||||
* like the StartOS action does.
|
||||
*
|
||||
* Usage (from proof-of-work/):
|
||||
* npm run create-admin -- <email> <password> [name]
|
||||
* ADMIN_EMAIL=me@example.com ADMIN_PASSWORD=secret123 npm run create-admin
|
||||
*
|
||||
* The email must be a real-looking address (login/signup validate the TLD);
|
||||
* "admin@local" will NOT work.
|
||||
*/
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { hashPassword } from "../lib/auth";
|
||||
import { ensureLibraryForUser } from "../lib/library";
|
||||
|
||||
async function main() {
|
||||
const force = process.argv.includes("--force");
|
||||
const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
||||
const email = positional[0] || process.env.ADMIN_EMAIL;
|
||||
const password = positional[1] || process.env.ADMIN_PASSWORD;
|
||||
const name = positional[2] || process.env.ADMIN_NAME || null;
|
||||
|
||||
if (!email || !password) {
|
||||
console.error(
|
||||
"usage: npm run create-admin -- <email> <password> [name]\n" +
|
||||
" or: ADMIN_EMAIL=... ADMIN_PASSWORD=... npm run create-admin",
|
||||
);
|
||||
process.exit(64);
|
||||
}
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
console.error(
|
||||
`Refusing "${email}": login/signup require a real email with a TLD ` +
|
||||
'(e.g. you@example.com). "admin@local" will not work.',
|
||||
);
|
||||
process.exit(64);
|
||||
}
|
||||
if (password.length < 8) {
|
||||
console.error("Password must be at least 8 characters.");
|
||||
process.exit(64);
|
||||
}
|
||||
|
||||
// Guard the footgun: a typo'd or reused email would otherwise silently
|
||||
// reset an existing user's password AND promote them to admin. Require
|
||||
// --force to touch an account that already exists.
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing && !force) {
|
||||
console.error(
|
||||
`A user with ${email} already exists (isAdmin=${existing.isAdmin}). ` +
|
||||
"Re-run with --force to RESET its password and promote it to admin.",
|
||||
);
|
||||
process.exit(73);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Upsert so --force re-running promotes/repairs an existing account rather
|
||||
// than erroring on the unique-email constraint.
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { passwordHash, isAdmin: true },
|
||||
create: {
|
||||
email,
|
||||
passwordHash,
|
||||
name: name?.trim() || null,
|
||||
isAdmin: true,
|
||||
userPreferences: {
|
||||
create: {
|
||||
theme: "system",
|
||||
defaultWeightUnit: "lbs",
|
||||
defaultRestSeconds: 90,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const added = await ensureLibraryForUser(user.id);
|
||||
console.log(
|
||||
`Admin ready: ${email} (id ${user.id}); seeded ${added} library exercises.`,
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("create-admin failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assertSafeProviderUrl, isBlockedAddress } from '@/lib/ai/safeUrl';
|
||||
|
||||
// SSRF guard for user-supplied provider base URLs (EVALUATION.md P1).
|
||||
// Self-hosted intent: private-LAN + loopback are ALLOWED (Ollama, gateways,
|
||||
// dev); only link-local/cloud-metadata + unspecified + non-http(s) are blocked.
|
||||
|
||||
describe('isBlockedAddress', () => {
|
||||
it('blocks link-local / cloud-metadata and unspecified', () => {
|
||||
expect(isBlockedAddress('169.254.169.254')).toBe(true); // cloud metadata
|
||||
expect(isBlockedAddress('169.254.0.1')).toBe(true);
|
||||
expect(isBlockedAddress('0.0.0.0')).toBe(true);
|
||||
expect(isBlockedAddress('::')).toBe(true);
|
||||
expect(isBlockedAddress('fe80::1')).toBe(true); // IPv6 link-local
|
||||
expect(isBlockedAddress('::ffff:169.254.169.254')).toBe(true); // mapped
|
||||
});
|
||||
|
||||
it('allows private LAN, loopback, and public (the legitimate targets)', () => {
|
||||
expect(isBlockedAddress('192.168.1.10')).toBe(false);
|
||||
expect(isBlockedAddress('10.0.0.5')).toBe(false);
|
||||
expect(isBlockedAddress('172.16.0.9')).toBe(false);
|
||||
expect(isBlockedAddress('127.0.0.1')).toBe(false);
|
||||
expect(isBlockedAddress('1.1.1.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertSafeProviderUrl', () => {
|
||||
it('rejects non-http(s) schemes', async () => {
|
||||
await expect(assertSafeProviderUrl('ftp://example.com')).rejects.toThrow();
|
||||
await expect(assertSafeProviderUrl('file:///etc/passwd')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unparseable URL', async () => {
|
||||
await expect(assertSafeProviderUrl('not a url')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects cloud-metadata / link-local IP literals', async () => {
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://169.254.169.254/latest/meta-data/'),
|
||||
).rejects.toThrow();
|
||||
await expect(assertSafeProviderUrl('http://0.0.0.0:11434')).rejects.toThrow();
|
||||
await expect(assertSafeProviderUrl('http://[fe80::1]:11434')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('allows private-LAN and loopback Ollama targets', async () => {
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://192.168.1.50:11434/api/chat'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://127.0.0.1:11434/api/tags'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://10.0.0.2:8000/v1/chat/completions'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// IPv6 literals carry brackets in URL.hostname; the guard must strip BOTH
|
||||
// and classify by the real address (catches a missing-/g bracket-strip bug).
|
||||
it('handles bracketed IPv6 literals correctly', async () => {
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://[::1]:11434/api/tags'),
|
||||
).resolves.toBeUndefined(); // loopback allowed
|
||||
await expect(
|
||||
assertSafeProviderUrl('http://[fe80::1]:11434'),
|
||||
).rejects.toThrow(/link-local/); // blocked by isBlockedAddress, not a DNS miss
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { resolveDatabasePath } from '@/lib/db-file';
|
||||
|
||||
// resolveDatabasePath MUST agree with how Prisma resolves DATABASE_URL, or
|
||||
// export-db/import-db operate on a different file than the live DB — the dev
|
||||
// "0-byte export" bug (EVALUATION.md P1). Prisma resolves a relative `file:`
|
||||
// URL against the schema dir (prisma/), not the cwd.
|
||||
|
||||
const original = process.env.DATABASE_URL;
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.DATABASE_URL;
|
||||
else process.env.DATABASE_URL = original;
|
||||
});
|
||||
|
||||
describe('resolveDatabasePath', () => {
|
||||
it('resolves a relative file: URL against prisma/, matching Prisma', () => {
|
||||
process.env.DATABASE_URL = 'file:./data/app.db';
|
||||
expect(resolveDatabasePath()).toBe(
|
||||
path.resolve(process.cwd(), 'prisma', 'data', 'app.db'),
|
||||
);
|
||||
});
|
||||
|
||||
it('strips a leading ./ but keeps the prisma/ anchor', () => {
|
||||
process.env.DATABASE_URL = 'file:data/app.db';
|
||||
expect(resolveDatabasePath()).toBe(
|
||||
path.resolve(process.cwd(), 'prisma', 'data', 'app.db'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an absolute file: path unchanged (production /data)', () => {
|
||||
process.env.DATABASE_URL = 'file:/data/app.db';
|
||||
expect(resolveDatabasePath()).toBe('/data/app.db');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||
getCurrentUserMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/auth', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||
});
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { POST as createConfig } from '@/app/api/ai/configs/route';
|
||||
import { POST as testConfig } from '@/app/api/ai/test/route';
|
||||
import { GET as ollamaModels } from '@/app/api/ai/ollama/models/route';
|
||||
|
||||
// Custom-URL providers (Ollama / OpenAI-compatible) are admin-only — a
|
||||
// non-admin pointing the server at an arbitrary URL is the SSRF actor vector
|
||||
// (EVALUATION.md P1). Fixed-URL cloud providers (claude/openai/gemini) stay
|
||||
// per-user. These tests lock that boundary.
|
||||
|
||||
function jsonReq(url: string, body: unknown): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
async function makeUser(email: string, isAdmin: boolean) {
|
||||
return prisma.user.create({
|
||||
data: { email, passwordHash: 'fake', isAdmin },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.aIConfigProfile.deleteMany();
|
||||
await prisma.userPreferences.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
getCurrentUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe('POST /api/ai/configs — custom-URL providers are admin-only', () => {
|
||||
it('rejects a non-admin creating an ollama (custom-URL) config', async () => {
|
||||
const u = await makeUser('u@x.com', false);
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await createConfig(
|
||||
jsonReq('http://x/api/ai/configs', {
|
||||
provider: 'ollama',
|
||||
model: 'llama3',
|
||||
baseUrl: 'http://ollama.startos:11434',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a non-admin supplying a baseUrl with any provider', async () => {
|
||||
const u = await makeUser('u2@x.com', false);
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await createConfig(
|
||||
jsonReq('http://x/api/ai/configs', {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
baseUrl: 'http://169.254.169.254',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows a non-admin creating a fixed-URL cloud config (claude)', async () => {
|
||||
const u = await makeUser('u3@x.com', false);
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await createConfig(
|
||||
jsonReq('http://x/api/ai/configs', {
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-6',
|
||||
apiKey: 'sk-test',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('allows an admin creating an ollama config', async () => {
|
||||
const a = await makeUser('admin@x.com', true);
|
||||
getCurrentUserMock.mockResolvedValue(a);
|
||||
const res = await createConfig(
|
||||
jsonReq('http://x/api/ai/configs', {
|
||||
provider: 'ollama',
|
||||
model: 'llama3',
|
||||
baseUrl: 'http://ollama.startos:11434',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/ai/test — testing a custom base URL is admin-only', () => {
|
||||
it('rejects a non-admin testing an openai-compatible draft', async () => {
|
||||
getCurrentUserMock.mockResolvedValue({ id: 'u', email: 'u@x.com', isAdmin: false });
|
||||
const res = await testConfig(
|
||||
jsonReq('http://x/api/ai/test', {
|
||||
provider: 'openai-compatible',
|
||||
model: 'x',
|
||||
baseUrl: 'http://192.168.0.1',
|
||||
apiKey: 'k',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/ai/ollama/models — admin-only', () => {
|
||||
it('returns 403 for a non-admin', async () => {
|
||||
getCurrentUserMock.mockResolvedValue({ id: 'u', email: 'u@x.com', isAdmin: false });
|
||||
const res = await ollamaModels(
|
||||
new NextRequest('http://x/api/ai/ollama/models?baseUrl=http://192.168.0.1'),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
const res = await ollamaModels(new NextRequest('http://x/api/ai/ollama/models'));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
PATCH as patchTemplate,
|
||||
DELETE as deleteTemplate,
|
||||
} from '@/app/api/ai/templates/[id]/route';
|
||||
import { GET as getConfig, POST as setConfig } from '@/app/api/ai/config/route';
|
||||
|
||||
function jsonReq(url: string, body?: unknown, method?: string): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
@@ -207,58 +206,3 @@ describe('DELETE /api/ai/templates/[id]', () => {
|
||||
expect(await prisma.aIPromptTemplate.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/ai/config', () => {
|
||||
it('GET returns aiKeyConfigured flag, never the plaintext key', async () => {
|
||||
const me = await prisma.user.create({
|
||||
data: { email: 'me@x', passwordHash: 'fake' },
|
||||
});
|
||||
await prisma.userPreferences.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
aiProvider: 'claude',
|
||||
aiModel: 'claude-sonnet-4-5',
|
||||
aiApiKey: 'sk-ant-secret',
|
||||
},
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(me);
|
||||
const body = await (await getConfig()).json();
|
||||
expect(body.aiProvider).toBe('claude');
|
||||
expect(body.aiModel).toBe('claude-sonnet-4-5');
|
||||
expect(body.aiKeyConfigured).toBe(true);
|
||||
expect(JSON.stringify(body)).not.toContain('sk-ant-secret');
|
||||
});
|
||||
|
||||
it('POST persists provider config', async () => {
|
||||
const me = await prisma.user.create({
|
||||
data: { email: 'me@x', passwordHash: 'fake' },
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(me);
|
||||
const res = await setConfig(
|
||||
jsonReq('http://x/api/ai/config', {
|
||||
aiProvider: 'ollama',
|
||||
aiModel: 'llama3.1:8b',
|
||||
aiBaseUrl: 'http://ollama.embassy:11434',
|
||||
aiApiKey: null,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const prefs = await prisma.userPreferences.findUnique({
|
||||
where: { userId: me.id },
|
||||
});
|
||||
expect(prefs?.aiProvider).toBe('ollama');
|
||||
expect(prefs?.aiBaseUrl).toBe('http://ollama.embassy:11434');
|
||||
expect(prefs?.aiApiKey).toBeNull();
|
||||
});
|
||||
|
||||
it('POST validates provider enum', async () => {
|
||||
const me = await prisma.user.create({
|
||||
data: { email: 'me@x', passwordHash: 'fake' },
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(me);
|
||||
const res = await setConfig(
|
||||
jsonReq('http://x/api/ai/config', { aiProvider: 'made-up-provider' }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||
getCurrentUserMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/auth', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||
});
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GET as exportDb } from '@/app/api/settings/export-db/route';
|
||||
import { POST as importDb } from '@/app/api/settings/import-db/route';
|
||||
|
||||
// The whole-instance DB export/import operate on every user's data (hashes,
|
||||
// plaintext AI keys) and can replace the entire DB. They MUST be admin-only —
|
||||
// see EVALUATION.md P0. These tests lock that gate so it can't silently regress.
|
||||
//
|
||||
// The admin "happy path" cases assert a real downstream status (export 200 /
|
||||
// import 400), not merely "not 401/403" — so they can't pass vacuously if the
|
||||
// route errors before reaching the gate. The export reads the live test DB,
|
||||
// which setup-actions.ts has already created at DATABASE_URL.
|
||||
|
||||
const regularUser = { id: 'u1', email: 'user@example.com', isAdmin: false };
|
||||
const adminUser = { id: 'a1', email: 'admin@example.com', isAdmin: true };
|
||||
|
||||
// POST with no body — formData() throws downstream; only the gate matters here.
|
||||
function emptyImportReq(): NextRequest {
|
||||
return new NextRequest('http://x/api/settings/import-db', {
|
||||
method: 'POST',
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
// POST with a structurally-present but non-SQLite file: passes the gate, then
|
||||
// fails the magic-byte check with a clean 400 — proving the gate was cleared.
|
||||
function badFileImportReq(): NextRequest {
|
||||
const form = new FormData();
|
||||
form.append('database', new File([Buffer.from('not a sqlite db')], 'x.db'));
|
||||
return new NextRequest('http://x/api/settings/import-db', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getCurrentUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe('GET /api/settings/export-db (whole-instance DB)', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
expect((await exportDb()).status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for a non-admin user', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(regularUser);
|
||||
expect((await exportDb()).status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns the DB file (200) for an admin', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(adminUser);
|
||||
const res = await exportDb();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/x-sqlite3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/settings/import-db (whole-instance DB replace)', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
expect((await importDb(emptyImportReq())).status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for a non-admin user', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(regularUser);
|
||||
expect((await importDb(emptyImportReq())).status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets an admin past the gate (400 at the magic-byte check, not 401/403)', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(adminUser);
|
||||
// A non-SQLite file clears the admin gate and is rejected at the magic-byte
|
||||
// check with 400 — unambiguously "past the gate", not a vacuous pass.
|
||||
expect((await importDb(badFileImportReq())).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { v_1_1_0_4 } from './v1.1.0.4'
|
||||
import { v_1_1_0_5 } from './v1.1.0.5'
|
||||
import { v_1_1_0_6 } from './v1.1.0.6'
|
||||
import { v_1_1_0_7 } from './v1.1.0.7'
|
||||
import { v_1_1_0_8 } from './v1.1.0.8'
|
||||
|
||||
/**
|
||||
* Version graph for the `proof-of-work` package.
|
||||
@@ -48,9 +49,12 @@ import { v_1_1_0_7 } from './v1.1.0.7'
|
||||
* v1.1.0:7 — Exercise-history popup auto-loads more rows on scroll
|
||||
* (switched from a flaky IntersectionObserver-in-popup to
|
||||
* a plain scroll listener with 300px lookahead).
|
||||
* v1.1.0:8 — Multi-user authz hardening: whole-instance DB export/import
|
||||
* admin-only; custom-URL AI providers (Ollama / OpenAI-compatible)
|
||||
* admin-only + SSRF guard; dead legacy /api/ai/config removed.
|
||||
*/
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_1_1_0_7,
|
||||
current: v_1_1_0_8,
|
||||
other: [
|
||||
v_1_0_0_1,
|
||||
v_1_0_0_2,
|
||||
@@ -65,5 +69,6 @@ export const versionGraph = VersionGraph.of({
|
||||
v_1_1_0_4,
|
||||
v_1_1_0_5,
|
||||
v_1_1_0_6,
|
||||
v_1_1_0_7,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
/**
|
||||
* v1.1.0:8 — Multi-user authorization hardening (2026-06-13 security batch).
|
||||
*
|
||||
* Closes a P0 multi-tenant breach and the related SSRF surface found in a
|
||||
* full security evaluation (see EVALUATION.md at the repo root):
|
||||
*
|
||||
* - Whole-instance DB export/import (`/api/settings/{export,import}-db`) are
|
||||
* now admin-only. Previously any signed-in user could download the entire
|
||||
* SQLite database (every user's bcrypt hashes + plaintext AI keys) or
|
||||
* replace it wholesale. The per-user CSV export/import stays open to all.
|
||||
* - AI providers that take a custom base URL (Ollama, OpenAI-compatible) are
|
||||
* now admin-only, and all server-side fetches to a user-supplied URL pass
|
||||
* through an SSRF guard that blocks link-local / cloud-metadata addresses
|
||||
* (private-LAN + loopback stay allowed — reaching ollama.startos is the
|
||||
* point). Fixed-URL cloud providers (Claude/OpenAI/Gemini) remain per-user.
|
||||
* The dead legacy `/api/ai/config` route was removed.
|
||||
* - Dev-only: fixed a 0-byte export-db path resolution and the broken
|
||||
* quick-start (added `npm run create-admin`).
|
||||
*
|
||||
* App-code only — no schema, no API contract change for existing data, no
|
||||
* data migration. Existing /data survives untouched.
|
||||
*/
|
||||
export const v_1_1_0_8 = VersionInfo.of({
|
||||
version: '1.1.0:8',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Security hardening. Full-database export and import are now admin-only — previously any signed-in user could download or replace the entire instance database (all users\' data, password hashes, and AI keys). AI providers that use a custom server URL (Ollama, OpenAI-compatible) are now admin-only and protected against requests to internal metadata addresses; the fixed cloud providers (Claude, OpenAI, Gemini) stay available to every user. The per-user CSV export/import is unchanged. No schema or data changes — your existing data is untouched.',
|
||||
},
|
||||
migrations: {
|
||||
up: async () => {},
|
||||
down: IMPOSSIBLE,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user