Skip to content

feat: harden cron auth with zero-trust HMAC validation#686

Merged
2witstudios merged 4 commits intomasterfrom
feat/zero-trust-cron-hardening
Feb 16, 2026
Merged

feat: harden cron auth with zero-trust HMAC validation#686
2witstudios merged 4 commits intomasterfrom
feat/zero-trust-cron-hardening

Conversation

@2witstudios
Copy link
Owner

@2witstudios 2witstudios commented Feb 16, 2026

Summary

Addresses #562 - Migrate all cron endpoints to HMAC-signed validation and make CRON_SECRET required in production (fail-closed security model).

  • Migrate 5 routes from legacy Bearer token to validateSignedCronRequest
  • Add production fail-closed: returns 403 if CRON_SECRET not configured when NODE_ENV === 'production'
  • Remove legacy validateCronRequest and hasValidCronSecret exports (no backward compatibility)
  • Update tests for new fail-closed behavior (25 tests passing)

Security Model

All 7 cron routes now use validateSignedCronRequest with:

  • HMAC-SHA256 signature verification - cryptographic request authentication
  • Anti-replay protection - timestamp freshness (5 min window) + nonce uniqueness
  • Defense-in-depth - internal network origin checks
  • Fail-closed in production - requests rejected if CRON_SECRET not set

Test plan

  • Run cron auth tests: cd apps/web && pnpm vitest run src/lib/auth/__tests__/cron-auth.test.ts (25/25 passing)
  • Verify all cron routes use signed validator
  • No TypeScript errors in modified files
  • Manual test: dev server accepts cron requests from localhost without CRON_SECRET
  • Manual test: production mode rejects requests without CRON_SECRET

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Cron endpoints now require HMAC-signed requests with timestamp and nonce validation, anti-replay protection, and timing-safe signature checks; production fails closed if signing is missing while dev/test may fallback to internal-network checks.
  • Documentation
    • Inline trigger/auth instructions updated to describe HMAC-signed headers instead of bearer tokens.
  • Tests
    • Authentication tests reworked to cover environment-driven behaviors and the new signed-request flow.

@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 16, 2026

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 38 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Cron authentication replaced legacy secret validation with HMAC-SHA256 signed requests, nonce anti-replay, and timestamp checks. Multiple cron route handlers now call validateSignedCronRequest; deprecated validators removed and tests updated to reflect NODE_ENV-driven behaviors.

Changes

Cohort / File(s) Summary
Cron Routes Migration
apps/web/src/app/api/cron/cleanup-orphaned-files/route.ts, apps/web/src/app/api/cron/purge-ai-usage-logs/route.ts, apps/web/src/app/api/cron/purge-deleted-messages/route.ts, apps/web/src/app/api/cron/retention-cleanup/route.ts, apps/web/src/app/api/cron/verify-audit-chain/route.ts
Each route now imports and invokes validateSignedCronRequest instead of validateCronRequest. Inline docs updated to describe HMAC-signed requests via X-Cron-Timestamp, X-Cron-Nonce, and X-Cron-Signature headers. Control flow and error handling otherwise unchanged.
Authentication Core Refactoring
apps/web/src/lib/auth/cron-auth.ts
Removed hasValidCronSecret and validateCronRequest. Added computeCronSignature, checkAndRecordNonce, _resetNonceStore, _resetWarningFlag, and validateSignedCronRequest. Implements header checks, timestamp freshness, timing-safe HMAC verification, nonce anti-replay (record after successful signature), and internal-network origin logic; production fails closed without CRON_SECRET.
Test Suite Updates
apps/web/src/lib/auth/__tests__/cron-auth.test.ts
Removed tests for deprecated helpers and reworked suites to be NODE_ENV-driven. Added scenarios for development/production/test modes when CRON_SECRET is absent, preserved tests for signature and nonce utilities, and added _resetWarningFlag/nonce store resets for isolation.

Sequence Diagram

