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