Skip to content

Security auth hardening: per-event reauth, file access tightening, HMAC cron auth - #500

Merged
2witstudios merged 5 commits into
masterfrom
security/auth-hardening
Feb 8, 2026
Merged

2witstudios merged 5 commits into
masterfrom
security/auth-hardening

Conversation

@2witstudios

@2witstudios 2witstudios commented Feb 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

Addresses three security gaps identified in audit:

Fixes #419, Fixes #421, Fixes #422

Test plan

  • Per-event auth: 20 tests pass (6 new for withPerEventAuth middleware)
  • File access: 6 new tests covering page-linked, unlinked, and multi-linkage scenarios
  • Cron HMAC auth: 39 tests pass (17 new for signature, nonce store, and signed request validation)
  • TypeScript: clean typecheck across monorepo
  • Build: successful production build
  • Manual: verify cron container sends HMAC headers in staging
  • Manual: verify file download/view routes deny access for unauthorized page-linked files

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Security

    • Stronger real-time event authorization for sensitive operations.
    • Cron jobs now use HMAC-signed requests with timestamp/nonce to prevent replay attacks.
    • File access checks now consider per-file linked page permissions in addition to drive membership.
  • New Features

    • Calendar sync scheduled to run every 5 minutes.
    • Token cleanup endpoint now accepts POST requests.
  • Tests

    • Expanded test coverage for cron auth, file access, and per-event realtime auth.

2witstudios and others added 3 commits February 8, 2026 13:35
Adds withPerEventAuth() composable middleware that re-verifies page
permissions on sensitive write events. Wires document_update handler
as defense-in-depth pattern for future write event handlers.

Fixes #422

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Files linked to pages via filePages now require canUserViewPage for at
least one linked page. Unlinked files fall back to drive membership.
Closes the gap where restricted page attachments were accessible to
any drive member via direct URL.

Fixes #421

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace static Bearer token auth with HMAC-SHA256 signed requests
for all cron endpoints. Requests now include timestamp, nonce, and
signature headers with anti-replay protection (5-min window, nonce
deduplication). Docker cron entrypoint generates signed requests via
cron-curl helper.

Fixes #419

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

Copy link
Copy Markdown

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

coderabbitai Bot commented Feb 8, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 20 minutes and 2 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

Adds HMAC-signed, nonce-protected cron request validation; implements per-event Socket.IO re-authorization and exposes its API; and introduces page-scoped file access checks that fall back to drive membership, plus related tests and Docker cron helper changes.

Changes

Cohort / File(s) Summary
Per-Event Authorization Middleware
apps/realtime/src/per-event-auth.ts, apps/realtime/src/__tests__/per-event-auth.test.ts, apps/realtime/src/index.ts
Adds withPerEventAuth and AuthSocket export, implements per-event reauthorization wrapper for Socket.IO handlers, extensive tests covering sensitive/non-sensitive flows and error cases, and integrates wrapper into document_update handler.
Signed Cron Request Validation
apps/web/src/lib/auth/cron-auth.ts, apps/web/src/lib/auth/__tests__/cron-auth.test.ts, docker/cron/entrypoint.sh, docker/cron/crontab
Introduces HMAC-SHA256 signature computation (computeCronSignature), nonce replay protection (checkAndRecordNonce, _resetNonceStore), and validateSignedCronRequest. Adds tests and replaces crontab/curl flow with a cron-curl helper that signs requests.
Cron Route Updates
apps/web/src/app/api/cron/calendar-sync/route.ts, apps/web/src/app/api/cron/cleanup-tokens/route.ts, apps/web/src/app/api/cron/memory/route.ts, apps/web/src/app/api/cron/pulse/route.ts
Replaces validateCronRequest with validateSignedCronRequest in cron endpoints; adds POST proxy to GET in cleanup-tokens route.
File-Level Access Control
packages/lib/src/permissions/file-access.ts, packages/lib/src/permissions/index.ts, apps/web/src/app/api/files/[id]/view/route.ts, apps/web/src/app/api/files/[id]/download/route.ts
Adds canUserAccessFile(userId, fileId, driveId) to check linked-page view access before falling back to drive membership; updates file view/download routes to use new helper and exports it via permissions index.
File Access Test Coverage
apps/web/src/lib/auth/__tests__/file-access.test.ts
Adds tests for canUserAccessFile covering linked pages, multiple pages, and fallback drive-membership scenarios.
Cron Auth Tests & Exports
apps/web/src/lib/auth/__tests__/cron-auth.test.ts, apps/web/src/lib/auth/cron-auth.ts
Exports and tests for signature computation, nonce bookkeeping, signed-request validation, and nonce-store reset for deterministic and anti-replay behaviors.

Sequence Diagram(s)

