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:
Keysat
2026-06-15 13:36:40 -05:00
parent 755b100f00
commit d0e98424c1
11 changed files with 317 additions and 49 deletions
+17 -1
View File
@@ -116,6 +116,18 @@ import { buildTenantAuthMiddleware } from "./tenant-auth.js";
const execFileAsync = promisify(execFile);
const app = express();
// Trust the operator's reverse proxy (StartOS / StartTunnel, or a cloud proxy)
// so req.ip is the real client address rather than a client-spoofable
// X-Forwarded-For entry. The value is how many trusted proxies sit in front of
// this process — default 1 (the StartOS/StartTunnel hop). Erring low is safe
// (it can only over-count clients onto one IP, hitting the trial cap sooner);
// erring high would re-open the trial-cap bypass. Override via
// RECAP_TRUSTED_PROXY_HOPS (0 = no proxy in front; use the socket address only).
const hopsParsed = parseInt(process.env.RECAP_TRUSTED_PROXY_HOPS, 10);
const trustedProxyHops =
Number.isInteger(hopsParsed) && hopsParsed >= 0 ? hopsParsed : 1;
app.set("trust proxy", trustedProxyHops);
const PORT = process.env.PORT || 3001;
// ── Multi-tenant mode toggle ────────────────────────────────────────────
@@ -2618,7 +2630,11 @@ app.post("/api/process", async (req, res) => {
// through the relay. Non-relay providers ignore this opt.
const jobId = randomUUID();
const isFree = isFreeUser();
// The free-tier single-flight lock is a single-mode concept (one operator,
// BYO key, one job at a time). In multi mode, per-tenant credit metering is
// the resource control, so a process-global lock would wrongly serialize
// every tenant onto one job at a time — never apply it there.
const isFree = req.recapMode !== "multi" && isFreeUser();
if (isFree) {
if (!tryAcquireFreeSlot({ url, title: itemTitle, abortController })) {
const current = getCurrentFreeJob();