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