sequenceDiagram
    participant Client as Cron Scheduler
    participant Route as Cron Route Handler
    participant Validator as validateSignedCronRequest
    participant HeaderCheck as Header Validation
    participant SigVerify as Signature Verification
    participant NonceStore as Nonce Store
    participant NetworkCheck as Internal Network Check

    Client->>Route: GET /api/cron/[endpoint]\n(X-Cron-Timestamp, X-Cron-Nonce, X-Cron-Signature)
    Route->>Validator: invoke validateSignedCronRequest(request)
    Validator->>HeaderCheck: ensure headers present & timestamp fresh
    alt headers/timestamp invalid
        HeaderCheck-->>Validator: error
        Validator-->>Route: NextResponse (400/403)
    else
        Validator->>SigVerify: verify HMAC-SHA256 signature (timing-safe)
        alt signature invalid
            SigVerify-->>Validator: mismatch
            Validator-->>Route: NextResponse (403)
        else
            Validator->>NonceStore: checkAndRecordNonce(nonce)
            alt nonce replay
                NonceStore-->>Validator: reused
                Validator-->>Route: NextResponse (403)
            else
                Validator->>NetworkCheck: confirm internal-network origin
                alt not internal
                    NetworkCheck-->>Validator: rejected
                    Validator-->>Route: NextResponse (403)
                else
                    Validator-->>Route: null (authorized)
                    Route->>Route: proceed with cron task
                    Route-->>Client: 200 OK
                end
            end
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇
I chewed the old secret, found a brighter way,
HMAC and nonces now guard the day.
Timestamps keep time, replays leap no more,
Signed crons hop safely to every door. 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (13 files):

⚔️ apps/web/src/app/api/cron/cleanup-orphaned-files/route.ts (content)
⚔️ apps/web/src/app/api/cron/purge-ai-usage-logs/route.ts (content)
⚔️ apps/web/src/app/api/cron/purge-deleted-messages/route.ts (content)
⚔️ apps/web/src/app/api/cron/retention-cleanup/route.ts (content)
⚔️ apps/web/src/app/api/cron/verify-audit-chain/route.ts (content)
⚔️ apps/web/src/components/ai/chat/input/ChatInput.tsx (content)
⚔️ apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx (content)
⚔️ apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx (content)
⚔️ apps/web/src/hooks/page-agents/usePageAgentSidebarChat.ts (content)
⚔️ apps/web/src/hooks/useTokenRefresh.ts (content)
⚔️ apps/web/src/lib/ai/shared/hooks/index.ts (content)
⚔️ apps/web/src/lib/auth/__tests__/cron-auth.test.ts (content)
⚔️ apps/web/src/lib/auth/cron-auth.ts (content)

These conflicts must be resolved before merging into master.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: harden cron auth with zero-trust HMAC validation' accurately describes the main change: replacing legacy Bearer token validation with HMAC-signed request authentication across cron endpoints.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/zero-trust-cron-hardening

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.

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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
apps/web/src/app/api/cron/purge-deleted-messages/route.ts (1)

6-14: ⚠️ Potential issue | 🟡 Minor

Stale docstring still references Bearer token auth.

The JSDoc comment at lines 12–13 says curl -H "Authorization: Bearer $CRON_SECRET", but the route now uses HMAC-signed headers (x-cron-timestamp, x-cron-nonce, x-cron-signature). Update the example to reflect the new invocation method.

apps/web/src/app/api/cron/cleanup-orphaned-files/route.ts (1)

19-24: ⚠️ Potential issue | 🟡 Minor

Stale docstring still references Bearer token auth.

Lines 20–24 describe authentication as "CRON_SECRET Bearer token (timing-safe comparison)" — this no longer matches the HMAC-signed model. Same issue as in purge-deleted-messages/route.ts and other cron routes; update all to document the new x-cron-* header approach.

apps/web/src/app/api/cron/verify-audit-chain/route.ts (1)

11-12: ⚠️ Potential issue | 🟡 Minor

Stale docstring — same Bearer token reference as other routes.

Line 12 still shows the old curl -H "Authorization: Bearer $CRON_SECRET" invocation. Please update across all cron route docstrings in this PR.

apps/web/src/app/api/cron/retention-cleanup/route.ts (1)

14-19: ⚠️ Potential issue | 🟡 Minor

Stale docstring — same Bearer token reference.

Lines 15–16 and 18–19 describe the old auth model. Same fix needed here as the other cron routes.

apps/web/src/app/api/cron/purge-ai-usage-logs/route.ts (1)

14-15: ⚠️ Potential issue | 🟡 Minor

Stale docstring — same Bearer token reference.

Line 15 still references the old curl -H "Authorization: Bearer $CRON_SECRET" pattern. Same fix as the other cron routes.

apps/web/src/lib/auth/cron-auth.ts (1)

