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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user