Skip to content

feat: harden public write endpoints with zero-trust controls#726

Merged
2witstudios merged 3 commits intomasterfrom
ppg/public-endpoint-policy
Feb 26, 2026
Merged

feat: harden public write endpoints with zero-trust controls#726
2witstudios merged 3 commits intomasterfrom
ppg/public-endpoint-policy

Conversation

@2witstudios
Copy link
Owner

@2witstudios 2witstudios commented Feb 25, 2026

Closes #564

Summary

Hardened the two public write endpoints (/api/track and /api/contact) with zero-trust abuse controls. Added rate limiting via the existing distributed rate limiter, payload size caps, and strict Zod schema validation to both endpoints. Created 33 tests covering all security properties.

Changes

  • packages/lib/src/security/distributed-rate-limit.ts — Added TRACKING rate limit config (100 req/min), updated CONTACT_FORM config from 5/hour to 10/minute for the public web endpoint
  • packages/lib/src/security/tests/distributed-rate-limit.test.ts — Updated CONTACT_FORM test expectations, added TRACKING config test
  • apps/web/src/app/api/track/route.ts — Rewrote with rate limiting (100/min per IP), 10KB payload cap, Zod schema validation for known event types only, removed catch-all default event handling
  • apps/web/src/app/api/track/tests/route.test.ts — 15 tests covering rate limiting, payload size, schema validation, valid events, and PUT/beacon support
  • apps/web/src/app/api/contact/route.ts — New public contact form endpoint with rate limiting (10/min per IP), 5KB payload cap, strict Zod schema validation, database storage
  • apps/web/src/app/api/contact/tests/route.test.ts — 18 tests covering rate limiting, payload size, schema validation, valid submissions, and error handling

Follow-up fixes

  • TypeScript fix: Added fallback defaults for optional Zod fields passed to tracker functions (data.feature || 'unknown', data.message || 'Unknown error') — resolved CI build failures
  • CodeRabbit review: Used Buffer.byteLength() instead of string.length for byte-accurate payload size checks; masked email PII in contact form logs matching codebase pattern (jo***@example.com)

Notes

  • Both routes use Response.json() (standard Web API) instead of NextResponse.json() to avoid unnecessary next/server import — matches the pattern used by the marketing app's contact route
  • The track endpoint no longer accepts arbitrary/unknown event types — only the 7 defined event types are accepted. Previously it had a default case that tracked any event name
  • Schema validation uses zod/v4 matching the project convention seen in apps/web/src/app/api/feedback/route.ts
  • All 69 tests pass (33 route + 36 rate limit config)

Test plan

  • 15 tests for /api/track (rate limiting, payload size, schema validation, beacon support)
  • 18 tests for /api/contact (rate limiting, payload size, schema validation, DB errors)
  • 36 tests for distributed rate limit configs (including new TRACKING and updated CONTACT_FORM)
  • Full turbo build passes locally
  • All CI checks green

🤖 Generated with Claude Code

Add rate limiting, payload size caps, and Zod schema validation to
/api/track and /api/contact endpoints. These public endpoints now
enforce strict abuse controls while remaining unauthenticated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

This change hardens two public API endpoints—/api/contact and /api/track—with compensating zero-trust controls: distributed IP-based rate limiting, payload size caps, strict schema validation, and comprehensive request/response error handling. Both endpoints now enforce abuse constraints while maintaining their public access model. Rate-limit presets are updated to reflect stricter policies.

Changes

Cohort / File(s) Summary
Contact Form Endpoint
apps/web/src/app/api/contact/route.ts, apps/web/src/app/api/contact/__tests__/route.test.ts
Implements POST handler with rate limiting (10 req/min per IP), 5KB payload cap, strict zod schema validation (name, email, subject, message), DB insertion with trimmed fields, structured logging, and comprehensive error responses (400 for validation, 413 for oversized, 429 for rate-limited, 500 for DB errors). Test suite covers all paths and side effects.
Tracking Endpoint
apps/web/src/app/api/track/route.ts, apps/web/src/app/api/track/__tests__/route.test.ts
Refactors POST and PUT (beacon) handlers to enforce 100 req/min per-IP rate limiting, 10KB payload cap, strict event schema validation, enriched event metadata (IP, user-agent, timestamp), and fire-and-forget semantics with error recovery. Hardened user identification via optional auth. Test suite validates all event types, rate limiting, payload enforcement, and beacon handling.
Rate Limiting Configuration
packages/lib/src/security/distributed-rate-limit.ts, packages/lib/src/security/distributed-rate-limit.test.ts
Updates CONTACT_FORM limits from 5 per hour to 10 per minute with 1-minute block window; adds new TRACKING preset at 100 per minute with matching block duration. Both include progressiveDelay: false. Tests updated to reflect new limits and added coverage for TRACKING preset.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ContactAPI as /api/contact<br/>POST Handler
    participant RateLimiter as Rate Limiter<br/>(IP-based)
    participant SchemaValidator as Schema<br/>Validator
    participant Database as Contact<br/>Submissions DB
    participant Logger

    Client->>ContactAPI: POST /api/contact<br/>(name, email, subject, message)
    ContactAPI->>RateLimiter: Check rate limit for IP
    alt Rate Limited
        RateLimiter-->>ContactAPI: Rate limit exceeded
        ContactAPI-->>Logger: Log rate limit warning
        ContactAPI-->>Client: 429 Retry-After
    else Rate Limit OK
        ContactAPI->>SchemaValidator: Validate payload<br/>(schema + Content-Length)
        alt Validation Fails
            SchemaValidator-->>ContactAPI: Invalid (400 or 413)
            ContactAPI-->>Logger: Log validation error
            ContactAPI-->>Client: 400/413 error response
        else Validation OK
            ContactAPI->>Database: Insert submission<br/>(trimmed fields)
            alt DB Success
                Database-->>ContactAPI: Inserted
                ContactAPI-->>Logger: Log successful submission
                ContactAPI-->>Client: 200 OK
            else DB Error
                Database-->>ContactAPI: Error
                ContactAPI-->>Logger: Log DB error
                ContactAPI-->>Client: 500 error response
            end
        end
    end
Loading
sequenceDiagram
    participant Client
    participant TrackAPI as /api/track<br/>POST/PUT Handler
    participant RateLimiter as Rate Limiter<br/>(IP-based)
    participant SchemaValidator as Schema<br/>Validator
    participant AuthHelper as Auth Helper<br/>(optional)
    participant Analytics as Analytics<br/>Backend
    participant Logger

    Client->>TrackAPI: POST/PUT<br/>(event type + data)
    TrackAPI->>RateLimiter: Check rate limit for IP
    alt Rate Limited
        RateLimiter-->>TrackAPI: Rate limit exceeded
        TrackAPI-->>Logger: Log rate limit warning
        TrackAPI-->>Client: 429 Retry-After
    else Rate Limit OK
        TrackAPI->>SchemaValidator: Validate payload<br/>(schema + Content-Length)
        alt Validation Fails
            SchemaValidator-->>TrackAPI: Invalid
            TrackAPI-->>Logger: Log validation error
            TrackAPI-->>Client: 400/413 error response
        else Validation OK
            TrackAPI->>AuthHelper: Attempt authenticate<br/>(optional, non-blocking)
            AuthHelper-->>TrackAPI: userId or null
            TrackAPI->>Analytics: Track event<br/>(enriched: IP, user-agent,<br/>timestamp, userId if auth ok)
            Analytics-->>TrackAPI: Event recorded
            TrackAPI-->>Logger: Log event tracking
            TrackAPI-->>Client: 200 { ok: true }
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Rate limits bloom, schemas stand tall,
Public endpoints now guard against all;
A trust boundary, explicit and bright,
Zero-trust principles shining so tight!
~signed, a security-conscious rabbit

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: hardening public write endpoints with zero-trust controls, which directly reflects the substantial security improvements in the changeset.
Linked Issues check ✅ Passed The pull request fully addresses issue #564 by implementing zero-trust controls (rate limiting, payload caps, schema validation) on /api/track and /api/contact endpoints with comprehensive tests validating the abuse controls.
Out of Scope Changes check ✅ Passed All changes are directly aligned with hardening public write endpoints: rate limit configurations, endpoint implementations with security controls, and comprehensive test coverage for abuse controls and expected behavior.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ppg/public-endpoint-policy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Provide fallback defaults for optional Zod schema fields passed to
tracker functions that require string arguments. Fixes CI build
failures caused by string | undefined not being assignable to string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
apps/web/src/app/api/track/route.ts (1)

182-206: PUT beacon path bypasses rate limiting for malformed requests.

When JSON.parse(text) fails at line 194, the catch block returns { ok: true } without ever calling checkDistributedRateLimit. An attacker could flood the server with malformed PUT requests without being rate-limited. Since no downstream resources (DB, trackers) are consumed in that path, the blast radius is limited to CPU/bandwidth — acceptable for now, but worth noting if you add heavier processing later.

Consider moving the rate-limit check before the JSON parse, or adding it to the PUT handler directly:

Sketch: rate-limit in PUT before parsing
 export async function PUT(request: Request) {
   try {
+    const ip = getClientIP(request);
+    const rateLimitResult = await checkDistributedRateLimit(
+      `track:ip:${ip}`,
+      DISTRIBUTED_RATE_LIMITS.TRACKING
+    );
+    if (!rateLimitResult.allowed) {
+      return Response.json(
+        { error: 'Too many requests' },
+        { status: 429, headers: { 'Retry-After': String(rateLimitResult.retryAfter || 60) } }
+      );
+    }
+
     const text = await request.text();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/track/route.ts` around lines 182 - 206, The PUT handler
currently parses the body before calling any rate-limit, letting malformed
requests bypass rate limiting; move or invoke the distributed rate limiter at
the top of PUT by calling await checkDistributedRateLimit(request) (or the same
helper used by POST) immediately after reading/validating the request size
(after the MAX_PAYLOAD_BYTES check) and before JSON.parse, so malformed JSON
will still be rate-checked; keep constructing newRequest and forwarding to POST
unchanged and ensure any thrown rate-limit error is handled the same way as in
POST.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/app/api/contact/route.ts`:
- Around line 54-61: The length check uses rawBody.length (UTF-16 code units)
which mis-measures bytes; replace that check with a true byte-length computation
(e.g., const byteLength = typeof Buffer !== 'undefined' ?
Buffer.byteLength(rawBody, 'utf8') : new TextEncoder().encode(rawBody).length)
and compare byteLength to MAX_PAYLOAD_BYTES before returning Response.json;
update the block that reads rawBody (the request.text() / rawBody variable) to
use this byteLength variable in place of rawBody.length so multi-byte characters
are correctly bounded.
- Around line 96-99: The log call is recording raw PII—mask the email before
logging by transforming email.trim() into the established masked form (e.g.,
keep first 3 chars of the local part then '***' + '@' + domain) and pass that
masked value to loggers.api.info instead of the raw email; update the place
where loggers.api.info('Contact submission received', { ip, email: email.trim()
}) is called to compute a maskedEmail (use the same masking logic used in
signup-passkey/route.ts) and log email: maskedEmail.

In `@apps/web/src/app/api/track/route.ts`:
- Around line 76-83: The size check uses rawBody.length (UTF-16 code units)
which can misreport payload bytes; replace that check with a true byte-length
calculation (e.g., use Buffer.byteLength(rawBody, 'utf-8') or
TextEncoder().encode(rawBody).length) when comparing against MAX_PAYLOAD_BYTES
in the route handler that reads request.text(); update the conditional that
returns Response.json({ error: 'Payload too large' }, { status: 413 }) to use
the byte-length value (keep rawBody and MAX_PAYLOAD_BYTES names intact).

---

Nitpick comments:
In `@apps/web/src/app/api/track/route.ts`:
- Around line 182-206: The PUT handler currently parses the body before calling
any rate-limit, letting malformed requests bypass rate limiting; move or invoke
the distributed rate limiter at the top of PUT by calling await
checkDistributedRateLimit(request) (or the same helper used by POST) immediately
after reading/validating the request size (after the MAX_PAYLOAD_BYTES check)
and before JSON.parse, so malformed JSON will still be rate-checked; keep
constructing newRequest and forwarding to POST unchanged and ensure any thrown
rate-limit error is handled the same way as in POST.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3ddec7c and 5727c9b.

📒 Files selected for processing (6)
  • apps/web/src/app/api/contact/__tests__/route.test.ts
  • apps/web/src/app/api/contact/route.ts
  • apps/web/src/app/api/track/__tests__/route.test.ts
  • apps/web/src/app/api/track/route.ts
  • packages/lib/src/security/__tests__/distributed-rate-limit.test.ts
  • packages/lib/src/security/distributed-rate-limit.ts

- Use Buffer.byteLength() instead of string.length for payload size
  checks in both track and contact routes (handles multi-byte UTF-8)
- Mask email in contact form log output to prevent PII exposure,
  matching the existing codebase pattern (e.g., jo***@example.com)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios 2witstudios merged commit b39c861 into master Feb 26, 2026
10 checks passed
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.

[Zero Trust] Public endpoint policy

1 participant