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 { consumeCrmInvite } from '@/lib/services/crm-invite.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 CRM invite token.
const limited = await enforcePublicRateLimit(req, 'portalToken');
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 { 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 });

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import {
generateBrochureStorageKey,
registerBrochureVersion,
@@ -46,11 +46,28 @@ export const GET = withAuth(
}),
);
// Storage keys generated by `generateBrochureStorageKey` look like
// `<portSlug>/brochures/<brochureId>/<uuid>.pdf`. Reject anything else —
// without this, an admin holding manage_settings on port A could ship a
// foreign port's storage key (signed EOI bytes, another port's brochure)
// and have registerBrochureVersion repoint THIS port's brochure version
// at confidential bytes that subsequently serve under brochures.view.
const BROCHURE_KEY_RE =
/^[a-z0-9-]+\/brochures\/[A-Za-z0-9_-]+\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.pdf$/;
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const id = params.id!;
const input = await parseBody(req, registerBrochureVersionSchema);
if (!BROCHURE_KEY_RE.test(input.storageKey)) {
throw new ValidationError('storageKey is not in the expected brochure path');
}
const segments = input.storageKey.split('/');
// segments: [portSlug, 'brochures', brochureId, '<uuid>.pdf']
if (segments[2] !== id) {
throw new ValidationError('storageKey brochureId does not match route param');
}
const data = await registerBrochureVersion({
portId: ctx.portId,
brochureId: id,

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, requireSuperAdmin } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { getRole, updateRole, deleteRole } from '@/lib/services/roles.service';
import { updateRoleSchema } from '@/lib/validators/roles';
@@ -17,35 +17,34 @@ export const GET = withAuth(
}),
);
export const PATCH = withAuth(
withPermission('admin', 'manage_users', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateRoleSchema);
const data = await updateRole(params.id!, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
// Mutations on global roles are super-admin-only — see route.ts header.
export const PATCH = withAuth(async (req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'roles.update');
const body = await parseBody(req, updateRoleSchema);
const data = await updateRole(params.id!, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
});
export const DELETE = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
try {
await deleteRole(params.id!, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'roles.delete');
await deleteRole(params.id!, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, requireSuperAdmin } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { listRoles, createRole } from '@/lib/services/roles.service';
import { createRoleSchema } from '@/lib/validators/roles';
@@ -17,19 +17,22 @@ export const GET = withAuth(
}),
);
export const POST = withAuth(
withPermission('admin', 'manage_users', async (req, ctx) => {
try {
const body = await parseBody(req, createRoleSchema);
const data = await createRole(body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);
// Roles are global (no port_id) and assignments span every port via
// userPortRoles, so creation must be super-admin-only — a per-port admin
// holding admin.manage_users must never be able to mint a role that lives
// in another tenant.
export const POST = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'roles.create');
const body = await parseBody(req, createRoleSchema);
const data = await createRole(body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -34,6 +34,17 @@ export const getHandler: RouteHandler = async (_req, ctx, params) => {
}
};
// Defense against the post-upload register endpoint trusting an arbitrary
// storageKey from the body. The companion presign endpoint always issues
// `berths/<berthId>/uploads/<uuid>_<sanitized>` (see ./pdf-upload-url),
// and pdf-upload-url tenant-scopes the berth lookup. Without this regex,
// a rep with berths.edit could ship the storage key of a foreign-port
// PDF (signed EOI, brochure blob, another port's berth) and have the
// service repoint THIS berth's currentPdfVersionId at it — subsequent
// pdf-download serves those bytes under the rep's own permission gate.
const STORAGE_KEY_RE =
/^berths\/[A-Za-z0-9_-]+\/uploads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/;
export const postHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = (await req.json()) as Partial<PostBody>;
@@ -46,6 +57,12 @@ export const postHandler: RouteHandler = async (req, ctx, params) => {
if (!body.sha256 || typeof body.sha256 !== 'string') {
throw new ValidationError('sha256 is required');
}
const expectedPrefix = `berths/${params.id!}/uploads/`;
if (!body.storageKey.startsWith(expectedPrefix) || !STORAGE_KEY_RE.test(body.storageKey)) {
throw new ValidationError(
'storageKey must come from the matching presign endpoint for this berth',
);
}
const result = await uploadBerthPdf({
berthId: params.id!,
portId: ctx.portId,

View File

@@ -20,7 +20,7 @@ function sanitizeFolderPath(raw: string): string {
}
export const PATCH = withAuth(
withPermission('files', 'edit', async (req, ctx, params) => {
withPermission('files', 'manage_folders', async (req, ctx, params) => {
try {
const pathSegments = params.path;
const currentPath = Array.isArray(pathSegments)

View File

@@ -12,7 +12,7 @@ const createFolderSchema = z.object({
});
export const POST = withAuth(
withPermission('files', 'create', async (req, ctx) => {
withPermission('files', 'manage_folders', async (req, ctx) => {
try {
const body = await parseBody(req, createFolderSchema);

View File

@@ -6,7 +6,7 @@ import { uploadFile } from '@/lib/services/files';
import { uploadFileSchema } from '@/lib/validators/files';
export const POST = withAuth(
withPermission('files', 'create', async (req, ctx) => {
withPermission('files', 'upload', async (req, ctx) => {
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;