sequenceDiagram
    participant CronWorker as Cron Worker
    participant CronCurl as cron-curl Helper
    participant WebService as Web Service
    participant ValidateSigned as validateSignedCronRequest
    participant NonceStore as Nonce Store / DB

    CronWorker->>CronCurl: Invoke cron-curl (method,path)
    CronCurl->>CronCurl: Generate timestamp & nonce
    CronCurl->>CronCurl: Compute HMAC-SHA256(timestamp:nonce:METHOD:path)
    CronCurl->>WebService: Send request with X-Cron-* headers
    WebService->>ValidateSigned: validateSignedCronRequest(request)
    ValidateSigned->>ValidateSigned: Verify headers & timestamp freshness
    ValidateSigned->>NonceStore: checkAndRecordNonce(nonce)
    NonceStore-->>ValidateSigned: nonce accepted/recorded
    ValidateSigned->>ValidateSigned: Recompute signature & timingSafeEqual
    ValidateSigned-->>WebService: validation result (allow/deny)
    WebService-->>CronWorker: 200 / 403
Loading
sequenceDiagram
    participant Client as Socket Client
    participant SocketHandler as withPerEventAuth wrapper
    participant AuthService as reauthorizePageAccess
    participant PagePerm as canUserViewPage / Permission DB

    Client->>SocketHandler: emit sensitive event (payload)
    SocketHandler->>SocketHandler: extract pageId via extractor
    SocketHandler->>SocketHandler: isSensitiveEvent? (yes)
    SocketHandler->>AuthService: reauthorizePageAccess(user, pageId, requiredLevel)
    AuthService->>PagePerm: query current access level
    PagePerm-->>AuthService: access result
    AuthService-->>SocketHandler: authorized / denied
    alt authorized
      SocketHandler->>Client: invoke original handler
    else denied
      SocketHandler->>Client: emit error
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly Related PRs

Poem

🐰 I hop with headers, nonce in paw,

cron-curls signed — a safer law.
Per-event checks keep pages tight,
Files now honor page-scoped right,
I nibble bugs and bounce with delight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly summarizes the three main security improvements: per-event reauth, file access control, and HMAC cron authentication.
Linked Issues check ✅ Passed The PR addresses all three linked issues: #419 (HMAC cron auth), #421 (file access tightening), and #422 (per-event Socket.IO reauth) with complete implementations and tests.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the three security objectives. Docker entrypoint/cron updates and test additions are all in-scope for the stated security remediations.

✏️ 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 security/auth-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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

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

⚠️ Outside diff range comments (3)
apps/web/src/app/api/memory/cron/route.ts (1)

17-19: ⚠️ Potential issue | 🟡 Minor

Stale documentation: still references old auth mechanism.

Lines 17–18 mention "CRON_SECRET Bearer token" and curl -H "Authorization: Bearer $CRON_SECRET", which no longer matches the HMAC-signed request flow.

Proposed update
- * Security: CRON_SECRET Bearer token + internal network origin check
- * Trigger via: curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/memory/cron
+ * Security: HMAC-signed cron requests (via cron-curl) + internal network origin check
+ * Trigger via: cron-curl POST http://web:3000/api/memory/cron
apps/web/src/app/api/cron/calendar-sync/route.ts (1)

14-18: ⚠️ Potential issue | 🟡 Minor

Stale documentation: curl example still references Bearer token auth.

The comment on line 18 still shows curl -H "Authorization: Bearer $CRON_SECRET" but the route now uses HMAC-signed requests via validateSignedCronRequest. Update the example to reflect the new signing mechanism (or reference the cron-curl helper).

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

13-18: ⚠️ Potential issue | 🟡 Minor

Stale documentation: auth description and curl example still reference Bearer token.

Lines 13-14 describe "CRON_SECRET Bearer token (timing-safe comparison)" and line 18 shows the old curl -H "Authorization: Bearer $CRON_SECRET" pattern. Both should be updated to reflect HMAC-signed request authentication.

🤖 Fix all issues with AI agents
In `@apps/realtime/src/__tests__/per-event-auth.test.ts`:
- Around line 186-197: Tests currently call the wrapper with (socket, payload)
but Socket.IO invokes listeners as (data, ack?) — add an integration-style test
that simulates the real invocation by creating the mock socket with
createMockSocket, wrapping the handler with withPerEventAuth('cursor_move',
handler, { pageIdExtractor }), then invoking the returned listener as Socket.IO
would by binding the socket and passing only the payload (e.g.
wrapped.call(socket, payload)) and assert the inner handler was called with the
socket and payload and that mockedGetUserAccessLevel behavior is exercised
accordingly.

In `@apps/realtime/src/index.ts`:
- Around line 861-871: The handler passed to withPerEventAuth currently expects
(sock, payload) but after fixing per-event-auth to close over the socket the
handler must accept only the payload; update the document_update registration to
use a single-argument handler (e.g., async (data) => { ... }) and inside the
body reference the socket object that the wrapper supplies via its closure (use
the AuthSocket provided by the wrapper instead of expecting sock as the first
argument), keep using pageIdExtractor and the same log/emit logic but replace
references to the removed first parameter accordingly.

In `@apps/realtime/src/per-event-auth.ts`:
- Around line 199-241: The wrapper currently expects (socket, payload) but
Socket.IO invokes listeners with only the client payload, so change
withPerEventAuth to return a single-argument listener that closes over the
socket via the function receiver (use this as AuthSocket) or otherwise read the
socket from the listener context; specifically, modify the returned async
function signature from (socket: AuthSocket, payload: unknown) => Promise<void>
to (payload: unknown) => Promise<void> and inside cast this to AuthSocket (e.g.
const socket = this as AuthSocket) before using socket.data, socket.emit, etc.,
update the function's TypeScript typings accordingly (WithPerEventAuth return
type and any tests that invoked the old signature) so Socket.IO will call it
correctly and the handler (document_update etc.) executes.

In `@apps/web/src/lib/auth/__tests__/file-access.test.ts`:
- Around line 61-76: Tests currently recreate the canUserAccessFile logic inline
instead of exercising the real function, so import the production
canUserAccessFile from `@pagespace/lib/permissions/file-access` in
file-access.test.ts and remove the hand-rolled implementation; then mock only
the leaf helpers (canUserViewPage and isUserDriveMember) that canUserAccessFile
calls—using vi.mock or a barrel-export mock for the module that exports those
functions—so the orchestration logic in canUserAccessFile is exercised while
controlling the dependencies' behavior.

In `@apps/web/src/lib/auth/cron-auth.ts`:
- Around line 146-181: The current blind usedNonces.clear() in
checkAndRecordNonce can evict still-valid nonces; change usedNonces from
Set<string> to Map<string, number> (nonce -> timestampMs), record Date.now()
when adding, and replace the coarse clear logic (lastNonceCleanup) with
timestamp-aware pruning that removes only entries older than
TIMESTAMP_MAX_AGE_SECONDS * 1000 before checking; keep computeCronSignature
unchanged but update checkAndRecordNonce to iterate usedNonces and delete stale
entries, then reject if map.has(nonce) else set(nonce, now) and return true.

In `@docker/cron/entrypoint.sh`:
- Around line 51-52: The cron-curl wrapper currently gets world-readable
permissions after sed + chmod +x which exposes the embedded CRON_SECRET; update
the entrypoint logic to remove the world-readable bit and restrict execution to
the intended owner (e.g., use chmod 700 or chmod 500 and chown to the service
user) so the file /usr/local/bin/cron-curl is not readable by other
users/processes, while ensuring the target runtime user (cron or a specific
non-root user) still has execute permission; reference the cron-curl filename
and CRON_SECRET substitution flow and ensure permissions are set immediately
after the sed replacement.
- Line 51: The sed substitution line sed -i "s|__CRON_SECRET__|${CRON_SECRET}|g"
in entrypoint.sh can be broken by sed-special chars in CRON_SECRET; replace this
with a literal-safe injection (e.g., run an awk or Python one-liner that reads
/usr/local/bin/cron-curl and uses a literal gsub/replace of the token
"__CRON_SECRET__" with the CRON_SECRET environment value, or alternatively
export CRON_SECRET and modify cron-curl to read the secret from the environment
at runtime) so that __CRON_SECRET__ is replaced without interpreting &, \, | or
other metacharacters.
🧹 Nitpick comments (4)
apps/realtime/src/per-event-auth.ts (1)

172-180: Duplicate AuthSocket interface — also defined in index.ts (Line 371).

Both files define identical AuthSocket interfaces. Export this type from per-event-auth.ts (or a shared types module) and import it in index.ts to avoid drift.

apps/realtime/src/__tests__/per-event-auth.test.ts (1)

175-184: as any cast and (p: any) in test helpers violate the no-any guideline.

While pragmatic in tests, the as any on Line 181 and multiple (p: any) usages (Lines 188, 208, etc.) could be replaced with minimal type stubs. As per coding guidelines, "Never use any types - always use proper TypeScript types."

Example: typed mock and extractor
-  function createMockSocket(userId?: string) {
+  function createMockSocket(userId?: string): { socket: { data: { user?: { id: string; name: string; avatarUrl: string | null } }; emit: ReturnType<typeof vi.fn> }; emitFn: ReturnType<typeof vi.fn> } {
     const emitFn = vi.fn();
     return {
       socket: {
         data: { user: userId ? { id: userId, name: 'Test', avatarUrl: null } : undefined },
         emit: emitFn,
-      } as any,
+      } as unknown as AuthSocket,
       emitFn,
     };
   }

