Fix five P0/P1 security & correctness findings from the full-eval
- Arbitrary file write (P0): validate import keys in /api/library/import via a now-exported safeFilename(); a ../../ key is skipped, not written out of the scope dir. - SSRF (P0): guard downloadPodcastAudio — reject non-HTTP(S) schemes, block IP-literal and DNS-resolved private/link-local/loopback/reserved/multicast and embedded-IPv4 IPv6 targets (closes DNS rebinding), cap + resolve redirects. - ESM require (P1): top-level import of randomBytes in license-purchase.js (the inner require threw on the anon purchase-settle path). - Concurrency lock (P1): skip the process-global free-tier slot in multi-mode so it no longer serializes every cloud tenant onto one job. - X-Forwarded-For bypass (P1): set Express trust proxy from RECAP_TRUSTED_PROXY_HOPS (default 1); getClientIp now reads req.ip instead of a client-spoofable XFF entry. Tests added for safeFilename, the SSRF guard, and getClientIp (119 pass). Registry blockers deferred (ROADMAP); leaked-key history purge queued.
This commit is contained in:
@@ -30,7 +30,7 @@ Run from repo root unless noted.
|
||||
| Type-check (StartOS TS) | `npm run check` *(repo root; runs `tsc --noEmit` over `startos/**/*.ts`. The `server/` is plain JS and is not type-checked.)* |
|
||||
| Format (StartOS TS) | `npm run prettier` *(repo root; `prettier --write startos`. There is **no** ESLint/linter — `server/` JS is untooled. Many `startos/versions/*.ts` are currently unformatted.)* |
|
||||
|
||||
Mode is selected at boot via the `RECAP_MODE` env var: `single` (default) or `multi`.
|
||||
Mode is selected at boot via the `RECAP_MODE` env var: `single` (default) or `multi`. Other runtime env var of note: `RECAP_TRUSTED_PROXY_HOPS` (default `1`) — how many trusted reverse proxies sit in front of the app, so the anonymous-trial per-IP cap reads the real client IP from `X-Forwarded-For` (set `0` if the app is directly internet-facing, `2`+ behind a CDN/LB; setting it too high re-opens the trial-cap bypass).
|
||||
|
||||
## Directory layout
|
||||
|
||||
@@ -137,15 +137,15 @@ unsure whether a change is contract-affecting, assume it is and check.
|
||||
|
||||
### Evaluation work queue (P0/P1) — from the 2026-06-14 full-eval (`EVALUATION.md`)
|
||||
|
||||
Fix before exposing the cloud to untrusted users. The P0s are reproduced/verified, not theoretical.
|
||||
Five of seven items FIXED + tested (2026-06-15, reviewer-checked); the leaked-key purge awaits operator confirmation and the registry blockers are deferred.
|
||||
|
||||
- **[P0] Arbitrary file write — `POST /api/library/import`.** A `../../` session key escapes the scope dir (reproduced writing `/tmp/rce_test.json`). `server/library.js:131-139` uses the key as a filename without `safeFilename()`. Fix: export `safeFilename()` from `history.js` (it's currently module-private — see `:656`) and validate the key here plus in the array-import and `PUT /api/history/move` paths.
|
||||
- **[P0] SSRF with read-back — podcast download.** An anonymous trial can POST `{type:"podcast", url:"http://169.254.169.254/..."}`; `downloadPodcastAudio` (`server/audio.js:78-97`, reached from `index.js:3455`) does an unguarded `http.get`, follows redirects to any host, and the body is transcribed back to the attacker. Fix: reject private/link-local/loopback/reserved IPs, https-only, re-validate on each redirect, add a size/time cap.
|
||||
- **[P0] Live Gemini key in git history, still the active key** — `git show d5046a0:.env`, pushed to `origin/master`. Rotate the key in Google AI Studio now; then purge from history (BFG/filter-repo) and force-push.
|
||||
- **[P1] ESM `require("crypto")` throws** `ReferenceError` on the anon license-purchase settle path — `server/license-purchase.js:423` (called from `:353-354`). Import `randomBytes`/`randomUUID` at the top of the file.
|
||||
- **[P1] Global `currentFreeJob` lock serializes the entire multi-tenant cloud.** `isFreeUser()` returns true for every tenant in multi-mode, so a 2nd concurrent `/api/process` from any user gets `409`. `server/index.js:2621`, `license-middleware.js:143`. Scope the slot per-identity, or skip it in multi-mode.
|
||||
- **[P1] Trial IP-cap + magic-link rate-limit bypass via spoofed `X-Forwarded-For`** (no `trust proxy` / XFF normalization) — `server/anon-trial.js:50-57`, `auth-routes.js:209`. Deployment-dependent: confirm the recaps.cc edge proxy *overwrites* XFF and trust only the last hop. Self-hosted StartOS is unaffected.
|
||||
- **[P1] StartOS registry submission BLOCKED** (3 blockers) — missing root `instructions.md`; `packageRepo`/`upstreamRepo` point to `https://ten31.xyz` (a homepage, not a source repo); `license: 'Proprietary'` fails the "source available" gate (`startos/manifest/index.ts`). **Only blocks a community-registry submission — does NOT affect `make install`.**
|
||||
- **[P0] ✅ FIXED — arbitrary file write in `POST /api/library/import`.** `safeFilename()` is now exported from `history.js` and validates each import key (`server/library.js`); a `../../` key is skipped, not written outside the scope dir. Tests in `test/history.test.js`. *(Adjacent P2 still open: array-form import + `PUT /api/history/move` write unvalidated IDs into `_meta.json` — no file-path escape, and read-time `safeFilename` guards the load. See Known debt.)*
|
||||
- **[P0] ✅ FIXED — SSRF in podcast download.** `downloadPodcastAudio` (`server/audio.js`) rejects non-HTTP(S) schemes and blocks IP-literal AND DNS-resolved private/link-local/loopback/reserved/multicast/translation-prefix targets (closing the DNS-rebinding window), caps + resolves redirects. Tests in `test/audio.test.js`. *(Response size/time cap still deferred — ROADMAP P3.)*
|
||||
- **[P0] ⏳ Leaked Gemini key in git history** (`git show d5046a0:.env`). Operator to rotate in Google AI Studio (recommended, not strictly required since the repo was never shared); git-history purge via `git filter-repo --path .env --invert-paths` + force-push to Gitea is queued and runs on the operator's go-ahead.
|
||||
- **[P1] ✅ FIXED — ESM `require("crypto")`.** Replaced with a top-level `import { randomBytes }` in `server/license-purchase.js`.
|
||||
- **[P1] ✅ FIXED — global `currentFreeJob` lock serialized the whole cloud.** Now skipped in multi-mode (`server/index.js`: `const isFree = req.recapMode !== "multi" && isFreeUser()`); per-tenant credit metering is the control there.
|
||||
- **[P1] ✅ FIXED — `X-Forwarded-For` trial-cap bypass.** `app.set("trust proxy", …)` from `RECAP_TRUSTED_PROXY_HOPS` (default `1`) + `getClientIp` now returns `req.ip` (`server/index.js`, `server/anon-trial.js`). Tests in `test/anon-trial.test.js`. **Watch:** if the cloud ever gains a CDN/LB hop, bump `RECAP_TRUSTED_PROXY_HOPS` or the bypass reopens.
|
||||
- *(StartOS registry-submission blockers — deferred by decision 2026-06-15; moved to `ROADMAP.md`. They never affected `make install`.)*
|
||||
|
||||
### Known debt (P2) — track; not release-blocking for self-host
|
||||
|
||||
|
||||
Reference in New Issue
Block a user