69-108: ⚠️ Potential issue | 🟠 Major

Use shared Redis for distributed nonce tracking across instances.

The in-memory Map does not persist across serverless invocations or multiple replicas. An attacker can replay a signed request against a different instance within the 5-minute window, bypassing the replay check. While the HMAC signature + timestamp provide significant protection, this creates a replay vulnerability in distributed deployments.

Migrate checkAndRecordNonce() to use getSharedRedisClient() from @pagespace/lib/services/shared-redis with graceful fallback to in-memory for development, following the pattern in distributed-rate-limit.ts.

🤖 Fix all issues with AI agents
In `@apps/web/src/lib/auth/__tests__/cron-auth.test.ts`:
- Line 96: Remove the stray bare semicolon left in the test file
apps/web/src/lib/auth/__tests__/cron-auth.test.ts (it was a leftover from
removed validateCronRequest / hasValidCronSecret tests); delete the lone ";" so
the file contains only valid test code and re-run the test suite to ensure no
syntax errors remain.
🧹 Nitpick comments (1)
apps/web/src/lib/auth/cron-auth.ts (1)

172-205: Nonce is recorded before signature is verified — a minor ordering concern.

checkAndRecordNonce at line 183 records the nonce into the store. If the subsequent signature check (line 200) fails, the nonce has been "burned" — a legitimate request using that same nonce would then be rejected. In practice this is harmless because nonces are random and unique per request, but reordering to verify the signature first and record the nonce only on success would be slightly more robust.

♻️ Suggested reorder: verify signature before recording nonce
   // Anti-replay: check timestamp freshness
   const requestTime = parseInt(timestamp, 10);
   const now = Math.floor(Date.now() / 1000);
   if (isNaN(requestTime) || Math.abs(now - requestTime) >= TIMESTAMP_MAX_AGE_SECONDS) {
     return NextResponse.json(
       { error: 'Forbidden - cron request timestamp expired' },
       { status: 403 }
     );
   }

-  // Anti-replay: check nonce uniqueness
-  if (!checkAndRecordNonce(nonce)) {
-    return NextResponse.json(
-      { error: 'Forbidden - cron request nonce already used' },
-      { status: 403 }
-    );
-  }
-
   // Recompute and compare signature
   const url = new URL(request.url);
   const method = request.method.toUpperCase();
   const path = url.pathname;

   const expectedSignature = computeCronSignature(cronSecret, timestamp, nonce, method, path);

   const expectedBuffer = Buffer.from(expectedSignature, 'utf-8');
   const providedBuffer = Buffer.from(signature, 'utf-8');

   if (expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer)) {
     return NextResponse.json(
       { error: 'Forbidden - invalid cron signature' },
       { status: 403 }
     );
   }

+  // Anti-replay: check nonce uniqueness (after signature verified)
+  if (!checkAndRecordNonce(nonce)) {
+    return NextResponse.json(
+      { error: 'Forbidden - cron request nonce already used' },
+      { status: 403 }
+    );
+  }
+
   // Defense-in-depth: require internal network origin

2witstudios and others added 2 commits February 15, 2026 18:53
Migrate all cron endpoints to HMAC-signed validation and make
CRON_SECRET required in production (fail-closed security model).

Changes:
- Migrate 5 routes from legacy Bearer token to signed validation
- Add production fail-closed: 403 if CRON_SECRET not configured
- Remove legacy validateCronRequest and hasValidCronSecret exports
- Update tests for new fail-closed behavior

All 7 cron routes now use validateSignedCronRequest with:
- HMAC-SHA256 signature verification
- Anti-replay protection (timestamp + nonce)
- Defense-in-depth internal network checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove stray semicolon in test file (line 96)
- Update stale docstrings in 5 cron routes to reflect HMAC auth model
- Reorder validation: verify signature before recording nonce
- Update validateSignedCronRequest docstring with correct validation order

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios 2witstudios force-pushed the feat/zero-trust-cron-hardening branch from da5aa84 to c43f175 Compare February 16, 2026 00:53
@2witstudios
Copy link
Owner Author

Addressed CodeRabbit Review Feedback

Thank you for the thorough review! I've addressed the following items in commit c43f175:

Fixed:

  1. Stale docstrings - Updated all 5 cron routes to document the HMAC-signed authentication model (X-Cron-Timestamp, X-Cron-Nonce, X-Cron-Signature headers)
  2. Stray semicolon - Removed the leftover semicolon at line 96 in cron-auth.test.ts
  3. Nonce ordering - Reordered validation to verify signature BEFORE recording nonce, preventing "burning" valid nonces on invalid signature attempts

Out of scope (follow-up work):

  1. Redis for distributed nonce tracking - The in-memory nonce store is sufficient for this PR's scope. The HMAC signature + 5-minute timestamp window already provides strong anti-replay protection. Distributed nonce tracking via Redis would be a valuable follow-up enhancement for multi-replica deployments but is out of scope here.

All tests pass (25/25) and the branch has been rebased on latest master.

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.

🧹 Nitpick comments (3)
apps/web/src/lib/auth/cron-auth.ts (2)

69-70: In-memory nonce store won't prevent replay across multiple server instances.

The usedNonces Map is process-local. If the app runs on multiple replicas (e.g., serverless functions, multiple pods), a nonce accepted by one instance can be replayed against another. For a single-instance deployment this is fine, but if you scale horizontally, consider an external store (e.g., Redis with TTL) or document the single-instance assumption.


30-56: isInternalRequest is spoofable when used outside a trusted reverse proxy.

The host header and absence of x-forwarded-for can both be controlled by an attacker connecting directly. This is acceptable as defense-in-depth behind HMAC, but worth a doc note that the infrastructure (reverse proxy/load balancer) must set x-forwarded-for on all inbound requests for this check to be meaningful.

apps/web/src/lib/auth/__tests__/cron-auth.test.ts (1)

137-149: Consider resetting cronSecretWarningLogged between tests.

The module-level cronSecretWarningLogged flag persists across tests. If the "without CRON_SECRET" development-mode test runs first and sets the flag, subsequent tests in the same suite won't trigger the warning log path. This doesn't affect correctness today since no test asserts on the warning, but it could cause confusion if someone adds such an assertion later. A _resetWarningFlag() test helper (similar to _resetNonceStore) would make the test suite more robust.

- Document single-instance assumption for in-memory nonce store
- Add infrastructure requirement note for isInternalRequest
- Add _resetWarningFlag() test helper for proper test isolation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios
Copy link
Owner Author

Addressed Remaining Nitpicks (commit 8653372)

  1. In-memory nonce store documentation - Added comment documenting the single-instance assumption and recommending Redis for horizontal scaling
  2. isInternalRequest infrastructure note - Added docstring note that reverse proxy/load balancer MUST set x-forwarded-for for this check to be meaningful
  3. Test isolation helper - Added _resetWarningFlag() test helper and integrated it into the "without CRON_SECRET" test block for proper isolation

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.

🧹 Nitpick comments (2)
apps/web/src/lib/auth/cron-auth.ts (2)

46-52: host.startsWith('localhost') also matches hostnames like localhost.evil.com.

Since this is defense-in-depth behind HMAC, it's low risk, but a stricter check would anchor against the port separator or end-of-string:

♻️ Tighter localhost match
-  if (
-    host.startsWith('localhost') ||
-    host.startsWith('127.0.0.1') ||
-    host.startsWith('[::1]')
-  ) {
+  const hostname = host.split(':')[0];
+  if (['localhost', '127.0.0.1', '[::1]'].includes(hostname)) {

74-78: In-memory nonce store has no upper-bound on size.

Acknowledged as a known single-instance limitation (and Redis is a documented follow-up), but if a burst of requests occurs, the map grows unchecked until the next cleanup. Consider adding a cap (e.g., reject if usedNonces.size > MAX) as a simple safeguard against memory exhaustion, even in single-instance mode.

- Use regex to anchor localhost match to port separator or end-of-string
  (prevents localhost.evil.com matching)
- Add MAX_NONCES (10,000) limit to prevent memory exhaustion under burst
- Add tests for both security improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios
Copy link
Owner Author

Additional Security Hardening (commit 42dcf6e)

  1. Tighter localhost matching - Changed from startsWith('localhost') to regex anchored to port separator or end-of-string. Prevents localhost.evil.com from matching.

  2. Nonce store size limit - Added MAX_NONCES = 10,000 cap to prevent memory exhaustion under burst requests. When limit is reached, new nonces are rejected (treated same as replay).

Both improvements have test coverage (27 tests now passing).

@2witstudios 2witstudios merged commit 45171a4 into master Feb 16, 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.

1 participant