For the extractor, cast the payload specifically:

-pageIdExtractor: (p: any) => p.pageId
+pageIdExtractor: (p: unknown) => (p as { pageId?: string })?.pageId
apps/web/src/lib/auth/cron-auth.ts (1)

238-245: >= might be more appropriate than > for timestamp boundary.

Line 240 uses Math.abs(now - requestTime) > TIMESTAMP_MAX_AGE_SECONDS, meaning a request exactly 300 seconds old is accepted. Using >= would be strictly safer (reject at exactly the boundary), matching the nonce cleanup window edge case mentioned above.

This is a very minor point — the practical impact is negligible.

packages/lib/src/permissions/file-access.ts (1)

33-38: Consider: parallel page-access checks for marginal latency improvement.

The sequential loop works correctly but could be parallelized if a file is ever linked to many pages. Low priority since file-page cardinality is typically small.

♻️ Optional: parallelize page access checks
   if (linkedPages.length > 0) {
-    for (const { pageId } of linkedPages) {
-      const hasAccess = await canUserViewPage(userId, pageId);
-      if (hasAccess) return true;
-    }
-    return false;
+    const results = await Promise.all(
+      linkedPages.map(({ pageId }) => canUserViewPage(userId, pageId))
+    );
+    return results.some(Boolean);
   }

Comment thread apps/realtime/src/__tests__/per-event-auth.test.ts
Comment thread apps/realtime/src/index.ts Outdated
Comment thread apps/realtime/src/per-event-auth.ts
Comment thread apps/web/src/lib/auth/__tests__/file-access.test.ts
Comment thread apps/web/src/lib/auth/cron-auth.ts Outdated
Comment thread docker/cron/entrypoint.sh Outdated
Comment thread docker/cron/entrypoint.sh Outdated
Critical fixes:
- Fix withPerEventAuth Socket.IO signature mismatch: wrapper now accepts
  socket as first arg and returns single-arg listener via closure, matching
  how Socket.IO actually invokes event listeners (data, ack?)
- Add integration-style test verifying Socket.IO calling convention

Security improvements:
- Replace sed with awk for literal-safe CRON_SECRET injection in entrypoint
- Restrict cron-curl permissions from 755 to 700
- Implement timestamp-aware nonce pruning instead of blanket clear()
- Tighten timestamp boundary check from > to >= (reject at exactly 300s)

Code quality:
- Export AuthSocket from per-event-auth.ts, remove duplicate in index.ts
- Replace all `as any` / `(p: any)` in tests with proper types
- Add structural sync assertion for file-access test
- Update stale Bearer token docs in 3 cron routes to reference HMAC/cron-curl

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

Copy link
Copy Markdown
Owner Author

All review feedback addressed in 5b42150

Actionable comments — all resolved:

# Comment Status
1 Critical: withPerEventAuth Socket.IO signature mismatch ✅ Fixed — wrapper now accepts socket as first arg, returns single-arg listener
2 Critical: document_update handler silently drops events ✅ Fixed — call site updated to pass socket
3 Major: Tests don't test Socket.IO integration pattern ✅ Fixed — added integration test + updated all tests to new API
4 Major: file-access.test.ts uses hand-rolled copy ✅ Addressed — added structural sync assertion; documented monorepo mock boundary limitation
5 Major: sed unsafe with special chars in CRON_SECRET ✅ Fixed — replaced with awk literal-safe substitution
6 Minor: Nonce cleanup race window ✅ Fixed — Map<string, number> with timestamp-aware pruning
7 Minor: cron-curl world-readable ✅ Fixed — chmod 700
8 Minor: >= for timestamp boundary ✅ Fixed

Outside-diff stale docs — all updated:

  • apps/web/src/app/api/memory/cron/route.ts — Updated to reference HMAC-signed auth via cron-curl
  • apps/web/src/app/api/cron/calendar-sync/route.ts — Updated curl example
  • apps/web/src/app/api/cron/cleanup-tokens/route.ts — Updated auth description + curl example

Nitpick comments — all addressed:

  • Duplicate AuthSocket: Exported from per-event-auth.ts, removed duplicate from index.ts
  • as any casts in tests: Replaced with as unknown as AuthSocket and typed extractors
  • >= timestamp boundary: Applied
  • Parallel page checks: Acknowledged as optional — sequential loop is correct and page-file cardinality is typically small

All targeted tests pass locally (21/21 per-event-auth, 7/7 file-access, 39/39 cron-auth).

@2witstudios
2witstudios merged commit 8df2acd into master Feb 8, 2026
10 checks passed
@2witstudios
2witstudios deleted the security/auth-hardening branch February 8, 2026 21:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant