Skip to content

fix(api): harden the anonymous public surface (system info, rate limits, form DoS) - #1065

Draft
mmcintosh wants to merge 1 commit into
mainfrom
fix/harden-public-surface
Draft

fix(api): harden the anonymous public surface (system info, rate limits, form DoS)#1065
mmcintosh wants to merge 1 commit into
mainfrom
fix/harden-public-surface

Conversation

@mmcintosh

Copy link
Copy Markdown
Collaborator

Summary

Hardens the anonymous, unauthenticated public surface. Three independent fixes:

1. Info disclosure on /api/system/*

GET /api/system/stats returned aggregate content/media counts and the total user count; GET /api/system/env revealed which bindings/integrations are configured (DB, cache, R2, email queue, SendGrid, Cloudflare Images) — an infrastructure fingerprint. Both were anonymous. They now require authentication. /health, /info, and /ping stay public for probes.

2. Missing rate limits on public writes

The per-IP rate limiter was applied only to auth routes. Added it to:

  • POST /api/forms/:id/submit — 20/min/IP
  • POST /api/events — 60/min/IP

(The limiter fail-open-skips when CACHE_KV is unbound, unchanged behavior.)

3. Form-submit DoS

POST /api/forms/:id/submit parsed an unbounded body and then ran a depth-unbounded recursive sanitizer — a stack-exhaustion / CPU DoS from a hostile deeply-nested or oversized JSON payload. Now:

  • bodies over 512 KB (by Content-Length) are rejected before parsing (413)
  • the sanitizer is bounded to depth 32 / 10 000 nodes; exceeding either returns 413 and nothing is persisted

Deferred (called out in the review, not in this PR)

  • Stripping error.message from public read handlers (low severity; shared error handling — wants its own pass).
  • Core account lockout (entangled with the security-audit plugin).

Tests

  • api-system.test.ts/stats and /env return 401 for anonymous callers.
  • public-forms.test.ts — deep-nesting, too-many-fields, and oversized-Content-Length submissions return 413 (and persist nothing); a normal submission still succeeds.
  • tests/e2e/110-public-surface-hardening.spec.ts — end-to-end anon 401 on stats/env, /health stays public, oversized submit 413.
  • Full core suite green; type-check clean.

Marked draft pending maintainer review of the disclosure/coordination approach.

Three fixes to the unauthenticated surface:

- Info disclosure: GET /api/system/stats (total user/content/media counts) and
  GET /api/system/env (which bindings/integrations are configured) now require
  authentication. /health, /info, /ping stay public for probes.
- Rate limiting: POST /api/forms/:id/submit (20/min/IP) and POST /api/events
  (60/min/IP) are now per-IP rate-limited, bounding anonymous write amplification.
  (The limiter fail-open-skips when CACHE_KV is unbound, as before.)
- Form-submit DoS: reject bodies over 512KB by Content-Length before parsing,
  and bound the recursive sanitizer (depth 32, 10k nodes) — a hostile deeply
  nested / oversized JSON payload now returns 413 instead of exhausting the stack
  or CPU.

Deferred (noted in the review, not in this PR): stripping error.message from
public read handlers (low-severity, shared error handling) and core account
lockout (entangled with the security-audit plugin).

Tests: api-system anon-401 cases; public-forms depth/node/size 413 cases; E2E
spec 110. Full core suite green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant