From b91f0bdbf88cdda3e60a12fdb2a6aa190daaf16b Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:06:14 +0200 Subject: [PATCH] harden(security): rate-limit + bound input on /api/log (review: OWASP V2.2 / SOC2 CC6.1) (#651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/api/log/route.ts | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/app/api/log/route.ts b/app/api/log/route.ts index ce7c029e..accffee9 100644 --- a/app/api/log/route.ts +++ b/app/api/log/route.ts @@ -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 })