v1.1.0:9 — P2 hardening: input-validation 400s, auth rate-limit, XFF anti-spoof, non-root container
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled

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:
Keysat
2026-06-13 00:03:47 -05:00
parent 988a3cca9a
commit 3f22ef7600
23 changed files with 365 additions and 41 deletions
+16 -1
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyPassword, createSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { readJsonBody } from '@/lib/http';
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
const loginSchema = z.object({
email: z.string().email(),
@@ -10,7 +12,20 @@ const loginSchema = z.object({
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Per-IP cap, sharing the `login:${ip}` bucket with the UI login
// server action (app/auth/login/actions.ts): 10 attempts / 15 min.
// Without this the raw API endpoint is an uncapped credential-stuffing
// surface that bypasses the server-action's limiter.
const ip = clientIpFromHeaders(request.headers);
const limited = rateLimit(`login:${ip}`, { limit: 10, windowMs: 15 * 60_000 });
if (!limited.ok) {
return NextResponse.json(
{ error: `Too many login attempts. Try again in ${limited.retryAfterSec}s.` },
{ status: 429, headers: { 'Retry-After': String(limited.retryAfterSec) } }
);
}
const body = await readJsonBody(request);
const { email, password } = loginSchema.parse(body);
// Look up user by email
@@ -1,5 +1,6 @@
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
@@ -118,7 +119,7 @@ export async function PATCH(
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
}
const body = await request.json();
const body = await readJsonBody(request);
const validated = updateExerciseSchema.parse(body);
const data: any = {};
+2 -1
View File
@@ -1,6 +1,7 @@
import { getCurrentUser } from "@/lib/auth";
import { getExercises, createExercise } from "@/lib/db/exercises";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
@@ -60,7 +61,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const body = await readJsonBody(request);
const validated = CreateExerciseSchema.parse(body);
const existing = await prisma.exercise.findUnique({
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
const SeedExerciseSchema = z.object({
name: z.string().min(1),
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const body = await readJsonBody(request);
const parsed = SeedPayloadSchema.parse(body);
const existingExercises = await prisma.exercise.findMany({
+9 -1
View File
@@ -92,7 +92,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// This route uses safeParse (not an `instanceof z.ZodError` catch), so a
// malformed body would otherwise reach the generic catch as a 500. Guard
// it explicitly — matches the pattern in app/api/admin/signups/route.ts.
let body: any;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const parsed = requestBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
+2 -1
View File
@@ -1,5 +1,6 @@
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
@@ -61,7 +62,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const body = await readJsonBody(request);
const validated = PreferencesSchema.parse(body);
let preferences = await prisma.userPreferences.findUnique({
+2 -1
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { getProgramById } from "@/lib/db/programs";
/**
@@ -80,7 +81,7 @@ export async function PATCH(
return NextResponse.json({ error: "Program not found" }, { status: 404 });
}
const body = await request.json();
const body = await readJsonBody(request);
const validated = patchSchema.parse(body);
// If replacing the tree, verify exercise ownership.
+2 -1
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { getPrograms } from "@/lib/db/programs";
/**
@@ -61,7 +62,7 @@ export async function POST(request: NextRequest) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const body = await readJsonBody(request);
const validated = createProgramSchema.parse(body);
// Verify any referenced exerciseIds belong to this user.
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { z } from "zod";
// GET: Get workout by ID
@@ -95,7 +96,7 @@ export async function PATCH(
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const body = await request.json();
const body = await readJsonBody(request);
const validated = updateWorkoutSchema.parse(body);
const workoutData: Record<string, unknown> = {};
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
import { z } from "zod";
const addSetsSchema = z.object({
@@ -46,7 +47,7 @@ export async function POST(
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const body = await request.json();
const body = await readJsonBody(request);
const validated = addSetsSchema.parse(body);
// Delete existing sets for this exercise in this workout (replace mode)
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
const setSchema = z.object({
reps: z.number().int().positive().optional(),
@@ -40,7 +41,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const body = await readJsonBody(request);
const validated = saveImportSchema.parse(body);
// Load all user exercises for matching
+25 -4
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readJsonBody } from "@/lib/http";
// Schema now supports creating empty workouts (just date) or with sets
const createWorkoutSchema = z.object({
@@ -11,7 +12,10 @@ const createWorkoutSchema = z.object({
durationMinutes: z.number().int().positive().optional(),
difficulty: z.number().int().min(1).max(10).optional(),
caloriesBurned: z.number().int().positive().optional(),
date: z.string().optional(), // ISO date string or date-only string
date: z
.string()
.refine((s) => !Number.isNaN(Date.parse(s)), { message: "Invalid date" })
.optional(), // ISO date string or date-only string
sets: z
.array(
z.object({
@@ -45,8 +49,25 @@ export async function GET(request: NextRequest) {
const query = searchParams.get("q");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
const offset = parseInt(searchParams.get("offset") || "0");
// Validate pagination up front: a negative offset or non-numeric value
// would otherwise reach Prisma's `skip`/`take` and throw a generic 500.
const pagination = z
.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
})
.safeParse({
limit: searchParams.get("limit") || undefined,
offset: searchParams.get("offset") || undefined,
});
if (!pagination.success) {
return NextResponse.json(
{ error: "Invalid pagination parameters", details: pagination.error.errors },
{ status: 400 }
);
}
const { limit, offset } = pagination.data;
const where: Prisma.WorkoutWhereInput = {
userId: user.id,
@@ -116,7 +137,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const body = await readJsonBody(request);
const validated = createWorkoutSchema.parse(body);
const workoutDate = validated.date ? new Date(validated.date) : new Date();
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
/**
* Parse a JSON request body, turning a malformed or empty body into a
* `ZodError` rather than letting the raw `SyntaxError` from `request.json()`
* fall through a route's generic `catch` and become an HTTP 500.
*
* Why a `ZodError` specifically: every body-parsing route already maps
* `instanceof z.ZodError` to a 400. Throwing one here means a malformed body
* returns 400 across all of them with no per-route catch changes — the call
* site swaps `request.json()` for `readJsonBody(request)` and nothing else
* moves. (It is a genuine `z.ZodError`, so the `instanceof` checks hold.)
*/
export async function readJsonBody(request: Request): Promise<unknown> {
try {
return await request.json();
} catch {
throw new z.ZodError([
{
code: z.ZodIssueCode.custom,
path: [],
message: "Request body must be valid JSON",
},
]);
}
}
+26 -8
View File
@@ -44,19 +44,37 @@ export function rateLimit(
}
/**
* Best-effort client IP extraction. In a StartOS deployment the Next.js
* server sits behind a single proxy hop, so the leftmost
* `x-forwarded-for` entry is the originating client. If headers are
* absent (direct access in dev), fall back to the literal "unknown" key
* so the limiter still applies as a global rate cap.
* Best-effort client IP extraction for rate-limit keys.
*
* `X-Forwarded-For` is a client-appendable, comma-separated list: each proxy
* APPENDS the address it observed. A direct client can therefore forge any
* number of leftmost entries — using `xff.split(',')[0]` (the leftmost) lets
* an attacker rotate a fake IP per request and defeat the limiter entirely.
*
* In a StartOS deployment the Next.js server sits behind exactly one trusted
* proxy hop, so the RIGHTMOST entry is the address that proxy actually saw —
* the only value the client cannot spoof. We key off that. (If the proxy
* overwrites rather than appends XFF, the list has a single entry and
* rightmost == leftmost, so this is also correct in that case.) If XFF is
* absent (direct access in dev), fall back to `x-real-ip`, then to the
* literal "unknown" key so the limiter still applies as a global cap.
*
* Assumes a single trusted hop; if the deployment ever grows additional
* trusted proxies, count that many entries in from the right instead.
*/
export function clientIpFromHeaders(headers: Headers): string {
const xff = headers.get('x-forwarded-for');
if (xff) {
const first = xff.split(',')[0]?.trim();
if (first) return first;
const parts = xff
.split(',')
.map((p) => p.trim())
.filter(Boolean);
if (parts.length > 0) return parts[parts.length - 1];
}
const real = headers.get('x-real-ip');
if (real) return real;
if (real) {
const trimmed = real.trim();
if (trimmed) return trimmed;
}
return 'unknown';
}
+14 -2
View File
@@ -40,9 +40,21 @@ describe('rateLimit', () => {
});
describe('clientIpFromHeaders', () => {
it('uses the leftmost x-forwarded-for entry', () => {
it('uses the rightmost (single trusted proxy hop) x-forwarded-for entry', () => {
const h = new Headers({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' });
expect(clientIpFromHeaders(h)).toBe('1.2.3.4');
expect(clientIpFromHeaders(h)).toBe('5.6.7.8');
});
it('ignores a client-spoofed leftmost entry', () => {
// Attacker sends `X-Forwarded-For: evil`; the trusted proxy appends the
// real client address, which must win.
const h = new Headers({ 'x-forwarded-for': 'evil.spoofed, 203.0.113.9' });
expect(clientIpFromHeaders(h)).toBe('203.0.113.9');
});
it('handles a single-entry x-forwarded-for', () => {
const h = new Headers({ 'x-forwarded-for': '203.0.113.9' });
expect(clientIpFromHeaders(h)).toBe('203.0.113.9');
});
it('falls back to x-real-ip', () => {
@@ -21,6 +21,14 @@ function jsonReq(body: unknown): NextRequest {
} as ConstructorParameters<typeof NextRequest>[1]);
}
function rawReq(rawBody: string): NextRequest {
return new NextRequest('http://x/api/me/import', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: rawBody,
} as ConstructorParameters<typeof NextRequest>[1]);
}
async function makeUser(opts: { email: string }) {
return prisma.user.create({
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
@@ -95,6 +103,13 @@ describe('POST /api/me/import', () => {
expect(res.status).toBe(400);
});
it('returns 400 (not 500) on a malformed JSON body', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
const res = await importPost(rawReq('{ not valid json'));
expect(res.status).toBe(400);
});
it('merge mode imports exercises and workouts attributed to the actor', async () => {
const u = await makeUser({ email: 'a@x' });
getCurrentUserMock.mockResolvedValue(u);
@@ -0,0 +1,141 @@
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 };
});
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { POST as postWorkout, GET as getWorkouts } from '@/app/api/workouts/route';
import { POST as postAuth } from '@/app/api/auth/route';
function jsonReq(url: string, body?: unknown, init?: RequestInit): NextRequest {
const opts: RequestInit = {
method: body !== undefined ? 'POST' : 'GET',
headers: { 'content-type': 'application/json' },
...init,
};
if (body !== undefined) {
opts.body = JSON.stringify(body);
}
return new NextRequest(url, opts as ConstructorParameters<typeof NextRequest>[1]);
}
/** A request carrying a raw (possibly malformed) string body. */
function rawReq(url: string, rawBody: string, init?: RequestInit): NextRequest {
return new NextRequest(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: rawBody,
...init,
} as ConstructorParameters<typeof NextRequest>[1]);
}
async function makeUser(email: string) {
return prisma.user.create({
data: { email, passwordHash: 'fake', isAdmin: false },
});
}
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.exercise.deleteMany();
await prisma.workout.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
getCurrentUserMock.mockReset();
});
describe('malformed request bodies → 400 (not 500)', () => {
it('POST /api/workouts returns 400 on a body that is not valid JSON', async () => {
const alice = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(rawReq('http://x/api/workouts', '{ not valid json'));
expect(res.status).toBe(400);
});
it('POST /api/workouts returns 400 on an empty body', async () => {
const alice = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(rawReq('http://x/api/workouts', ''));
expect(res.status).toBe(400);
});
});
describe('POST /api/workouts date validation', () => {
it('returns 400 on an unparseable date string', async () => {
const alice = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(
jsonReq('http://x/api/workouts', { name: 'X', date: 'not-a-date' }),
);
expect(res.status).toBe(400);
});
it('accepts a valid date-only string', async () => {
const alice = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(alice);
const res = await postWorkout(
jsonReq('http://x/api/workouts', { name: 'X', date: '2026-06-01' }),
);
expect(res.status).toBe(201);
});
});
describe('GET /api/workouts pagination validation', () => {
it('returns 400 on a negative offset (was a Prisma 500)', async () => {
const alice = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(alice);
const res = await getWorkouts(jsonReq('http://x/api/workouts?offset=-5'));
expect(res.status).toBe(400);
});
it('returns 400 on a non-numeric limit', async () => {
const alice = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(alice);
const res = await getWorkouts(jsonReq('http://x/api/workouts?limit=abc'));
expect(res.status).toBe(400);
});
it('returns 400 on limit=0 (new min-1 contract)', async () => {
const alice = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(alice);
const res = await getWorkouts(jsonReq('http://x/api/workouts?limit=0'));
expect(res.status).toBe(400);
});
it('still serves valid pagination parameters', async () => {
const alice = await makeUser('a@x');
getCurrentUserMock.mockResolvedValue(alice);
const res = await getWorkouts(jsonReq('http://x/api/workouts?limit=10&offset=0'));
expect(res.status).toBe(200);
});
});
describe('POST /api/auth rate limiting', () => {
it('returns 429 + Retry-After after 10 attempts from one IP', async () => {
// Unique key per run so the process-global limiter bucket is clean.
const ip = `validation-test-${Math.random()}`;
const attempt = () =>
postAuth(
new NextRequest('http://x/api/auth', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify({ email: 'nobody@example.com', password: 'x' }),
} as ConstructorParameters<typeof NextRequest>[1]),
);
for (let i = 0; i < 10; i++) {
const res = await attempt();
expect(res.status).toBe(401); // no such user
}
const blocked = await attempt();
expect(blocked.status).toBe(429);
expect(blocked.headers.get('retry-after')).toBeTruthy();
});
});