v1.1.0:9 — P2 hardening: input-validation 400s, auth rate-limit, XFF anti-spoof, non-root container
P2 batch from the 2026-06-13 full-eval (EVALUATION.md / ROADMAP.md), reviewed by the reviewer agent. App-code + packaging only; no schema or data change, existing /data untouched. Input validation: malformed JSON bodies, invalid date, and out-of-range or non-numeric pagination on /api/workouts now return 400 instead of 500. New lib/http.ts readJsonBody maps a bad body to a ZodError across the 11 CRUD routes whose catch maps ZodError to 400; me/import and admin/signups guard request.json() in an explicit try/catch. Rate limiting: POST /api/auth now shares the UI login server action's per-IP 10-per-15min cap and returns 429 + Retry-After. clientIpFromHeaders reads the rightmost (trusted-proxy-appended) X-Forwarded-For entry instead of the spoofable leftmost. Container: drops root. The entrypoint prepares /data as root, chowns it to nextjs, then exec su-exec nextjs:nodejs node server.js (su-exec added to the runner image). The container drop needs live sideload verification.
This commit is contained in:
@@ -54,7 +54,9 @@ FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache dumb-init curl openssl sqlite \
|
||||
# su-exec: drop from root to the unprivileged `nextjs` user at the end of
|
||||
# the entrypoint, after the root-only /data preparation is done.
|
||||
RUN apk add --no-cache dumb-init curl openssl sqlite su-exec \
|
||||
&& addgroup -S nodejs -g 1001 \
|
||||
&& adduser -S nextjs -u 1001 -G nodejs
|
||||
|
||||
|
||||
@@ -330,12 +330,23 @@ if [ -f "$TEMPLATES_JSON_PATH" ] && [ -f "$TEMPLATES_SCRIPT" ] && [ -f "$DB_PATH
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 4 — launch the app.
|
||||
# Step 4 — launch the app as the unprivileged `nextjs` user.
|
||||
#
|
||||
# Everything above runs as root because the entrypoint has to prepare /data
|
||||
# — a StartOS-mounted volume whose runtime ownership we don't control at
|
||||
# build time — by creating the DB, running the ALTERs and reconciling the
|
||||
# library. Now that the data layer is ready we hand /data to `nextjs` and
|
||||
# drop privileges via su-exec, so the long-lived, remote-facing Node server
|
||||
# never runs as root (shrinks the blast radius of any RCE in the app).
|
||||
# -----------------------------------------------------------------------------
|
||||
export DATABASE_URL="file:$DB_PATH"
|
||||
export NODE_ENV="${NODE_ENV:-production}"
|
||||
export HOSTNAME="${HOSTNAME:-0.0.0.0}"
|
||||
export PORT="${PORT:-3000}"
|
||||
|
||||
log "launching Next.js on :${PORT} with DATABASE_URL=file:${DB_PATH}"
|
||||
exec node /app/server.js
|
||||
# Make every file the root-run setup just created in /data writable by the
|
||||
# app user. Guarded so a chown hiccup logs rather than aborts boot.
|
||||
chown -R nextjs:nodejs "$DATA_DIR" 2>/dev/null || log "WARN: could not chown $DATA_DIR; continuing"
|
||||
|
||||
log "launching Next.js on :${PORT} as nextjs with DATABASE_URL=file:${DB_PATH}"
|
||||
exec su-exec nextjs:nodejs node /app/server.js
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
import { v_1_1_0_9 } from './v1.1.0.9'
|
||||
|
||||
/**
|
||||
* Version graph for the `proof-of-work` package.
|
||||
@@ -52,9 +53,12 @@ import { v_1_1_0_8 } from './v1.1.0.8'
|
||||
* 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.
|
||||
* v1.1.0:9 — P2 hardening: malformed-body/invalid-date/bad-pagination ->
|
||||
* 400 (not 500); POST /api/auth rate-limited; rate-limiter XFF
|
||||
* anti-spoof (rightmost entry); container drops root via su-exec.
|
||||
*/
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_1_1_0_8,
|
||||
current: v_1_1_0_9,
|
||||
other: [
|
||||
v_1_0_0_1,
|
||||
v_1_0_0_2,
|
||||
@@ -70,5 +74,6 @@ export const versionGraph = VersionGraph.of({
|
||||
v_1_1_0_5,
|
||||
v_1_1_0_6,
|
||||
v_1_1_0_7,
|
||||
v_1_1_0_8,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
/**
|
||||
* v1.1.0:9 — P2 hardening batch (2026-06-13, follows the :8 security batch).
|
||||
*
|
||||
* Input-validation, rate-limiting and container hardening from the full-eval
|
||||
* P2 queue (see EVALUATION.md / ROADMAP.md):
|
||||
*
|
||||
* - Malformed request bodies now return 400 instead of 500. New
|
||||
* lib/http.ts `readJsonBody` maps a bad JSON body to a ZodError across the
|
||||
* 11 body-parsing CRUD routes (which already map ZodError -> 400);
|
||||
* /api/me/import and /api/admin/signups guard it explicitly (safeParse
|
||||
* style). Invalid `date` and out-of-range/non-numeric pagination on
|
||||
* /api/workouts are likewise 400, not a Prisma 500.
|
||||
* - POST /api/auth (the raw login API) is now rate-limited with the same
|
||||
* per-IP 10/15min cap as the UI login server action, sharing the
|
||||
* `login:${ip}` bucket — previously an uncapped credential-stuffing
|
||||
* surface. Returns 429 + Retry-After.
|
||||
* - The rate limiter's client-IP detection now reads the rightmost
|
||||
* (trusted-proxy-appended) X-Forwarded-For entry instead of the spoofable
|
||||
* leftmost one, so a forged XFF can't rotate the limiter key.
|
||||
* - The container drops root: the entrypoint still prepares /data as root,
|
||||
* then chowns it to `nextjs` and `exec su-exec`s the Node server as the
|
||||
* unprivileged uid 1001 — shrinking the blast radius of any app RCE.
|
||||
*
|
||||
* App-code + packaging only — no schema, no API contract change for existing
|
||||
* data, no data migration. Existing /data survives untouched.
|
||||
*/
|
||||
export const v_1_1_0_9 = VersionInfo.of({
|
||||
version: '1.1.0:9',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Hardening. Bad or malformed requests now return clean validation errors instead of server errors. The login API is rate-limited (matching the web form) and the limiter can no longer be fooled by spoofed forwarding headers. The app container now runs as an unprivileged user instead of root, reducing the impact of any future vulnerability. No schema or data changes — your existing data is untouched.',
|
||||
},
|
||||
migrations: {
|
||||
up: async () => {},
|
||||
down: IMPOSSIBLE,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user