-
Notifications
You must be signed in to change notification settings - Fork 2
Add admin role version validation to prevent stale session attacks #558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…dmin routes Fixes #550 ## Changes - Migrated gift subscription POST/DELETE handlers to use `verifyAdminAuth()` - Added security event logging for admin role version mismatches - Added comprehensive security tests for role demotion race conditions ## Security Impact Previously, gift subscription admin endpoints checked `auth.role === 'admin'` without validating `adminRoleVersion`, creating a stale admin window where demoted admins could still use existing sessions. Now uses `verifyAdminAuth()` which: 1. Validates the role claim in the session token 2. Re-validates against database to check current `adminRoleVersion` 3. Logs security events when validation fails due to version mismatch This follows the zero-trust principle and matches the pattern used by 8 other admin routes in the codebase. ## Testing - Added 10 security tests covering: - Valid admin access scenarios - Role demotion race conditions - Multiple role changes with stale tokens - Security event logging verification Resolves P2 security issue from 2026-02-11 security posture assessment. https://claude.ai/code/session_M9lh1
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the 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. 📝 WalkthroughWalkthroughReplaces in-handler admin checks with a new withAdminAuth wrapper, adds CSRF validation and structured AdminValidationResult for adminRoleVersion checks, logs admin_role_version_mismatch, propagates adminRoleVersion through sessions/DB, and adds comprehensive security tests for gift-subscription admin endpoints. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Route as GiftSubscriptionRoute
participant Auth as withAdminAuth / verifyAdminAuth
participant CSRF as validateCSRF
participant DB as validateAdminAccess / SessionStore
participant Logger as logSecurityEvent
participant Stripe
Client->>Route: POST/DELETE /api/admin/users/{userId}/gift-subscription + session + csrf
Route->>Auth: withAdminAuth -> verifyAdminAuth(request)
Auth->>CSRF: validateCSRF(request)
CSRF-->>Auth: ok / error
Auth->>DB: validateAdminAccess(claimedAdminVersion)
DB-->>Auth: AdminValidationResult (isValid / reason / actualAdminRoleVersion)
alt CSRF fail or role version mismatch
Auth->>Logger: logSecurityEvent("login_csrf_invalid" / "admin_role_version_mismatch")
Logger-->>Auth: logged
Auth-->>Route: NextResponse (403/CSRF error)
Route-->>Client: 403 + error
else valid admin
Route->>Stripe: create or expire gift subscription
Stripe-->>Route: success
Route-->>Client: 200/204 success
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
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. Comment |
Zero-trust defense-in-depth improvements: - Add explicit CSRF validation for state-changing admin operations - Enhance validateAdminAccess to return detailed validation results - Include actualAdminRoleVersion and currentRole in security logs - Fix lint errors: remove unused imports, add explicit Error type - Add defensive cleanup in test afterEach Review feedback addressed: - CodeRabbit: Add actual adminRoleVersion from DB for richer audit context - CodeRabbit: Fix implicit any type in mock callback - CodeRabbit: Wrap cleanup in try/catch to prevent cascading failures - Security review: Re-add CSRF for defense-in-depth (zero-trust) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Review Feedback AddressedI've addressed all the review feedback and added security hardening improvements: CodeRabbit Nitpicks Fixed
Security Hardening (Zero-Trust Defense-in-Depth)
Test Updates
Files Changed
CI should pass now. Please re-review when checks are green. |
- Update validateAdminAccess mock to return AdminValidationResult instead of boolean - Add mock for CSRF validation (allows all requests by default) - Add mock for security event logging - Import AdminValidationResult type Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update admin-role-version.test.ts to check isValid property instead of boolean return value from validateAdminAccess - Add enhanced assertions for reason, actualAdminRoleVersion, and currentRole in validation result - Add additional CSRF mock path to ensure mock is applied correctly for relative imports in auth.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instead of trying to mock CSRF validation (which wasn't working due to module resolution issues), use real CSRF tokens in test requests. This approach: - Is more realistic and tests actual production behavior - Generates CSRF tokens from the session ID after session creation - Includes X-CSRF-Token header in all POST/DELETE requests - Removes non-working CSRF mocks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The test verifyAdminAuth mock now: - Skips CSRF validation (which was causing path resolution issues) - Still validates adminRoleVersion against database (the core security check) - Logs security events for forensic analysis on validation failures This isolates the test to focus on admin role version validation without the complexity of CSRF token generation and validation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The import is now dynamically imported inside the mock function. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instead of mocking verifyAdminAuth to skip CSRF, use real CSRF tokens: - Generate valid CSRF tokens bound to session in beforeEach - Include X-CSRF-Token header in all authenticated requests - Add explicit CSRF protection tests (missing token, invalid token) This ensures full defense-in-depth security testing: - CSRF protection is enforced - adminRoleVersion validation prevents stale session attacks - Security events are logged for forensic analysis Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The cookie name should be 'session' not 'ps-session' to match COOKIE_CONFIG.session.name in cookie-config.ts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In
`@apps/web/src/app/api/admin/users/`[userId]/gift-subscription/__tests__/route.security.test.ts:
- Around line 302-354: The tests expect specific CSRF error codes but
verifyAdminAuth is swallowing validateCSRF responses and returning null; change
verifyAdminAuth (or the POST route handler) to propagate validateCSRF's
NextResponse errors instead of converting them to null so the route returns the
original 403 responses with the code field; locate verifyAdminAuth in auth.ts
and the validateCSRF call in csrf-validation.ts and update verifyAdminAuth to
check the return value from validateCSRF and if it is a NextResponse error,
return that response (or throw/forward it) so the POST handler can return it
unchanged.
In `@apps/web/src/lib/auth/auth.ts`:
- Around line 44-60: The CSRF validation branch discards the NextResponse from
validateCSRF() and returns null; instead, capture and return the csrfError
NextResponse so callers see the specific error codes. In the isStateChanging
branch where csrfError is set from validateCSRF(request), keep the
logSecurityEvent call (including user.id, method, authType: 'session', action)
but change the final return from null to returning csrfError (the value returned
by validateCSRF) so the function propagates the original NextResponse error.
🧹 Nitpick comments (3)
apps/web/src/lib/auth/__tests__/auth.test.ts (1)
124-125: Test expectations correctly align withAdminValidationResultinterface.The valid and invalid result shapes match the interface definition in
admin-role.ts(lines 40–45). Consider adding an assertion onlogSecurityEventin the version-mismatch test (line 170) for completeness, though this is already well-covered in the route security tests.Also applies to: 180-187
apps/web/src/lib/auth/__tests__/admin-role-version.test.ts (1)
54-58: Consider wrappingafterEachcleanup intry/catchfor consistency.The
route.security.test.tswraps its cleanup intry/catch(lines 150–156) to prevent cascading failures, but this file does not. Since these are integration tests hitting a real database, a cleanup failure here could mask subsequent test failures.♻️ Suggested change
afterEach(async () => { - await db.delete(sessions).where(eq(sessions.userId, adminUserId)); - await db.delete(sessions).where(eq(sessions.userId, regularUserId)); - await db.delete(users).where(eq(users.id, adminUserId)); - await db.delete(users).where(eq(users.id, regularUserId)); + try { + await db.delete(sessions).where(eq(sessions.userId, adminUserId)); + await db.delete(sessions).where(eq(sessions.userId, regularUserId)); + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, regularUserId)); + } catch { + // Swallow cleanup errors to avoid masking test failures + } });apps/web/src/lib/auth/admin-role.ts (1)
44-44: Consider usingUserRoletype instead ofstringforcurrentRole.
currentRoleis typed asstring, but theUserRoletype ('user' | 'admin') is already defined on line 3. Using it here would provide stronger type safety and make the interface self-documenting.♻️ Suggested change
export interface AdminValidationResult { isValid: boolean; reason?: 'user_not_found' | 'not_admin' | 'version_mismatch'; actualAdminRoleVersion?: number; - currentRole?: string; + currentRole?: UserRole; }
apps/web/src/app/api/admin/users/[userId]/gift-subscription/__tests__/route.security.test.ts
Show resolved
Hide resolved
The adminRoleVersion validation was fundamentally broken because validateSession() returned the CURRENT adminRoleVersion from the user table instead of the value stored when the session was created. This meant role changes were never detected. Changes: - Add adminRoleVersion column to sessions table schema - Store adminRoleVersion at session creation time - Return stored adminRoleVersion in session claims (not current) This enables verifyAdminAuth() to properly detect when a user's admin role has changed since their session was created, preventing stale admin session attacks. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Changes: - verifyAdminAuth now returns NextResponse on error instead of null - CSRF errors are propagated with their original error codes (CSRF_TOKEN_MISSING, etc.) - validateAdminAccess is called for ALL authenticated users (not just role=admin from claims) - Added isAdminAuthError type guard for callers to check error responses - Updated all admin route handlers to use new return type - Updated unit tests for new behavior This ensures: 1. CSRF error codes are preserved in responses 2. Security events are logged for all admin access denials 3. Role changes are properly detected via validateAdminAccess Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/src/app/api/admin/users/[userId]/gift-subscription/route.ts (1)
99-110:⚠️ Potential issue | 🟡 MinorUser email included in Stripe coupon name.
Line 104 embeds
targetUser.emailin the couponnamefield, which is visible in the Stripe dashboard. This is low-risk since Stripe already stores customer PII, but worth noting for GDPR/data-minimization compliance if the project tracks PII propagation to third-party systems.apps/web/src/app/api/admin/audit-logs/export/route.ts (1)
159-168:⚠️ Potential issue | 🟡 MinorPre-existing LIKE pattern injection — search input is not escaped here, unlike the main audit-logs route.
The main
audit-logs/route.ts(lines 85-89) escapes%,_, and\before building the LIKE pattern and uses Drizzle'silike()function. This export route uses raw SQL with unescapedsearchdirectly interpolated into the LIKE pattern. An attacker with admin access could use%or_wildcards to broaden the export beyond intended results.Consider applying the same escaping logic used in the main route, and switching to Drizzle's
ilike()for consistency.
🧹 Nitpick comments (4)
apps/web/src/lib/auth/__tests__/auth.test.ts (1)
181-206: Consider asserting thatlogSecurityEventwas called for the version mismatch case.The
admin_role_version_mismatchsecurity event is a key forensic signal. Adding an assertion here would guard against accidental removal of the logging call.💡 Suggested assertion addition
expect(isAdminAuthError(result)).toBe(true); expect(result).toBeInstanceOf(NextResponse); expect(mockValidateAdminAccess).toHaveBeenCalledWith('admin-123', 0); + + const { logSecurityEvent } = await import('@pagespace/lib/server'); + expect(logSecurityEvent).toHaveBeenCalledWith('admin_role_version_mismatch', expect.objectContaining({ + reason: 'version_mismatch', + userId: 'admin-123', + claimedAdminRoleVersion: 0, + actualAdminRoleVersion: 1, + }));apps/web/src/lib/auth/auth.ts (2)
80-96: Semantic mismatch:admin_role_version_mismatchevent logged for all validation failures, includingnot_admin.When
validateAdminAccessreturnsreason: 'not_admin'(e.g., a regular user attempting admin access), the event typeadmin_role_version_mismatchis misleading — there's no version mismatch, the user simply isn't an admin. Consider using the more generalunauthorizedevent for non-version-mismatch reasons, reservingadmin_role_version_mismatchfor actual version divergence.💡 Suggested refinement
const validationResult: AdminValidationResult = await validateAdminAccess(user.id, user.adminRoleVersion); if (!validationResult.isValid) { - logSecurityEvent('admin_role_version_mismatch', { + const eventType = validationResult.reason === 'version_mismatch' + ? 'admin_role_version_mismatch' + : 'unauthorized'; + logSecurityEvent(eventType, { reason: validationResult.reason ?? 'admin_role_version_validation_failed', userId: user.id, claimedAdminRoleVersion: user.adminRoleVersion, actualAdminRoleVersion: validationResult.actualAdminRoleVersion, currentRole: validationResult.currentRole, authType: 'session', action: 'deny_access', });
48-55: Authentication failure returns 403 instead of 401.When
verifyAuthreturnsnull(the user is unauthenticated — no valid session), the response is a403 Forbiddenwith the message "Admin access required." While a blanket 403 avoids leaking endpoint information, returning401 Unauthorizedfor genuinely unauthenticated requests is more semantically correct per HTTP standards and helps clients (e.g., redirecting to login). Consider differentiating:401for unauthenticated,403for authenticated-but-not-admin.apps/web/src/app/api/admin/global-prompt/route.ts (1)
57-65: Auth check sits outsidetry/catch, unlikeschema/route.ts.In
schema/route.ts,verifyAdminAuthis called inside thetryblock (Line 8), but here it's called before it (Line 59). IfverifyAdminAuthwere to throw an unexpected error, this route would surface an uncontrolled framework-level 500 instead of the structured error response at Line 410.Consider moving the auth check inside the
tryblock for consistency and resilience:Suggested fix
export async function GET(request: Request) { - // Verify admin authentication - const adminAuthResult = await verifyAdminAuth(request); - if (isAdminAuthError(adminAuthResult)) { - return adminAuthResult; - } - const adminUser = adminAuthResult; - try { + // Verify admin authentication + const adminAuthResult = await verifyAdminAuth(request); + if (isAdminAuthError(adminAuthResult)) { + return adminAuthResult; + } + const adminUser = adminAuthResult; + // Parse query params for context selection
Rename adminUser to _adminUser in routes that only need auth check but don't use the user data. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add withAdminAuth higher-order function that wraps admin route handlers with authentication, eliminating the repetitive 3-line auth pattern across all admin routes. Changes: - Add withAdminAuth HOF to apps/web/src/lib/auth/auth.ts - Export withAdminAuth and AdminRouteContext from auth index - Migrate 9 route files (11 handlers) to use the new pattern - Update search-security test mocks to support withAdminAuth Benefits: - DRY: Auth logic in one place instead of 25+ call sites - Type-safe: Handlers receive VerifiedUser, not union type - Pit of success: Impossible to forget auth check Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/app/api/admin/contact/route.ts (1)
35-42:⚠️ Potential issue | 🟡 MinorLIKE pattern injection:
searchTermis not escaped before use withilike.The audit-logs route (Lines 76-82 in
audit-logs/route.ts) explicitly escapes%,_, and\before building the LIKE pattern. This route passessearchTermdirectly intoilikepatterns, allowing users to inject wildcards like%or_for broader matching than intended.This is a pre-existing issue (not introduced by this PR), but it's worth aligning with the sanitization pattern already established in the audit-logs route.
Suggested fix
const searchConditions = searchTerm - ? or( - ilike(contactSubmissions.name, `%${searchTerm}%`), - ilike(contactSubmissions.email, `%${searchTerm}%`), - ilike(contactSubmissions.subject, `%${searchTerm}%`), - ilike(contactSubmissions.message, `%${searchTerm}%`) + ? (() => { + const escaped = searchTerm + .replace(/\\/g, '\\\\') + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); + const pattern = `%${escaped}%`; + return or( + ilike(contactSubmissions.name, pattern), + ilike(contactSubmissions.email, pattern), + ilike(contactSubmissions.subject, pattern), + ilike(contactSubmissions.message, pattern) + ); + })()
🧹 Nitpick comments (3)
apps/web/src/lib/auth/auth.ts (1)
33-52: Orphaned JSDoc block — the docstring forverifyAdminAuthis separated from the function byAdminRouteContextandwithAdminAuth.The JSDoc starting at Line 33 documents
verifyAdminAuthbut the function itself doesn't appear until Line 82. The intervening type alias andwithAdminAuthHOF make it easy to misattribute this doc. Consider moving it directly aboveverifyAdminAuthat Line 82.apps/web/src/app/api/admin/users/[userId]/gift-subscription/route.ts (2)
86-101: Coupon ID incorporatesDate.now()— low collision risk but non-deterministic.The coupon ID
GIFT_${targetUserId}_${Date.now()}is technically vulnerable to collisions if two admins gift the same user within the same millisecond. This is extremely unlikely for a manual admin action, but if you ever want to make this bulletproof, consider appending a short random suffix. TheDate.now()usage here is for Stripe's external coupon identifier, so the CUID2 convention for internal IDs doesn't apply.
157-157:requestparameter is unused in the DELETE handler.The
requestparameter is passed by thewithAdminAuthwrapper but never referenced in the DELETE handler body. This is harmless but could be prefixed with an underscore for clarity, consistent with the pattern in other routes (e.g.,_requestinusers/route.ts).Suggested change
-export const DELETE = withAdminAuth<RouteContext>(async (adminUser, request, context) => { +export const DELETE = withAdminAuth<RouteContext>(async (adminUser, _request, context) => {
Use function overloads to properly type withAdminAuth: - Routes without context get a single-arg wrapper function - Routes with context get a two-arg wrapper function This fixes the CI TypeScript error where AdminRouteContext including undefined was incompatible with Next.js route handler signatures. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
This PR adds security validation to the gift subscription admin endpoints to prevent unauthorized access through stale admin sessions after role demotion. It implements
verifyAdminAuth()which validates both the user's current role andadminRoleVersionto detect when an admin user has been demoted.Key Changes
verifyAdminAuth()inauth.tsthat validates admin access by checking both role andadminRoleVersionagainst the databaseadmin_role_version_mismatchevents when a stale admin session is detected, capturing details like the claimed version and actual version mismatchauthenticateRequestWithOptions()withverifyAdminAuth()in both POST and DELETE handlers for/api/admin/users/[userId]/gift-subscriptionImplementation Details
verifyAdminAuth()function callsvalidateAdminAccess()which compares theadminRoleVersionin the user's session token against the currentadminRoleVersionin the databasehttps://claude.ai/code/session_01WNEyTFTTg1P1Ko3Wip2GJd
Summary by CodeRabbit
New Features
Security
Tests
Chores