harden(security): rate-limit + bound input on /api/log (review: OWASP V2.2 / SOC2 CC6.1) (#651)
Follow-up to the PII-redaction fix, addressing the compliance-swarm findings on this unauthenticated client telemetry sink:
- Per-/24 rate limit (30/min) via checkRateLimit — bounds log-flooding (CC6.1). Fails open when no limiter is configured; the whole handler is wrapped so a transient limiter error degrades to { ok:false } rather than a 500.
- Cap message length (2000) and serialized extra size (8 KB) — bounds client input (V2.2). Shape is coerced rather than strictly schema-rejected, so a malformed report still logs (this endpoint exists to capture client errors).
- No auth added: the endpoint is intentionally called pre-auth during onboarding.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+36
-6
@@ -1,17 +1,47 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { truncateIp } from '@/lib/api/v1/with-api-v1'
|
||||
|
||||
const log = createLogger('onboarding-client')
|
||||
|
||||
// This is an UNAUTHENTICATED client telemetry sink — it's called from the
|
||||
// browser during onboarding, before a session necessarily exists, so it can't
|
||||
// require auth. Abuse is bounded instead by a per-/24 rate limit (log-flooding,
|
||||
// SOC2 CC6.1) and size caps on the client-supplied fields (OWASP V2.2). PII in
|
||||
// message/extra is redacted by the structured logger before it reaches Vercel.
|
||||
const RATE_LIMIT = { maxRequests: 30, windowMs: 60_000 }
|
||||
const MAX_MESSAGE_LEN = 2000
|
||||
const MAX_EXTRA_BYTES = 8000
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { message, extra } = await request.json()
|
||||
const fwd = request.headers.get('x-forwarded-for')
|
||||
const rawIp = fwd ? fwd.split(',')[0]?.trim() : request.headers.get('x-real-ip') ?? undefined
|
||||
const identifier = truncateIp(rawIp || undefined) ?? 'unknown'
|
||||
const rl = await checkRateLimit({ prefix: 'client-log', identifier, ...RATE_LIMIT })
|
||||
if (!rl.ok) return rl.response!
|
||||
|
||||
// Client-reported onboarding errors. Route through the structured logger so
|
||||
// the (untrusted, client-supplied) message + extra are PII-redacted —
|
||||
// personnummer / IBAN / tokens etc. via the logger's REDACT_KEYS — before
|
||||
// reaching Vercel logs. The previous raw `console.error(..., JSON.stringify(extra))`
|
||||
// logged them verbatim.
|
||||
const body = await request.json()
|
||||
|
||||
// Bound the client-supplied fields before logging: cap the message length
|
||||
// and the serialized size of `extra` so a single request can't flood the
|
||||
// log pipeline. Shape is coerced rather than strictly schema-rejected —
|
||||
// dropping a malformed error report would lose the telemetry this endpoint
|
||||
// exists to capture.
|
||||
const message =
|
||||
typeof body?.message === 'string' ? body.message.slice(0, MAX_MESSAGE_LEN) : 'client onboarding error'
|
||||
let extra = body?.extra
|
||||
if (extra !== undefined) {
|
||||
try {
|
||||
if (JSON.stringify(extra).length > MAX_EXTRA_BYTES) extra = { truncated: true }
|
||||
} catch {
|
||||
extra = undefined // non-serializable (e.g. cyclic) — drop it
|
||||
}
|
||||
}
|
||||
|
||||
// Route through the structured logger so message + extra are PII-redacted
|
||||
// (personnummer / IBAN / tokens via REDACT_KEYS) before reaching Vercel logs.
|
||||
log.error('client onboarding error', { clientMessage: message, extra })
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
|
||||
Reference in New Issue
Block a user