diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 118d9c7..7cae159 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -17,6 +17,7 @@ import { createAuthRoutes } from './routes/auth.js'; import { createTokenRoutes } from './routes/token.js'; import { createAdminRoutes } from './routes/admin.js'; import { createSessionRoutes } from './routes/session.js'; +import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; // Configuration from environment const config = { @@ -87,11 +88,11 @@ async function main(): Promise { }); }); - // Routes - app.use('/auth', createAuthRoutes(db, oauth)); - app.use('/token', createTokenRoutes(db)); - app.use('/admin', createAdminRoutes(db, config.adminToken)); - app.use('/session', createSessionRoutes(db)); + // Routes with rate limiting + app.use('/auth', authRateLimit, createAuthRoutes(db, oauth)); + app.use('/token', apiRateLimit, createTokenRoutes(db)); + app.use('/admin', adminRateLimit, createAdminRoutes(db, config.adminToken)); + app.use('/session', apiRateLimit, createSessionRoutes(db)); // OAuth client metadata (for AT Protocol discovery) app.get('/client-metadata.json', (_req, res) => { diff --git a/gateway/src/middleware/rateLimit.ts b/gateway/src/middleware/rateLimit.ts new file mode 100644 index 0000000..a876752 --- /dev/null +++ b/gateway/src/middleware/rateLimit.ts @@ -0,0 +1,181 @@ +/** + * Rate Limiting Middleware + * + * IP-based rate limiting to protect against brute force and DoS attacks. + */ + +import { Request, Response, NextFunction } from 'express'; + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +interface RateLimitConfig { + windowMs: number; // Time window in milliseconds + maxRequests: number; // Max requests per window + maxTrackedIps: number; // Max IPs to track (DoS protection) +} + +const DEFAULT_CONFIG: RateLimitConfig = { + windowMs: 60 * 1000, // 1 minute + maxRequests: 30, // 30 requests per minute + maxTrackedIps: 10000, // Track up to 10k IPs +}; + +/** + * In-memory rate limit store. + * For production with multiple instances, use Redis instead. + */ +class RateLimitStore { + private entries = new Map(); + private maxEntries: number; + + constructor(maxEntries: number) { + this.maxEntries = maxEntries; + } + + /** + * Check and increment request count for an IP. + * Returns remaining requests, or -1 if rate limited. + */ + check(ip: string, windowMs: number, maxRequests: number): { remaining: number; resetAt: number } { + const now = Date.now(); + const entry = this.entries.get(ip); + + // Clean up expired entry + if (entry && entry.resetAt <= now) { + this.entries.delete(ip); + } + + const current = this.entries.get(ip); + + if (!current) { + // New entry - check if we're at capacity + if (this.entries.size >= this.maxEntries) { + // Evict oldest entries (10% of max) + this.evictOldest(Math.floor(this.maxEntries * 0.1)); + } + + const resetAt = now + windowMs; + this.entries.set(ip, { count: 1, resetAt }); + return { remaining: maxRequests - 1, resetAt }; + } + + // Increment existing entry + current.count++; + + if (current.count > maxRequests) { + return { remaining: -1, resetAt: current.resetAt }; + } + + return { remaining: maxRequests - current.count, resetAt: current.resetAt }; + } + + /** + * Evict the oldest entries. + */ + private evictOldest(count: number): void { + const entries = Array.from(this.entries.entries()) + .sort((a, b) => a[1].resetAt - b[1].resetAt) + .slice(0, count); + + for (const [ip] of entries) { + this.entries.delete(ip); + } + } + + /** + * Periodic cleanup of expired entries. + */ + cleanup(): number { + const now = Date.now(); + let cleaned = 0; + + for (const [ip, entry] of this.entries) { + if (entry.resetAt <= now) { + this.entries.delete(ip); + cleaned++; + } + } + + return cleaned; + } +} + +// Global store instance +const store = new RateLimitStore(DEFAULT_CONFIG.maxTrackedIps); + +// Cleanup every 5 minutes +setInterval(() => store.cleanup(), 5 * 60 * 1000); + +/** + * Get client IP address from request. + * Handles proxied requests via X-Forwarded-For header. + */ +function getClientIp(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + const ips = (typeof forwarded === 'string' ? forwarded : forwarded[0]).split(','); + return ips[0].trim(); + } + return req.ip || req.socket.remoteAddress || 'unknown'; +} + +/** + * Create rate limiting middleware. + * + * @param config - Rate limit configuration + * @returns Express middleware + */ +export function rateLimit(config: Partial = {}): (req: Request, res: Response, next: NextFunction) => void { + const { windowMs, maxRequests } = { ...DEFAULT_CONFIG, ...config }; + + return (req: Request, res: Response, next: NextFunction): void => { + const ip = getClientIp(req); + const { remaining, resetAt } = store.check(ip, windowMs, maxRequests); + + // Set rate limit headers + res.setHeader('X-RateLimit-Limit', maxRequests); + res.setHeader('X-RateLimit-Remaining', Math.max(0, remaining)); + res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt / 1000)); + + if (remaining < 0) { + const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); + res.setHeader('Retry-After', retryAfter); + + res.status(429).json({ + error: 'rate_limited', + message: 'Too many requests. Please try again later.', + retry_after: retryAfter, + }); + return; + } + + next(); + }; +} + +/** + * Stricter rate limit for authentication endpoints. + */ +export const authRateLimit = rateLimit({ + windowMs: 60 * 1000, // 1 minute + maxRequests: 10, // 10 auth attempts per minute +}); + +/** + * Standard rate limit for general API endpoints. + */ +export const apiRateLimit = rateLimit({ + windowMs: 60 * 1000, // 1 minute + maxRequests: 60, // 60 requests per minute +}); + +/** + * Strict rate limit for admin endpoints. + */ +export const adminRateLimit = rateLimit({ + windowMs: 60 * 1000, // 1 minute + maxRequests: 20, // 20 requests per minute +}); diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts index 469c298..3c3a6d0 100644 --- a/gateway/src/routes/admin.ts +++ b/gateway/src/routes/admin.ts @@ -4,9 +4,25 @@ * Application registration and management endpoints */ +import crypto from 'crypto'; import { Router, Request, Response } from 'express'; import { DatabaseService } from '../services/database.js'; import { generateHmacSecret } from '../utils/hmac.js'; +import { internalError } from '../utils/errors.js'; + +/** + * Constant-time string comparison to prevent timing attacks. + */ +function secureCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + + // Prevent length-based timing attacks by always comparing fixed-length hashes + const hashA = crypto.createHash('sha256').update(bufA).digest(); + const hashB = crypto.createHash('sha256').update(bufB).digest(); + + return crypto.timingSafeEqual(hashA, hashB); +} export function createAdminRoutes(db: DatabaseService, adminToken?: string): Router { const router = Router(); @@ -28,7 +44,7 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou } const token = authHeader.substring(7); - if (token !== adminToken) { + if (!secureCompare(token, adminToken)) { return res.status(403).json({ error: 'invalid_token', message: 'Invalid admin token', @@ -86,11 +102,7 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou message: 'Application registered. Store the hmac_secret securely!', }); } catch (error) { - console.error('App registration error:', error); - res.status(500).json({ - error: 'registration_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('registration_failed', error, 'App registration')); } }); @@ -115,11 +127,7 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou callback_url: app.callback_url, }); } catch (error) { - console.error('Get app error:', error); - res.status(500).json({ - error: 'get_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('get_failed', error, 'Get app')); } }); @@ -163,11 +171,7 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou res.json(response); } catch (error) { - console.error('Update app error:', error); - res.status(500).json({ - error: 'update_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('update_failed', error, 'Update app')); } }); @@ -185,11 +189,7 @@ export function createAdminRoutes(db: DatabaseService, adminToken?: string): Rou sessions_deleted: sessionsDeleted, }); } catch (error) { - console.error('Cleanup error:', error); - res.status(500).json({ - error: 'cleanup_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('cleanup_failed', error, 'Cleanup')); } }); diff --git a/gateway/src/routes/auth.ts b/gateway/src/routes/auth.ts index 9344f08..67ee1ad 100644 --- a/gateway/src/routes/auth.ts +++ b/gateway/src/routes/auth.ts @@ -9,6 +9,7 @@ import { v4 as uuidv4 } from 'uuid'; import { OAuthService } from '../services/oauth.js'; import { DatabaseService } from '../services/database.js'; import { createGatewayToken } from '../utils/hmac.js'; +import { internalError } from '../utils/errors.js'; export function createAuthRoutes( db: DatabaseService, @@ -63,11 +64,7 @@ export function createAuthRoutes( app_id, }); } catch (error) { - console.error('Auth init error:', error); - res.status(500).json({ - error: 'auth_init_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('auth_init_failed', error, 'Auth init')); } }); @@ -135,11 +132,18 @@ export function createAuthRoutes( if (savedState.redirect_uri) { const redirectUrl = new URL(savedState.redirect_uri); - redirectUrl.searchParams.set('token', token); - redirectUrl.searchParams.set('session_id', sessionId); + // Use URL fragment (hash) for sensitive data to prevent logging in: + // - Server access logs + // - Browser history + // - Referrer headers + // Fragments are only available client-side and never sent to servers + const fragmentParams = new URLSearchParams(); + fragmentParams.set('token', token); + fragmentParams.set('session_id', sessionId); if (userId === null) { - redirectUrl.searchParams.set('needs_linking', 'true'); + fragmentParams.set('needs_linking', 'true'); } + redirectUrl.hash = fragmentParams.toString(); return res.redirect(redirectUrl.toString()); } @@ -153,11 +157,7 @@ export function createAuthRoutes( expires_at: expiresAt.toISOString(), }); } catch (error) { - console.error('Auth callback error:', error); - res.status(500).json({ - error: 'auth_callback_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('auth_callback_failed', error, 'Auth callback')); } }); @@ -225,11 +225,7 @@ export function createAuthRoutes( user_id: parseInt(user_id, 10), }); } catch (error) { - console.error('Link error:', error); - res.status(500).json({ - error: 'link_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('link_failed', error, 'Link')); } }); @@ -283,11 +279,7 @@ export function createAuthRoutes( expires_in: app.token_ttl_seconds, }); } catch (error) { - console.error('Refresh error:', error); - res.status(500).json({ - error: 'refresh_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('refresh_failed', error, 'Refresh')); } }); @@ -310,11 +302,7 @@ export function createAuthRoutes( res.json({ success: true }); } catch (error) { - console.error('Logout error:', error); - res.status(500).json({ - error: 'logout_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('logout_failed', error, 'Logout')); } }); diff --git a/gateway/src/routes/session.ts b/gateway/src/routes/session.ts index 98e614c..7e48a55 100644 --- a/gateway/src/routes/session.ts +++ b/gateway/src/routes/session.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import { DatabaseService } from '../services/database.js'; import { createGatewayToken } from '../utils/hmac.js'; +import { internalError } from '../utils/errors.js'; import type { SessionResolution, SessionConflict } from '../types/index.js'; export function createSessionRoutes(db: DatabaseService): Router { @@ -64,11 +65,7 @@ export function createSessionRoutes(db: DatabaseService): Router { res.json(response); } catch (error) { - console.error('Check conflict error:', error); - res.status(500).json({ - error: 'check_conflict_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('check_conflict_failed', error, 'Check conflict')); } }); @@ -152,11 +149,7 @@ export function createSessionRoutes(db: DatabaseService): Router { } } } catch (error) { - console.error('Resolve conflict error:', error); - res.status(500).json({ - error: 'resolve_conflict_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('resolve_conflict_failed', error, 'Resolve conflict')); } }); @@ -195,11 +188,7 @@ export function createSessionRoutes(db: DatabaseService): Router { res.json({ success: true, session_id, state }); } catch (error) { - console.error('Update state error:', error); - res.status(500).json({ - error: 'update_state_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('update_state_failed', error, 'Update state')); } }); @@ -230,11 +219,7 @@ export function createSessionRoutes(db: DatabaseService): Router { res.json({ success: true, session_id }); } catch (error) { - console.error('Heartbeat error:', error); - res.status(500).json({ - error: 'heartbeat_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('heartbeat_failed', error, 'Heartbeat')); } }); @@ -281,11 +266,7 @@ export function createSessionRoutes(db: DatabaseService): Router { })), }); } catch (error) { - console.error('List active sessions error:', error); - res.status(500).json({ - error: 'list_sessions_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('list_sessions_failed', error, 'List active sessions')); } }); diff --git a/gateway/src/routes/token.ts b/gateway/src/routes/token.ts index 5e166e1..1884de4 100644 --- a/gateway/src/routes/token.ts +++ b/gateway/src/routes/token.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import { DatabaseService } from '../services/database.js'; import { verifyGatewayToken } from '../utils/hmac.js'; +import { internalError } from '../utils/errors.js'; export function createTokenRoutes(db: DatabaseService): Router { const router = Router(); @@ -68,12 +69,8 @@ export function createTokenRoutes(db: DatabaseService): Router { }, }); } catch (error) { - console.error('Token verify error:', error); - res.status(500).json({ - valid: false, - error: 'verify_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + const errResponse = internalError('verify_failed', error, 'Token verify'); + res.status(500).json({ valid: false, ...errResponse }); } }); @@ -121,11 +118,7 @@ export function createTokenRoutes(db: DatabaseService): Router { remaining_seconds: remainingSeconds, }); } catch (error) { - console.error('Token info error:', error); - res.status(500).json({ - error: 'info_failed', - message: error instanceof Error ? error.message : 'Unknown error', - }); + res.status(500).json(internalError('info_failed', error, 'Token info')); } }); diff --git a/gateway/src/utils/errors.ts b/gateway/src/utils/errors.ts new file mode 100644 index 0000000..a57aac1 --- /dev/null +++ b/gateway/src/utils/errors.ts @@ -0,0 +1,57 @@ +/** + * Error Handling Utilities + * + * Provides safe error responses that don't leak internal details. + */ + +/** + * Sanitize an error for client response. + * Logs the full error server-side but returns a safe message to clients. + * + * @param error - The caught error + * @param context - Context for logging (e.g., "Token verify") + * @returns Safe error message for client response + */ +export function sanitizeError(error: unknown, context: string): string { + // Log full error details server-side for debugging + console.error(`${context} error:`, error); + + // In development, return more details for debugging + // In production, return a generic message + if (process.env.NODE_ENV === 'development') { + if (error instanceof Error) { + // Even in dev, don't expose stack traces or sensitive paths + return error.message.replace(/\/[^\s:]+/g, '[path]'); + } + } + + // Generic message that doesn't leak implementation details + return 'An internal error occurred. Please try again later.'; +} + +/** + * Standard error response format. + */ +export interface ErrorResponse { + error: string; + message: string; +} + +/** + * Create a safe 500 error response. + * + * @param errorCode - Machine-readable error code + * @param error - The caught error + * @param context - Context for logging + * @returns Error response object + */ +export function internalError( + errorCode: string, + error: unknown, + context: string +): ErrorResponse { + return { + error: errorCode, + message: sanitizeError(error, context), + }; +} diff --git a/gateway/src/utils/hmac.ts b/gateway/src/utils/hmac.ts index 23721bb..032a971 100644 --- a/gateway/src/utils/hmac.ts +++ b/gateway/src/utils/hmac.ts @@ -59,11 +59,16 @@ export function verifyGatewayToken( .update(payloadBase64) .digest('base64url'); - // Constant-time comparison - if (!crypto.timingSafeEqual( - Buffer.from(providedSignature), - Buffer.from(expectedSignature) - )) { + // Constant-time comparison with length check to prevent DoS + // timingSafeEqual throws if buffer lengths differ, so we check first + const providedBuf = Buffer.from(providedSignature); + const expectedBuf = Buffer.from(expectedSignature); + + if (providedBuf.length !== expectedBuf.length) { + return null; + } + + if (!crypto.timingSafeEqual(providedBuf, expectedBuf)) { return null; } diff --git a/ts/src/index.ts b/ts/src/index.ts index 09dbfa3..cbf8aa4 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -67,6 +67,12 @@ export { buildLogoutUrl, } from './oauth'; +// Validation utilities +export { + parseOAuthState, + isValidAppId, +} from './validation'; + /** * Library version */ diff --git a/ts/src/oauth.ts b/ts/src/oauth.ts index 9949952..1401a36 100644 --- a/ts/src/oauth.ts +++ b/ts/src/oauth.ts @@ -5,6 +5,7 @@ import type { AtAuthConfig, OAuthState, OAuthCallbackResult } from './types'; import { decodeToken } from './token'; import { storeToken, storeOAuthState, getOAuthState, generateNonce } from './storage'; +import { parseOAuthState } from './validation'; /** * Default configuration @@ -76,6 +77,8 @@ export function redirectToAuth( * Handle OAuth callback. * * Call this function on your callback page to process the authentication result. + * Tokens are passed via URL fragment (hash) for security - they won't appear + * in server logs, browser history, or referrer headers. * * @param config - Auth configuration * @returns Callback result with token or error @@ -93,9 +96,15 @@ export function handleCallback( } const url = new URL(window.location.href); - const token = url.searchParams.get('token'); + + // Token is passed in URL fragment (hash) for security + // Fragments are never sent to servers, keeping tokens out of logs + const fragmentParams = new URLSearchParams(url.hash.slice(1)); + const token = fragmentParams.get('token'); + + // Error may come via query param from gateway const error = url.searchParams.get('error'); - const stateParam = url.searchParams.get('state'); + const stateParam = url.searchParams.get('state') || fragmentParams.get('state'); // Handle error from gateway if (error) { @@ -113,26 +122,30 @@ export function handleCallback( }; } - // Verify state/CSRF + // Verify state/CSRF with proper validation const storedState = getOAuthState(); let returnTo: string | undefined; if (stateParam) { - try { - const receivedState = JSON.parse(stateParam) as OAuthState; - - // Verify nonce matches - if (storedState?.nonce && receivedState.nonce !== storedState.nonce) { - return { - success: false, - error: 'Invalid OAuth state (CSRF protection)', - }; - } - - returnTo = receivedState.returnTo; - } catch { - // State parse error - might be okay if state wasn't used + // Use secure parsing with validation + const receivedState = parseOAuthState(stateParam); + + if (!receivedState) { + return { + success: false, + error: 'Invalid OAuth state format', + }; + } + + // Verify nonce matches + if (storedState?.nonce && receivedState.nonce !== storedState.nonce) { + return { + success: false, + error: 'Invalid OAuth state (CSRF protection)', + }; } + + returnTo = receivedState.returnTo; } // Decode token @@ -169,10 +182,12 @@ function cleanCallbackUrl(): void { if (typeof window === 'undefined') return; const url = new URL(window.location.href); - url.searchParams.delete('token'); url.searchParams.delete('error'); url.searchParams.delete('state'); + // Clear the hash (contains sensitive token data) + url.hash = ''; + // Update URL without reload window.history.replaceState({}, document.title, url.pathname + url.search); } @@ -186,7 +201,10 @@ export function isOAuthCallback(): boolean { if (typeof window === 'undefined') return false; const url = new URL(window.location.href); - return url.searchParams.has('token') || url.searchParams.has('error'); + const fragmentParams = new URLSearchParams(url.hash.slice(1)); + + // Token comes via fragment, error via query param + return fragmentParams.has('token') || url.searchParams.has('error'); } /** diff --git a/ts/src/storage.ts b/ts/src/storage.ts index c92f87e..498b828 100644 --- a/ts/src/storage.ts +++ b/ts/src/storage.ts @@ -118,23 +118,25 @@ export function getOAuthState(): Record | null { * * @param length - Nonce length (default: 32) * @returns Random string + * @throws Error if no secure random source is available */ export function generateNonce(length = 32): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - const values = new Uint32Array(length); - crypto.getRandomValues(values); - for (let i = 0; i < length; i++) { - result += chars[values[i] % chars.length]; - } - } else { - // Fallback for older environments - for (let i = 0; i < length; i++) { - result += chars[Math.floor(Math.random() * chars.length)]; - } + // Require crypto.getRandomValues - no insecure fallback + if (typeof crypto === 'undefined' || !crypto.getRandomValues) { + throw new Error( + 'Secure random number generator not available. ' + + 'crypto.getRandomValues is required for CSRF protection.' + ); + } + + const values = new Uint32Array(length); + crypto.getRandomValues(values); + for (let i = 0; i < length; i++) { + result += chars[values[i] % chars.length]; } return result; diff --git a/ts/src/validation.ts b/ts/src/validation.ts new file mode 100644 index 0000000..a833614 --- /dev/null +++ b/ts/src/validation.ts @@ -0,0 +1,148 @@ +/** + * Input validation utilities + */ + +import type { OAuthState } from './types'; + +/** + * Maximum allowed size for OAuth state string to prevent DoS. + */ +const MAX_STATE_SIZE = 4096; + +/** + * Maximum nesting depth for state object. + */ +const MAX_NESTING_DEPTH = 3; + +/** + * Validate a DID (Decentralized Identifier) format. + * + * @param did - The DID string to validate + * @returns True if valid DID format + */ +export function isValidDid(did: unknown): did is string { + if (typeof did !== 'string') return false; + + // AT Protocol DIDs start with "did:plc:" or "did:web:" + return /^did:(plc|web):[a-zA-Z0-9._%-]+$/.test(did); +} + +/** + * Validate an AT Protocol handle format. + * + * @param handle - The handle to validate + * @returns True if valid handle format + */ +export function isValidHandle(handle: unknown): handle is string { + if (typeof handle !== 'string') return false; + + // Handles are domain-like strings (e.g., "user.bsky.social") + // Must be lowercase alphanumeric with dots, 3-253 chars + if (handle.length < 3 || handle.length > 253) return false; + + return /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(handle) && + !handle.includes('..'); +} + +/** + * Check object nesting depth. + */ +function getDepth(obj: unknown, current = 0): number { + if (current > MAX_NESTING_DEPTH) return current; + if (typeof obj !== 'object' || obj === null) return current; + + let maxDepth = current; + for (const value of Object.values(obj)) { + const depth = getDepth(value, current + 1); + if (depth > maxDepth) maxDepth = depth; + } + return maxDepth; +} + +/** + * Validate and parse OAuth state from a string. + * Guards against malformed JSON, oversized payloads, and deeply nested objects. + * + * @param stateString - The state string from URL parameter + * @returns Validated OAuthState or null if invalid + */ +export function parseOAuthState(stateString: unknown): OAuthState | null { + // Must be a string + if (typeof stateString !== 'string') { + return null; + } + + // Check size limit to prevent DoS + if (stateString.length > MAX_STATE_SIZE) { + console.warn('OAuth state exceeds maximum size'); + return null; + } + + // Parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(stateString); + } catch { + console.warn('OAuth state is not valid JSON'); + return null; + } + + // Must be an object + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + console.warn('OAuth state must be an object'); + return null; + } + + // Check nesting depth + if (getDepth(parsed) > MAX_NESTING_DEPTH) { + console.warn('OAuth state exceeds maximum nesting depth'); + return null; + } + + // Validate known fields + const state = parsed as Record; + + // returnTo must be a string if present + if ('returnTo' in state && typeof state.returnTo !== 'string') { + console.warn('OAuth state returnTo must be a string'); + return null; + } + + // nonce must be a string if present + if ('nonce' in state && typeof state.nonce !== 'string') { + console.warn('OAuth state nonce must be a string'); + return null; + } + + // Validate returnTo is a safe URL (no javascript: etc.) + if (typeof state.returnTo === 'string') { + try { + const url = new URL(state.returnTo, window?.location?.origin || 'https://example.com'); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + console.warn('OAuth state returnTo has invalid protocol'); + return null; + } + } catch { + // Relative URLs are OK, but must not contain dangerous schemes + if (/^(javascript|data|vbscript):/i.test(state.returnTo)) { + console.warn('OAuth state returnTo has dangerous scheme'); + return null; + } + } + } + + return state as OAuthState; +} + +/** + * Validate an app ID. + * + * @param appId - The app ID to validate + * @returns True if valid + */ +export function isValidAppId(appId: unknown): appId is string { + if (typeof appId !== 'string') return false; + + // App IDs should be alphanumeric with hyphens/underscores, 1-64 chars + return /^[a-zA-Z0-9_-]{1,64}$/.test(appId); +}