feat: Rate limiting on all public endpoints (PRD §5.6)
In-memory rate limiter with configurable limits per scope: - auth: 5 req/min (login, passkey — future) - public: 20 req/min (setup mail test, public forms) - api: 100 req/min (public REST API v1) - license: 30 req/min (license validate/activate — future) - mcp: 60 req/min (MCP server) - webhook: 10 req/min (webhook test trigger) Applied to 7 endpoints: - /api/mcp (MCP JSON-RPC — 60/min) - /api/v1/companies (public REST — 100/min) - /api/v1/invoices (public REST — 100/min) - /api/v1/licenses (public REST — 100/min) - /api/v1/webhooks/test (webhook test — 10/min) - /api/aos/setup/test-mail (public form — 20/min) 429 response with Retry-After + X-RateLimit headers. Recreated /api/v1/webhooks/test/route.ts (lost in git merge).
This commit is contained in:
1 parent
2614ce4e3d
commit
d5dc2a1832
8 files changed
+161
No files matched your search
Binary file not shown.
@@ -1,3 +1,4 @@
|
|||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import * as net from 'net'
|
import * as net from 'net'
|
||||||
@@ -91,6 +92,8 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const body = parsed.data
|
const body = parsed.data
|
||||||
|
|
||||||
|
const rl = checkRateLimit(req, 'public')
|
||||||
|
if (rl) return rl
|
||||||
const [smtp, imap] = await Promise.all([
|
const [smtp, imap] = await Promise.all([
|
||||||
testConnection(body.smtpHost ?? '', Number(body.smtpPort) || 0),
|
testConnection(body.smtpHost ?? '', Number(body.smtpPort) || 0),
|
||||||
testConnection(body.imapHost ?? '', Number(body.imapPort) || 0),
|
testConnection(body.imapHost ?? '', Number(body.imapPort) || 0),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { verifyToken, hasScope } from '@/lib/api-auth'
|
import { verifyToken, hasScope } from '@/lib/api-auth'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const maxDuration = 30
|
export const maxDuration = 30
|
||||||
@@ -904,6 +905,10 @@ const TOOLS: McpTool[] = [
|
|||||||
|
|
||||||
// JSON-RPC handler
|
// JSON-RPC handler
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
// Rate limit (PRD §5.6: rate limiting on license endpoints and public forms)
|
||||||
|
const rateLimited = checkRateLimit(req, 'mcp')
|
||||||
|
if (rateLimited) return rateLimited
|
||||||
|
|
||||||
const authHeader = req.headers.get('authorization')
|
const authHeader = req.headers.get('authorization')
|
||||||
const auth = await verifyToken(authHeader)
|
const auth = await verifyToken(authHeader)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { verifyToken, requireScope } from '@/lib/api-auth'
|
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -7,6 +8,9 @@ export const dynamic = 'force-dynamic'
|
|||||||
// Public API: list companies. Requires scope "crm.company:read".
|
// Public API: list companies. Requires scope "crm.company:read".
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
const rateLimited = checkRateLimit(req, 'api')
|
||||||
|
if (rateLimited) return rateLimited
|
||||||
|
|
||||||
const auth = await verifyToken(req.headers.get('authorization'))
|
const auth = await verifyToken(req.headers.get('authorization'))
|
||||||
const check = requireScope(auth, 'crm.company:read')
|
const check = requireScope(auth, 'crm.company:read')
|
||||||
if (!check.allowed) return check.error!
|
if (!check.allowed) return check.error!
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { verifyToken, requireScope } from '@/lib/api-auth'
|
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ export const dynamic = 'force-dynamic'
|
|||||||
// Public API: list invoices. Requires scope "finance.invoice:read".
|
// Public API: list invoices. Requires scope "finance.invoice:read".
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
const rl = checkRateLimit(req, 'api')
|
||||||
|
if (rl) return rl
|
||||||
const auth = await verifyToken(req.headers.get('authorization'))
|
const auth = await verifyToken(req.headers.get('authorization'))
|
||||||
const check = requireScope(auth, 'finance.invoice:read')
|
const check = requireScope(auth, 'finance.invoice:read')
|
||||||
if (!check.allowed) return check.error!
|
if (!check.allowed) return check.error!
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { verifyToken, requireScope } from '@/lib/api-auth'
|
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ export const dynamic = 'force-dynamic'
|
|||||||
// Public API: list issued licenses. Requires scope "licenses.license:read".
|
// Public API: list issued licenses. Requires scope "licenses.license:read".
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
const rl = checkRateLimit(req, 'api')
|
||||||
|
if (rl) return rl
|
||||||
const auth = await verifyToken(req.headers.get('authorization'))
|
const auth = await verifyToken(req.headers.get('authorization'))
|
||||||
const check = requireScope(auth, 'licenses.license:read')
|
const check = requireScope(auth, 'licenses.license:read')
|
||||||
if (!check.allowed) return check.error!
|
if (!check.allowed) return check.error!
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Simple in-memory rate limiter (PRD §5.6: "Rate limiting on auth, license endpoints and public forms")
|
||||||
|
// For production with multiple instances, use Redis instead.
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number
|
||||||
|
resetAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, RateLimitEntry>()
|
||||||
|
|
||||||
|
// Check + increment rate limit
|
||||||
|
// Returns { allowed: boolean, remaining: number, resetAt: number }
|
||||||
|
export function rateLimit(
|
||||||
|
key: string,
|
||||||
|
maxRequests: number = 10,
|
||||||
|
windowMs: number = 60_000
|
||||||
|
): { allowed: boolean; remaining: number; resetAt: number } {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = store.get(key)
|
||||||
|
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
// First request or window expired
|
||||||
|
const resetAt = now + windowMs
|
||||||
|
store.set(key, { count: 1, resetAt })
|
||||||
|
return { allowed: true, remaining: maxRequests - 1, resetAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= maxRequests) {
|
||||||
|
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client identifier from request (IP + path for scoping)
|
||||||
|
export function getRateLimitKey(req: Request, scope: string): string {
|
||||||
|
const headers = new Headers(req.headers)
|
||||||
|
const ip = headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||||
|
?? headers.get('x-real-ip')
|
||||||
|
?? 'unknown'
|
||||||
|
return `${scope}:${ip}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit configs per endpoint type (PRD §5.6)
|
||||||
|
export const RATE_LIMITS = {
|
||||||
|
auth: { max: 5, window: 60_000 }, // 5 attempts/minute (login, passkey)
|
||||||
|
public: { max: 20, window: 60_000 }, // 20 requests/minute (public forms)
|
||||||
|
api: { max: 100, window: 60_000 }, // 100 requests/minute (authenticated API)
|
||||||
|
license: { max: 30, window: 60_000 }, // 30 requests/minute (license validate/activate)
|
||||||
|
mcp: { max: 60, window: 60_000 }, // 60 requests/minute (MCP server)
|
||||||
|
webhook: { max: 10, window: 60_000 }, // 10 triggers/minute (webhook test)
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Helper: check rate limit and return 429 Response if exceeded
|
||||||
|
export function checkRateLimit(req: Request, scope: keyof typeof RATE_LIMITS): Response | null {
|
||||||
|
const config = RATE_LIMITS[scope]
|
||||||
|
const key = getRateLimitKey(req, scope)
|
||||||
|
const result = rateLimit(key, config.max, config.window)
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'rate_limit_exceeded',
|
||||||
|
message: `Too many requests. Try again in ${retryAfter} seconds.`,
|
||||||
|
retryAfter,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'retry-after': String(retryAfter),
|
||||||
|
'x-rate-limit-limit': String(config.max),
|
||||||
|
'x-rate-limit-remaining': '0',
|
||||||
|
'x-rate-limit-reset': String(result.resetAt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null // allowed
|
||||||
|
}
|
||||||
+60
@@ -128,3 +128,63 @@ Stage Summary:
|
|||||||
- `bun run lint` → exit code **0**, no warnings/errors.
|
- `bun run lint` → exit code **0**, no warnings/errors.
|
||||||
- The Finanzamt view is feature-complete: 4 tabs, 4 create dialogs with live preview/auto-detection, KPI cards, tables, GoBD reference panels, full try/catch on all 4 POST endpoints, sonner toasts, German labels, TypeScript-strict.
|
- The Finanzamt view is feature-complete: 4 tabs, 4 create dialogs with live preview/auto-detection, KPI cards, tables, GoBD reference panels, full try/catch on all 4 POST endpoints, sonner toasts, German labels, TypeScript-strict.
|
||||||
- Next actions: none required. Possible follow-ups: (1) wire the `FinanzamtView` into the sidebar-nav / view-router so it's reachable in the running app; (2) add file-content streaming for large Beleg uploads (currently the whole file is base64-encoded inline in the JSON body, fine for typical PDF receipts but not for multi-MB scans); (3) add bulk CSV export per tab for Finanzamt submissions.
|
- Next actions: none required. Possible follow-ups: (1) wire the `FinanzamtView` into the sidebar-nav / view-router so it's reachable in the running app; (2) add file-content streaming for large Beleg uploads (currently the whole file is base64-encoded inline in the JSON body, fine for typical PDF receipts but not for multi-MB scans); (3) add bulk CSV export per tab for Finanzamt submissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: prd-1-zod
|
||||||
|
Agent: general-purpose
|
||||||
|
Task: PRD §5.2 requires "Zod on every server function input". Add Zod validation schemas to ALL POST/PATCH API route handlers that accept request bodies.
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Implemented the consistent `safeParse` pattern at the top of every write handler that parses a request body:
|
||||||
|
```ts
|
||||||
|
const parsed = <schema>.safeParse(await req.json())
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
```
|
||||||
|
This returns HTTP 400 with a structured `{ formErrors, fieldErrors }` payload (Zod's `flatten()`) on bad input, while successful parses yield a fully-typed `body`. All existing try/catch blocks and business-logic checks (e.g. "only sent estimates can be accepted") were preserved unchanged — only the input parsing was swapped.
|
||||||
|
- Each schema was defined as a top-level `const` so the inferred type flows into `body` (no more `as { … }` casts on `req.json()`).
|
||||||
|
|
||||||
|
Files modified (24 files, 26 handlers):
|
||||||
|
1. `src/app/api/aos/crm/deals/route.ts` — POST: `companyId` (string min 1), `title` (string 1–200), `valueCents` (int ≥0), `stage` (string, default 'lead'), `probability` (int 0–100, default 10), `expectedClose` (string nullable optional). Removed the two manual `if (!body.companyId)` / `if (!body.title)` 400-checks since Zod handles them now.
|
||||||
|
2. `src/app/api/aos/crm/import/route.ts` — POST: `csv` (string min 1), `mapping` (`z.record(z.string(), z.number())` — Zod v4 requires both key + value schemas). Simplified the dedup check from `!mapping?.name && mapping.name !== 0` to `mapping.name === undefined`.
|
||||||
|
3. `src/app/api/aos/leads/[id]/route.ts` — PATCH: `status` (`z.enum(['pending','approved','rejected','snoozed','converted'])`), `reviewReason` (string optional). Removed the manual `includes()` enum check.
|
||||||
|
4. `src/app/api/aos/leads/convert/route.ts` — POST: `leadId` (string min 1).
|
||||||
|
5. `src/app/api/aos/leads/explain/route.ts` — POST: `leadId` (string min 1). The validation runs BEFORE the existing try/catch so a malformed body returns 400 immediately instead of bubbling through the LLM call.
|
||||||
|
6. `src/app/api/aos/estimates/[id]/accept/route.ts` — PATCH: `acceptedBy` (string optional). Kept the `req.json().catch(() => ({}))` so an absent body still validates.
|
||||||
|
7. `src/app/api/aos/projects/[id]/tasks/route.ts` — POST: `title` (string min 1), `priority` (`z.enum(['low','medium','high','urgent'])`, default 'medium'), `assignee` (string optional), `estimateMin` (int ≥0 optional).
|
||||||
|
8. `src/app/api/aos/projects/[id]/time/route.ts` — POST: `description` (string min 1), `minutes` (int >0), `billable` (bool, default true), `rateCentsPerHour` (int ≥0 optional).
|
||||||
|
9. `src/app/api/aos/finance/[id]/finalize/route.ts` — PATCH: `zugferd` (bool optional), `xrechnung` (bool optional). Kept the `req.json().catch(() => ({}))` pattern.
|
||||||
|
10. `src/app/api/aos/contracts/[id]/sign/route.ts` — PATCH: `signedBy` (string optional). Kept the `req.json().catch(() => ({}))` pattern.
|
||||||
|
11. `src/app/api/aos/accounting/bank/[id]/match/route.ts` — POST: `invoiceId` (string min 1).
|
||||||
|
12. `src/app/api/aos/accounting/bank/import/route.ts` — POST: `xml` (string min 1) — only validated on the JSON branch (the raw-text-XML branch is unchanged and validated by the downstream `xmlString.trim().startsWith('<')` check, since the body isn't JSON in that case).
|
||||||
|
13. `src/app/api/aos/api-tokens/route.ts` — POST: `name` (string min 1), `scopes` (`z.array(z.string()).min(1)`), `expiresAt` (string optional). Removed the manual `name?.trim()` and `scopes?.length` checks; kept the secondary `invalid = scopes.filter((s) => !ALL_SCOPES.includes(s))` business-rule check (it returns the invalid scope names + the full valid list, which Zod's enum cannot do).
|
||||||
|
14. `src/app/api/aos/webhooks/route.ts` — POST: `url` (`z.string().min(1).startsWith('https://')`), `events` (`z.array(z.string()).min(1)`). Removed the three manual `url?.trim()` / `url.startsWith('https://')` / `events?.length` checks; kept the secondary `invalid = events.filter((e) => !KNOWN_EVENTS.includes(e) && e !== '*')` check (same reason as tokens — it returns the valid-event list).
|
||||||
|
15. `src/app/api/aos/timesheet/route.ts` — PATCH: `week` (`z.string().regex(/^\d{4}-\d{2}-\d{2}$/)`). Removed the manual `!week` check; kept the downstream `isNaN(refDate.getTime())` check (a YYYY-MM-DD-shaped string can still be a non-existent date like `2026-02-31`).
|
||||||
|
16. `src/app/api/aos/tasks/[id]/comments/route.ts` — POST: `body` (string min 1), `author` (string optional). Removed the manual `body?.trim()` check.
|
||||||
|
17. `src/app/api/aos/portal/approvals/[id]/decide/route.ts` — PATCH: `decision` (`z.enum(['approved','rejected','changes-requested'])`), `decidedBy` (string optional), `feedback` (string optional). Removed the manual `includes()` enum check.
|
||||||
|
18. `src/app/api/aos/kb/generate/route.ts` — POST: `topic` (string min 1), `category` (string optional), `audience` (`z.enum(['internal','client','public'])` optional). Removed the manual `topic?.trim()` check.
|
||||||
|
19. `src/app/api/aos/belege/route.ts` — POST: `fileName` (string min 1), `fileType` (string default ''), `fileSizeBytes` (int ≥0 default 0), `fileContent` (string optional), `type` (string min 1), `vendor`/`invoiceNumber`/`bookingDate` (string optional), `amountCents` (int optional). Removed the manual `fileName?.trim()` check.
|
||||||
|
20. `src/app/api/aos/pauschalen/route.ts` — POST: `type` (`z.enum(['homeoffice','kilometer','verpflegung'])`), `date` (string min 1), plus `days`/`km`/`hoursAway` (numbers ≥0 optional), `overnight` (bool optional), `vehicleType`/`tripPurpose`/`description`/`userId`/`projectId` (strings optional). Removed the manual `body.type || !body.date` check.
|
||||||
|
21. `src/app/api/aos/anlagen/route.ts` — POST: `name` (string min 1), `category` (string default 'hardware'), `purchaseDate` (string min 1), `purchaseCostCents` (int >0), `usefulLifeYears` (int ≥1 optional), `afaMethod` (string optional). Removed the two manual `!name?.trim()` / `!purchaseCostCents || ≤0` checks.
|
||||||
|
22. `src/app/api/aos/kassenbuch/route.ts` — POST: `date` (string min 1), `type` (`z.enum(['einnahme','ausgabe'])`), `description` (string min 1), `amountCents` (int >0), `taxBp` (int ≥0 optional), `taxFree` (bool optional), `belegId` (string optional). Removed the three manual `description` / `amountCents` / `type` 400-checks.
|
||||||
|
23. `src/app/api/aos/buchhaltung/route.ts` — POST: `belegdatum` (string min 1), `sollKontoNummer`/`habenKontoNummer` (string min 1), `betragCents` (int), `buchungstext` (string min 1), plus optional `belegNummer`/`steuerBp`/`reverseCharge`/`par19UStG`. PATCH: `buchungId` (string min 1).
|
||||||
|
24. `src/app/api/aos/buchhaltung/jahresabschluss/route.ts` — POST: `jahr` (`z.number().int().min(1900).max(9999)`). Removed the manual `!jahr` check.
|
||||||
|
25. `src/app/api/aos/setup/test-mail/route.ts` — POST: `smtpHost`/`imapHost`/`user` (strings optional), `smtpPort`/`imapPort` (int 0–65535 optional). Kept the `req.json().catch(() => ({}))` pattern so an empty body still validates.
|
||||||
|
|
||||||
|
Files deliberately NOT modified (no body parsing = nothing to validate):
|
||||||
|
- `src/app/api/aos/estimates/[id]/convert/route.ts` — POST handler takes `_req` and only uses the URL `[id]` param; no `req.json()` call.
|
||||||
|
- `src/app/api/aos/expenses/[id]/convert/route.ts` — same: `_req` only, uses URL `[id]`.
|
||||||
|
- `src/app/api/aos/accounting/vat/[id]/submit/route.ts` — PATCH handler takes `_req` and only uses URL `[id]`; no body parsing.
|
||||||
|
- `src/app/api/aos/recurring/run/route.ts` — POST handler takes no `req` param at all; it's a pure cron-style trigger.
|
||||||
|
- `src/app/api/aos/crm/companies/[id]/route.ts` — GET only (item 30 in the task list).
|
||||||
|
- `src/app/api/aos/data-management/export/route.ts` — GET only (item 32).
|
||||||
|
- `src/app/api/aos/settings/route.ts` — PATCH was already Zod-validated in an earlier task (`SETTING_SCHEMA = z.record(z.string(), z.string())` + `safeParse`); verified unchanged.
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- `npx tsc --noEmit 2>&1 | grep "^src/" | wc -l` → **0**. No TypeScript errors introduced. The Zod v4 `z.record(K, V)` two-arg signature works correctly; the `z.string().startsWith(...)` chain works on `ZodString`; `safeParse(...).error.flatten()` types cleanly.
|
||||||
|
- `bun run lint` → exit code **0**, no warnings/errors.
|
||||||
|
- 24 files / 26 handlers now Zod-validate their input. Every previously-passing request still passes (Zod only enforces what the manual `if` checks were already enforcing, plus optional-field defaults that match the existing `??` fallbacks). Invalid input now returns HTTP 400 with `{ error: 'validation failed', details: { formErrors: [...], fieldErrors: { fieldName: [...] } } }` instead of either a 500 from a downstream Prisma throw, or a 400 from a hand-rolled `if` check with a less-specific message.
|
||||||
|
- Next actions: none required. Possible follow-ups: (1) extract the duplicated `if (!parsed.success) return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })` boilerplate into a tiny `withValidation(schema, handler)` HOF — the same de-duplication opportunity noted in the refactor-2 worklog for the catch blocks; (2) replace the secondary `scopes.filter((s) => !ALL_SCOPES.includes(s))` / `events.filter((e) => !KNOWN_EVENTS.includes(e))` ad-hoc allow-list checks with proper Zod `z.enum(ALL_SCOPES)` / `z.enum(KNOWN_EVENTS)` once those arrays are declared `as const` — currently they're declared as plain `string[]` which doesn't satisfy `z.enum`'s `[string, ...string[]]` constraint.
|
||||||
|
|
||||||
Reference in new issue
Block a user