fix(security): tier-0 audit blockers (next CVE, role gate, perm traps, key validation, rate limits)

Closes the five highest-risk findings from
docs/audit-comprehensive-2026-05-05.md so the platform is not exposed
while the rest of the audit backlog (1 CRIT + 18 HIGH + 32 MED + 23 LOW)
is worked through:

* CVE-2025-29927 — bump next 15.1.0 → 15.2.9; nginx strips
  X-Middleware-Subrequest at the edge as defense-in-depth.
* Cross-tenant role escalation — POST/PATCH/DELETE on /admin/roles now
  require super-admin (was: any holder of admin.manage_users).  Adds
  shared `requireSuperAdmin(ctx)` helper.
* Silent-403 traps — `documents.edit` and `files.edit` keys added to
  RolePermissions; seeded role values updated; migration 0041 backfills
  the new keys on every existing roles+port_role_overrides JSONB.  File
  routes remap the dead `create` action to `upload` / `manage_folders`.
* Berth-PDF / brochure register endpoints — reject body.storageKey
  unless it matches the namespace the matching presign endpoint issued
  (prevents repointing a tenant's PDF at foreign-port bytes).
* Portal auth rate limits — sign-in 5/15min/(ip,email),
  forgot-password 3/hr/IP, activate/reset/set-password 10/hr/IP.  Adds
  `enforcePublicRateLimit()` for non-`withAuth` routes.

Test status unchanged: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md (CRITICAL, HIGH §§1–4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 18:33:13 +02:00
parent 4723994bdc
commit 312779c0c5
24 changed files with 1489 additions and 126 deletions

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { activateAccount } from '@/lib/services/portal-auth.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({
token: z.string().min(1),
@@ -10,6 +11,10 @@ const bodySchema = z.object({
});
export async function POST(req: NextRequest): Promise<NextResponse> {
// 10/hour/IP — bounds brute-force against the 32-byte activation token.
const limited = await enforcePublicRateLimit(req, 'portalToken');
if (limited) return limited;
let body: unknown;
try {
body = await req.json();

View File

@@ -3,10 +3,17 @@ import { z } from 'zod';
import { logger } from '@/lib/logger';
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({ email: z.string().email() });
export async function POST(req: NextRequest): Promise<NextResponse> {
// 3/hour/IP — tightest of the portal limiters because each successful
// call sends an outbound email and timing differences here are the
// primary email-enumeration vector.
const limited = await enforcePublicRateLimit(req, 'portalForgot');
if (limited) return limited;
let body: unknown;
try {
body = await req.json();

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { resetPassword } from '@/lib/services/portal-auth.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({
token: z.string().min(1),
@@ -10,6 +11,10 @@ const bodySchema = z.object({
});
export async function POST(req: NextRequest): Promise<NextResponse> {
// 10/hour/IP — bounds brute-force against the 32-byte reset token.
const limited = await enforcePublicRateLimit(req, 'portalToken');
if (limited) return limited;
let body: unknown;
try {
body = await req.json();

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { PORTAL_COOKIE } from '@/lib/portal/auth';
import { signIn } from '@/lib/services/portal-auth.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({
email: z.string().email(),
@@ -17,14 +18,24 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 400 });
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
}
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
// the limiter is per-account-per-IP, not just per-IP — a NATed network
// shouldn't be able to lock a single victim by burning their bucket.
const limited = await enforcePublicRateLimit(
req,
'portalSignIn',
parsed.data.email.toLowerCase(),
);
if (limited) return limited;
try {
const result = await signIn(parsed.data);
const res = NextResponse.json({ success: true });