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 { z } from 'zod';
|
||||||
import { and, eq } from 'drizzle-orm';
|
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 { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { berths, berthTags } from '@/lib/db/schema/berths';
|
import { berths, berthTags } from '@/lib/db/schema/berths';
|
||||||
@@ -71,7 +71,8 @@ const PERMISSION_BY_ACTION: Record<
|
|||||||
archive: { resource: 'berths', action: 'edit' },
|
archive: { resource: 'berths', action: 'edit' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(
|
||||||
|
withRateLimit('bulk', async (req, ctx) => {
|
||||||
let body: z.infer<typeof bulkSchema>;
|
let body: z.infer<typeof bulkSchema>;
|
||||||
try {
|
try {
|
||||||
body = await parseBody(req, bulkSchema);
|
body = await parseBody(req, bulkSchema);
|
||||||
@@ -145,4 +146,5 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
results,
|
results,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth, withRateLimit } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { runBulk } from '@/lib/api/bulk-helpers';
|
import { runBulk } from '@/lib/api/bulk-helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
@@ -48,7 +48,8 @@ const PERMISSION_BY_ACTION = {
|
|||||||
remove_tag: 'edit' as const,
|
remove_tag: 'edit' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(
|
||||||
|
withRateLimit('bulk', async (req, ctx) => {
|
||||||
let body: z.infer<typeof bulkSchema>;
|
let body: z.infer<typeof bulkSchema>;
|
||||||
try {
|
try {
|
||||||
body = await parseBody(req, bulkSchema);
|
body = await parseBody(req, bulkSchema);
|
||||||
@@ -218,4 +219,5 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ data: { results, summary } });
|
return NextResponse.json({ data: { results, summary } });
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth, withRateLimit } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { runBulk } from '@/lib/api/bulk-helpers';
|
import { runBulk } from '@/lib/api/bulk-helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
@@ -33,7 +33,8 @@ const PERMISSION_BY_ACTION = {
|
|||||||
remove_tag: 'edit' as const,
|
remove_tag: 'edit' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(
|
||||||
|
withRateLimit('bulk', async (req, ctx) => {
|
||||||
let body: z.infer<typeof bulkSchema>;
|
let body: z.infer<typeof bulkSchema>;
|
||||||
try {
|
try {
|
||||||
body = await parseBody(req, bulkSchema);
|
body = await parseBody(req, bulkSchema);
|
||||||
@@ -73,4 +74,5 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ data: { results, summary } });
|
return NextResponse.json({ data: { results, summary } });
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth, withRateLimit } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
@@ -62,7 +62,8 @@ const PERMISSION_BY_ACTION: Record<
|
|||||||
archive: { resource: 'interests', action: 'delete' },
|
archive: { resource: 'interests', action: 'delete' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(
|
||||||
|
withRateLimit('bulk', async (req, ctx) => {
|
||||||
let body: z.infer<typeof bulkSchema>;
|
let body: z.infer<typeof bulkSchema>;
|
||||||
try {
|
try {
|
||||||
body = await parseBody(req, bulkSchema);
|
body = await parseBody(req, bulkSchema);
|
||||||
@@ -127,4 +128,5 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json({ data: { results, summary } });
|
return NextResponse.json({ data: { results, summary } });
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -24,9 +24,27 @@ import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report';
|
|||||||
* option) so we don't have to keep adding routes per report kind.
|
* option) so we don't have to keep adding routes per report kind.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// M15: this route renders a fully client-supplied payload synchronously
|
||||||
|
// via `renderToBuffer` on the request thread, gated only by
|
||||||
|
// `reports.view_dashboard`. Without hard bounds an authed user can POST
|
||||||
|
// a huge payload and OOM/stall the Node process. The async worker path
|
||||||
|
// caps rows at REPORT_ROW_CAP (1000); mirror that here, and additionally
|
||||||
|
// cap the section count, per-section column count, and the total cell
|
||||||
|
// budget across all sections so a fan-out of many small sections can't
|
||||||
|
// dodge the per-section row cap.
|
||||||
|
const REPORT_ROW_CAP = 1_000;
|
||||||
|
const MAX_SECTIONS = 50;
|
||||||
|
const MAX_COLUMNS = 50;
|
||||||
|
const MAX_KPIS = 100;
|
||||||
|
/** Upper bound on total rendered table cells (rows × columns, summed
|
||||||
|
* across every section). Sized so the worst case stays well within the
|
||||||
|
* per-section caps but bounds the aggregate render cost. */
|
||||||
|
const MAX_TOTAL_CELLS = 200_000;
|
||||||
|
|
||||||
// Minimal shape validation — full ReportPayload is structurally typed
|
// Minimal shape validation — full ReportPayload is structurally typed
|
||||||
// in TS; here we just check it has the basic envelope.
|
// in TS; here we just check it has the basic envelope.
|
||||||
const payloadSchema = z.object({
|
const payloadSchema = z
|
||||||
|
.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
filenameSlug: z.string().min(1),
|
filenameSlug: z.string().min(1),
|
||||||
@@ -34,30 +52,49 @@ const payloadSchema = z.object({
|
|||||||
from: z.string().datetime(),
|
from: z.string().datetime(),
|
||||||
to: z.string().datetime(),
|
to: z.string().datetime(),
|
||||||
}),
|
}),
|
||||||
kpis: z.array(
|
kpis: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
value: z.union([z.string(), z.number()]),
|
value: z.union([z.string(), z.number()]),
|
||||||
hint: z.string().optional(),
|
hint: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
sections: z.array(
|
.max(MAX_KPIS),
|
||||||
|
sections: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
columns: z.array(
|
columns: z
|
||||||
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
align: z.enum(['left', 'right', 'center']).optional(),
|
align: z.enum(['left', 'right', 'center']).optional(),
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
rows: z.array(z.record(z.string(), z.unknown())),
|
.max(MAX_COLUMNS),
|
||||||
|
rows: z.array(z.record(z.string(), z.unknown())).max(REPORT_ROW_CAP),
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.max(MAX_SECTIONS),
|
||||||
/** Optional filename override (without extension) — the client
|
/** Optional filename override (without extension) — the client
|
||||||
* passes the slug derived from the custom title. */
|
* passes the slug derived from the custom title. */
|
||||||
filenameOverride: z.string().optional(),
|
filenameOverride: z.string().optional(),
|
||||||
});
|
})
|
||||||
|
// Total-cell budget: the per-section `.max()` caps bound each section,
|
||||||
|
// but a payload could still fan out MAX_SECTIONS × REPORT_ROW_CAP ×
|
||||||
|
// MAX_COLUMNS cells. Reject any payload whose summed cell count exceeds
|
||||||
|
// the aggregate budget before it reaches the synchronous renderer.
|
||||||
|
.refine(
|
||||||
|
(p) =>
|
||||||
|
p.sections.reduce((total, s) => total + s.rows.length * Math.max(1, s.columns.length), 0) <=
|
||||||
|
MAX_TOTAL_CELLS,
|
||||||
|
{
|
||||||
|
message: `Report payload exceeds the maximum of ${MAX_TOTAL_CELLS} total cells`,
|
||||||
|
path: ['sections'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth, withRateLimit } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
||||||
@@ -46,7 +46,8 @@ const PERMISSION_BY_ACTION: Record<
|
|||||||
archive: { resource: 'residential_interests', action: 'delete' },
|
archive: { resource: 'residential_interests', action: 'delete' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(
|
||||||
|
withRateLimit('bulk', async (req, ctx) => {
|
||||||
let body: z.infer<typeof bulkSchema>;
|
let body: z.infer<typeof bulkSchema>;
|
||||||
try {
|
try {
|
||||||
await assertResidentialModuleEnabled(ctx.portId);
|
await assertResidentialModuleEnabled(ctx.portId);
|
||||||
@@ -104,4 +105,5 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth, withRateLimit } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { runBulk } from '@/lib/api/bulk-helpers';
|
import { runBulk } from '@/lib/api/bulk-helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
@@ -33,7 +33,8 @@ const PERMISSION_BY_ACTION = {
|
|||||||
remove_tag: 'edit' as const,
|
remove_tag: 'edit' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(
|
||||||
|
withRateLimit('bulk', async (req, ctx) => {
|
||||||
let body: z.infer<typeof bulkSchema>;
|
let body: z.infer<typeof bulkSchema>;
|
||||||
try {
|
try {
|
||||||
body = await parseBody(req, bulkSchema);
|
body = await parseBody(req, bulkSchema);
|
||||||
@@ -73,4 +74,5 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ data: { results, summary } });
|
return NextResponse.json({ data: { results, summary } });
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -112,6 +112,15 @@ export function deepMerge(
|
|||||||
export function withAuth<TParams extends RouteParams = Record<string, string>>(
|
export function withAuth<TParams extends RouteParams = Record<string, string>>(
|
||||||
handler: RouteHandler<TParams>,
|
handler: RouteHandler<TParams>,
|
||||||
): (req: NextRequest, routeContext: { params: Promise<TParams> }) => Promise<NextResponse> {
|
): (req: NextRequest, routeContext: { params: Promise<TParams> }) => Promise<NextResponse> {
|
||||||
|
// M14: apply the broad per-user `api` limiter (120/min) as a default
|
||||||
|
// backstop for EVERY authenticated v1 request. Tighter named limiters
|
||||||
|
// (`ai`, `bulk`, `ocr`, …) still compose ON TOP via `withRateLimit`
|
||||||
|
// inside the handler chain - they use distinct Redis key prefixes, so
|
||||||
|
// a request that trips a named limiter is counted in its own bucket
|
||||||
|
// AND this `api` bucket independently (no double-counting within a
|
||||||
|
// single bucket). `checkRateLimit` fails OPEN on a Redis outage
|
||||||
|
// (see rate-limit.ts), so this can never lock the API out.
|
||||||
|
const rateLimited = withRateLimit('api', handler as RouteHandler) as RouteHandler<TParams>;
|
||||||
return async (req, routeContext) => {
|
return async (req, routeContext) => {
|
||||||
// Mint or accept a request id BEFORE entering the ALS frame so every
|
// Mint or accept a request id BEFORE entering the ALS frame so every
|
||||||
// log line + the response header reference the same value. Clients
|
// log line + the response header reference the same value. Clients
|
||||||
@@ -269,7 +278,10 @@ export function withAuth<TParams extends RouteParams = Record<string, string>>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const params = await routeContext.params;
|
const params = await routeContext.params;
|
||||||
return tag(await handler(req, ctx, params));
|
// Call through the `api`-limited wrapper (M14). On a 429 it
|
||||||
|
// short-circuits before the inner handler; otherwise it
|
||||||
|
// delegates straight to the original handler.
|
||||||
|
return tag(await rateLimited(req, ctx, params));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return tag(errorResponse(error));
|
return tag(errorResponse(error));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user