fix(audit): rate-limit/DoS — M13 (bulk limiter on 6 routes), M14 (api limiter default in withAuth, fail-open), M15 (export-pdf payload bounds); L21 verified not-a-bug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 13:07:25 +02:00
parent ebe5fe6ed8
commit 64c73a5d77
8 changed files with 518 additions and 457 deletions

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { withAuth, withRateLimit } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { berths, berthTags } from '@/lib/db/schema/berths';
@@ -71,78 +71,80 @@ const PERMISSION_BY_ACTION: Record<
archive: { resource: 'berths', action: 'edit' },
};
export const POST = withAuth(async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
body = await parseBody(req, bulkSchema);
} catch (error) {
return errorResponse(error);
}
const perm = PERMISSION_BY_ACTION[body.action];
const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action];
if (!allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const results: RowResult[] = [];
for (const id of body.ids) {
export const POST = withAuth(
withRateLimit('bulk', async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
if (body.action === 'change_status') {
// Status mutations go through the dedicated path so the under-
// offer / sold transitions can auto-create the primary
// interest_berths link + emit the rules-engine evaluation.
await updateBerthStatus(
id,
ctx.portId,
{ status: body.status, reason: 'Bulk status change' },
meta,
);
} else if (body.action === 'change_tenure_type') {
await updateBerth(id, ctx.portId, { tenureType: body.tenureType }, meta);
} else if (body.action === 'archive') {
await archiveBerth(id, ctx.portId, { reason: body.reason ?? '' }, meta);
} else if (body.action === 'add_tag' || body.action === 'remove_tag') {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, ctx.portId)),
});
if (!berth) {
results.push({ id, ok: false, error: 'Not found' });
continue;
}
// Compose the new tag set, then re-write atomically.
const currentTags = await db
.select({ tagId: berthTags.tagId })
.from(berthTags)
.where(eq(berthTags.berthId, id));
const currentIds = new Set(currentTags.map((t) => t.tagId));
if (body.action === 'add_tag') currentIds.add(body.tagId);
else currentIds.delete(body.tagId);
await setBerthTags(id, ctx.portId, Array.from(currentIds), meta);
}
results.push({ id, ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
results.push({ id, ok: false, error: message });
body = await parseBody(req, bulkSchema);
} catch (error) {
return errorResponse(error);
}
}
const okCount = results.filter((r) => r.ok).length;
return NextResponse.json({
data: {
action: body.action,
total: results.length,
ok: okCount,
failed: results.length - okCount,
results,
},
});
});
const perm = PERMISSION_BY_ACTION[body.action];
const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action];
if (!allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const results: RowResult[] = [];
for (const id of body.ids) {
try {
if (body.action === 'change_status') {
// Status mutations go through the dedicated path so the under-
// offer / sold transitions can auto-create the primary
// interest_berths link + emit the rules-engine evaluation.
await updateBerthStatus(
id,
ctx.portId,
{ status: body.status, reason: 'Bulk status change' },
meta,
);
} else if (body.action === 'change_tenure_type') {
await updateBerth(id, ctx.portId, { tenureType: body.tenureType }, meta);
} else if (body.action === 'archive') {
await archiveBerth(id, ctx.portId, { reason: body.reason ?? '' }, meta);
} else if (body.action === 'add_tag' || body.action === 'remove_tag') {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, ctx.portId)),
});
if (!berth) {
results.push({ id, ok: false, error: 'Not found' });
continue;
}
// Compose the new tag set, then re-write atomically.
const currentTags = await db
.select({ tagId: berthTags.tagId })
.from(berthTags)
.where(eq(berthTags.berthId, id));
const currentIds = new Set(currentTags.map((t) => t.tagId));
if (body.action === 'add_tag') currentIds.add(body.tagId);
else currentIds.delete(body.tagId);
await setBerthTags(id, ctx.portId, Array.from(currentIds), meta);
}
results.push({ id, ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
results.push({ id, ok: false, error: message });
}
}
const okCount = results.filter((r) => r.ok).length;
return NextResponse.json({
data: {
action: body.action,
total: results.length,
ok: okCount,
failed: results.length - okCount,
results,
},
});
}),
);