AtAuth
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

Security fixes for HIGH and MEDIUM severity vulnerabilities

HIGH:
- H1: Fix admin token timing attack using constant-time comparison
- H2: Fix HMAC length mismatch DoS with pre-check before timingSafeEqual

MEDIUM:
- M1: Remove weak PRNG fallback, require crypto.getRandomValues
- M2: Move tokens from URL query params to fragments for security
- M3: Add IP-based rate limiting middleware for all gateway routes
- M4: Sanitize error messages to prevent information disclosure
- M5: Add OAuth state validation with size/depth limits

New files:
- gateway/src/middleware/rateLimit.ts: IP-based rate limiting
- gateway/src/utils/errors.ts: Safe error response handling
- ts/src/validation.ts: Input validation utilities

+503 -123
+6 -5
gateway/src/index.ts
··· 17 17 import { createTokenRoutes } from './routes/token.js'; 18 18 import { createAdminRoutes } from './routes/admin.js'; 19 19 import { createSessionRoutes } from './routes/session.js'; 20 + import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; 20 21 21 22 // Configuration from environment 22 23 const config = { ··· 87 88 }); 88 89 }); 89 90 90 - // Routes 91 - app.use('/auth', createAuthRoutes(db, oauth)); 92 - app.use('/token', createTokenRoutes(db)); 93 - app.use('/admin', createAdminRoutes(db, config.adminToken)); 94 - app.use('/session', createSessionRoutes(db)); 91 + // Routes with rate limiting 92 + app.use('/auth', authRateLimit, createAuthRoutes(db, oauth)); 93 + app.use('/token', apiRateLimit, createTokenRoutes(db)); 94 + app.use('/admin', adminRateLimit, createAdminRoutes(db, config.adminToken)); 95 + app.use('/session', apiRateLimit, createSessionRoutes(db)); 95 96 96 97 // OAuth client metadata (for AT Protocol discovery) 97 98 app.get('/client-metadata.json', (_req, res) => {
+181
gateway/src/middleware/rateLimit.ts
··· 1 + /** 2 + * Rate Limiting Middleware 3 + * 4 + * IP-based rate limiting to protect against brute force and DoS attacks. 5 + */ 6 + 7 + import { Request, Response, NextFunction } from 'express'; 8 + 9 + interface RateLimitEntry { 10 + count: number; 11 + resetAt: number; 12 + } 13 + 14 + interface RateLimitConfig { 15 + windowMs: number; // Time window in milliseconds 16 + maxRequests: number; // Max requests per window 17 + maxTrackedIps: number; // Max IPs to track (DoS protection) 18 + } 19 + 20 + const DEFAULT_CONFIG: RateLimitConfig = { 21 + windowMs: 60 * 1000, // 1 minute 22 + maxRequests: 30, // 30 requests per minute 23 + maxTrackedIps: 10000, // Track up to 10k IPs 24 + }; 25 + 26 + /** 27 + * In-memory rate limit store. 28 + * For production with multiple instances, use Redis instead. 29 + */ 30 + class RateLimitStore { 31 + private entries = new Map<string, RateLimitEntry>(); 32 + private maxEntries: number; 33 + 34 + constructor(maxEntries: number) { 35 + this.maxEntries = maxEntries; 36 + } 37 + 38 + /** 39 + * Check and increment request count for an IP. 40 + * Returns remaining requests, or -1 if rate limited. 41 + */ 42 + check(ip: string, windowMs: number, maxRequests: number): { remaining: number; resetAt: number } { 43 + const now = Date.now(); 44 + const entry = this.entries.get(ip); 45 + 46 + // Clean up expired entry 47 + if (entry && entry.resetAt <= now) { 48 + this.entries.delete(ip); 49 + } 50 + 51 + const current = this.entries.get(ip); 52 + 53 + if (!current) { 54 + // New entry - check if we're at capacity 55 + if (this.entries.size >= this.maxEntries) { 56 + // Evict oldest entries (10% of max) 57 + this.evictOldest(Math.floor(this.maxEntries * 0.1)); 58 + } 59 + 60 + const resetAt = now + windowMs; 61 + this.entries.set(ip, { count: 1, resetAt }); 62 + return { remaining: maxRequests - 1, resetAt }; 63 + } 64 + 65 + // Increment existing entry 66 + current.count++; 67 + 68 + if (current.count > maxRequests) { 69 + return { remaining: -1, resetAt: current.resetAt }; 70 + } 71 + 72 + return { remaining: maxRequests - current.count, resetAt: current.resetAt }; 73 + } 74 + 75 + /** 76 + * Evict the oldest entries. 77 + */ 78 + private evictOldest(count: number): void { 79 + const entries = Array.from(this.entries.entries()) 80 + .sort((a, b) => a[1].resetAt - b[1].resetAt) 81 + .slice(0, count); 82 + 83 + for (const [ip] of entries) { 84 + this.entries.delete(ip); 85 + } 86 + } 87 + 88 + /** 89 + * Periodic cleanup of expired entries. 90 + */ 91 + cleanup(): number { 92 + const now = Date.now(); 93 + let cleaned = 0; 94 + 95 + for (const [ip, entry] of this.entries) { 96 + if (entry.resetAt <= now) { 97 + this.entries.delete(ip); 98 + cleaned++; 99 + } 100 + } 101 + 102 + return cleaned; 103 + } 104 + } 105 + 106 + // Global store instance 107 + const store = new RateLimitStore(DEFAULT_CONFIG.maxTrackedIps); 108 + 109 + // Cleanup every 5 minutes 110 + setInterval(() => store.cleanup(), 5 * 60 * 1000); 111 + 112 + /** 113 + * Get client IP address from request. 114 + * Handles proxied requests via X-Forwarded-For header. 115 + */ 116 + function getClientIp(req: Request): string { 117 + const forwarded = req.headers['x-forwarded-for']; 118 + if (forwarded) { 119 + const ips = (typeof forwarded === 'string' ? forwarded : forwarded[0]).split(','); 120 + return ips[0].trim(); 121 + } 122 + return req.ip || req.socket.remoteAddress || 'unknown'; 123 + } 124 + 125 + /** 126 + * Create rate limiting middleware. 127 + * 128 + * @param config - Rate limit configuration 129 + * @returns Express middleware 130 + */ 131 + export function rateLimit(config: Partial<RateLimitConfig> = {}): (req: Request, res: Response, next: NextFunction) => void { 132 + const { windowMs, maxRequests } = { ...DEFAULT_CONFIG, ...config }; 133 + 134 + return (req: Request, res: Response, next: NextFunction): void => { 135 + const ip = getClientIp(req); 136 + const { remaining, resetAt } = store.check(ip, windowMs, maxRequests); 137 + 138 + // Set rate limit headers 139 + res.setHeader('X-RateLimit-Limit', maxRequests); 140 + res.setHeader('X-RateLimit-Remaining', Math.max(0, remaining)); 141 + res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt / 1000)); 142 + 143 + if (remaining < 0) { 144 + const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); 145 + res.setHeader('Retry-After', retryAfter); 146 + 147 + res.status(429).json({ 148 + error: 'rate_limited', 149 + message: 'Too many requests. Please try again later.', 150 + retry_after: retryAfter, 151 + }); 152 + return; 153 + } 154 + 155 + next(); 156 + }; 157 + } 158 + 159 + /** 160 + * Stricter rate limit for authentication endpoints. 161 + */ 162 + export const authRateLimit = rateLimit({ 163 + windowMs: 60 * 1000, // 1 minute 164 + maxRequests: 10, // 10 auth attempts per minute 165 + }); 166 + 167 + /** 168 + * Standard rate limit for general API endpoints. 169 + */ 170 + export const apiRateLimit = rateLimit({ 171 + windowMs: 60 * 1000, // 1 minute 172 + maxRequests: 60, // 60 requests per minute 173 + }); 174 + 175 + /** 176 + * Strict rate limit for admin endpoints. 177 + */ 178 + export const adminRateLimit = rateLimit({ 179 + windowMs: 60 * 1000, // 1 minute 180 + maxRequests: 20, // 20 requests per minute 181 + });
+21 -21
gateway/src/routes/admin.ts
··· 4 4 * Application registration and management endpoints 5 5 */ 6 6 7 + import crypto from 'crypto'; 7 8 import { Router, Request, Response } from 'express'; 8 9 import { DatabaseService } from '../services/database.js'; 9 10 import { generateHmacSecret } from '../utils/hmac.js'; 11 + import { internalError } from '../utils/errors.js'; 12 + 13 + /** 14 + * Constant-time string comparison to prevent timing attacks. 15 + */ 16 + function secureCompare(a: string, b: string): boolean { 17 + const bufA = Buffer.from(a); 18 + const bufB = Buffer.from(b); 19 + 20 + // Prevent length-based timing attacks by always comparing fixed-length hashes 21 + const hashA = crypto.createHash('sha256').update(bufA).digest(); 22 + const hashB = crypto.createHash('sha256').update(bufB).digest(); 23 + 24 + return crypto.timingSafeEqual(hashA, hashB); 25 + } 10 26 11 27 export function createAdminRoutes(db: DatabaseService, adminToken?: string): Router { 12 28 const router = Router(); ··· 28 44 } 29 45 30 46 const token = authHeader.substring(7); 31 - if (token !== adminToken) { 47 + if (!secureCompare(token, adminToken)) { 32 48 return res.status(403).json({ 33 49 error: 'invalid_token', 34 50 message: 'Invalid admin token', ··· 86 102 message: 'Application registered. Store the hmac_secret securely!', 87 103 }); 88 104 } catch (error) { 89 - console.error('App registration error:', error); 90 - res.status(500).json({ 91 - error: 'registration_failed', 92 - message: error instanceof Error ? error.message : 'Unknown error', 93 - }); 105 + res.status(500).json(internalError('registration_failed', error, 'App registration')); 94 106 } 95 107 }); 96 108 ··· 115 127 callback_url: app.callback_url, 116 128 }); 117 129 } catch (error) { 118 - console.error('Get app error:', error); 119 - res.status(500).json({ 120 - error: 'get_failed', 121 - message: error instanceof Error ? error.message : 'Unknown error', 122 - }); 130 + res.status(500).json(internalError('get_failed', error, 'Get app')); 123 131 } 124 132 }); 125 133 ··· 163 171 164 172 res.json(response); 165 173 } catch (error) { 166 - console.error('Update app error:', error); 167 - res.status(500).json({ 168 - error: 'update_failed', 169 - message: error instanceof Error ? error.message : 'Unknown error', 170 - }); 174 + res.status(500).json(internalError('update_failed', error, 'Update app')); 171 175 } 172 176 }); 173 177 ··· 185 189 sessions_deleted: sessionsDeleted, 186 190 }); 187 191 } catch (error) { 188 - console.error('Cleanup error:', error); 189 - res.status(500).json({ 190 - error: 'cleanup_failed', 191 - message: error instanceof Error ? error.message : 'Unknown error', 192 - }); 192 + res.status(500).json(internalError('cleanup_failed', error, 'Cleanup')); 193 193 } 194 194 }); 195 195
+16 -28
gateway/src/routes/auth.ts
··· 9 9 import { OAuthService } from '../services/oauth.js'; 10 10 import { DatabaseService } from '../services/database.js'; 11 11 import { createGatewayToken } from '../utils/hmac.js'; 12 + import { internalError } from '../utils/errors.js'; 12 13 13 14 export function createAuthRoutes( 14 15 db: DatabaseService, ··· 63 64 app_id, 64 65 }); 65 66 } catch (error) { 66 - console.error('Auth init error:', error); 67 - res.status(500).json({ 68 - error: 'auth_init_failed', 69 - message: error instanceof Error ? error.message : 'Unknown error', 70 - }); 67 + res.status(500).json(internalError('auth_init_failed', error, 'Auth init')); 71 68 } 72 69 }); 73 70 ··· 135 132 136 133 if (savedState.redirect_uri) { 137 134 const redirectUrl = new URL(savedState.redirect_uri); 138 - redirectUrl.searchParams.set('token', token); 139 - redirectUrl.searchParams.set('session_id', sessionId); 135 + // Use URL fragment (hash) for sensitive data to prevent logging in: 136 + // - Server access logs 137 + // - Browser history 138 + // - Referrer headers 139 + // Fragments are only available client-side and never sent to servers 140 + const fragmentParams = new URLSearchParams(); 141 + fragmentParams.set('token', token); 142 + fragmentParams.set('session_id', sessionId); 140 143 if (userId === null) { 141 - redirectUrl.searchParams.set('needs_linking', 'true'); 144 + fragmentParams.set('needs_linking', 'true'); 142 145 } 146 + redirectUrl.hash = fragmentParams.toString(); 143 147 return res.redirect(redirectUrl.toString()); 144 148 } 145 149 ··· 153 157 expires_at: expiresAt.toISOString(), 154 158 }); 155 159 } catch (error) { 156 - console.error('Auth callback error:', error); 157 - res.status(500).json({ 158 - error: 'auth_callback_failed', 159 - message: error instanceof Error ? error.message : 'Unknown error', 160 - }); 160 + res.status(500).json(internalError('auth_callback_failed', error, 'Auth callback')); 161 161 } 162 162 }); 163 163 ··· 225 225 user_id: parseInt(user_id, 10), 226 226 }); 227 227 } catch (error) { 228 - console.error('Link error:', error); 229 - res.status(500).json({ 230 - error: 'link_failed', 231 - message: error instanceof Error ? error.message : 'Unknown error', 232 - }); 228 + res.status(500).json(internalError('link_failed', error, 'Link')); 233 229 } 234 230 }); 235 231 ··· 283 279 expires_in: app.token_ttl_seconds, 284 280 }); 285 281 } catch (error) { 286 - console.error('Refresh error:', error); 287 - res.status(500).json({ 288 - error: 'refresh_failed', 289 - message: error instanceof Error ? error.message : 'Unknown error', 290 - }); 282 + res.status(500).json(internalError('refresh_failed', error, 'Refresh')); 291 283 } 292 284 }); 293 285 ··· 310 302 311 303 res.json({ success: true }); 312 304 } catch (error) { 313 - console.error('Logout error:', error); 314 - res.status(500).json({ 315 - error: 'logout_failed', 316 - message: error instanceof Error ? error.message : 'Unknown error', 317 - }); 305 + res.status(500).json(internalError('logout_failed', error, 'Logout')); 318 306 } 319 307 }); 320 308
+6 -25
gateway/src/routes/session.ts
··· 7 7 import { Router, Request, Response } from 'express'; 8 8 import { DatabaseService } from '../services/database.js'; 9 9 import { createGatewayToken } from '../utils/hmac.js'; 10 + import { internalError } from '../utils/errors.js'; 10 11 import type { SessionResolution, SessionConflict } from '../types/index.js'; 11 12 12 13 export function createSessionRoutes(db: DatabaseService): Router { ··· 64 65 65 66 res.json(response); 66 67 } catch (error) { 67 - console.error('Check conflict error:', error); 68 - res.status(500).json({ 69 - error: 'check_conflict_failed', 70 - message: error instanceof Error ? error.message : 'Unknown error', 71 - }); 68 + res.status(500).json(internalError('check_conflict_failed', error, 'Check conflict')); 72 69 } 73 70 }); 74 71 ··· 152 149 } 153 150 } 154 151 } catch (error) { 155 - console.error('Resolve conflict error:', error); 156 - res.status(500).json({ 157 - error: 'resolve_conflict_failed', 158 - message: error instanceof Error ? error.message : 'Unknown error', 159 - }); 152 + res.status(500).json(internalError('resolve_conflict_failed', error, 'Resolve conflict')); 160 153 } 161 154 }); 162 155 ··· 195 188 196 189 res.json({ success: true, session_id, state }); 197 190 } catch (error) { 198 - console.error('Update state error:', error); 199 - res.status(500).json({ 200 - error: 'update_state_failed', 201 - message: error instanceof Error ? error.message : 'Unknown error', 202 - }); 191 + res.status(500).json(internalError('update_state_failed', error, 'Update state')); 203 192 } 204 193 }); 205 194 ··· 230 219 231 220 res.json({ success: true, session_id }); 232 221 } catch (error) { 233 - console.error('Heartbeat error:', error); 234 - res.status(500).json({ 235 - error: 'heartbeat_failed', 236 - message: error instanceof Error ? error.message : 'Unknown error', 237 - }); 222 + res.status(500).json(internalError('heartbeat_failed', error, 'Heartbeat')); 238 223 } 239 224 }); 240 225 ··· 281 266 })), 282 267 }); 283 268 } catch (error) { 284 - console.error('List active sessions error:', error); 285 - res.status(500).json({ 286 - error: 'list_sessions_failed', 287 - message: error instanceof Error ? error.message : 'Unknown error', 288 - }); 269 + res.status(500).json(internalError('list_sessions_failed', error, 'List active sessions')); 289 270 } 290 271 }); 291 272
+4 -11
gateway/src/routes/token.ts
··· 7 7 import { Router, Request, Response } from 'express'; 8 8 import { DatabaseService } from '../services/database.js'; 9 9 import { verifyGatewayToken } from '../utils/hmac.js'; 10 + import { internalError } from '../utils/errors.js'; 10 11 11 12 export function createTokenRoutes(db: DatabaseService): Router { 12 13 const router = Router(); ··· 68 69 }, 69 70 }); 70 71 } catch (error) { 71 - console.error('Token verify error:', error); 72 - res.status(500).json({ 73 - valid: false, 74 - error: 'verify_failed', 75 - message: error instanceof Error ? error.message : 'Unknown error', 76 - }); 72 + const errResponse = internalError('verify_failed', error, 'Token verify'); 73 + res.status(500).json({ valid: false, ...errResponse }); 77 74 } 78 75 }); 79 76 ··· 121 118 remaining_seconds: remainingSeconds, 122 119 }); 123 120 } catch (error) { 124 - console.error('Token info error:', error); 125 - res.status(500).json({ 126 - error: 'info_failed', 127 - message: error instanceof Error ? error.message : 'Unknown error', 128 - }); 121 + res.status(500).json(internalError('info_failed', error, 'Token info')); 129 122 } 130 123 }); 131 124
+57
gateway/src/utils/errors.ts
··· 1 + /** 2 + * Error Handling Utilities 3 + * 4 + * Provides safe error responses that don't leak internal details. 5 + */ 6 + 7 + /** 8 + * Sanitize an error for client response. 9 + * Logs the full error server-side but returns a safe message to clients. 10 + * 11 + * @param error - The caught error 12 + * @param context - Context for logging (e.g., "Token verify") 13 + * @returns Safe error message for client response 14 + */ 15 + export function sanitizeError(error: unknown, context: string): string { 16 + // Log full error details server-side for debugging 17 + console.error(`${context} error:`, error); 18 + 19 + // In development, return more details for debugging 20 + // In production, return a generic message 21 + if (process.env.NODE_ENV === 'development') { 22 + if (error instanceof Error) { 23 + // Even in dev, don't expose stack traces or sensitive paths 24 + return error.message.replace(/\/[^\s:]+/g, '[path]'); 25 + } 26 + } 27 + 28 + // Generic message that doesn't leak implementation details 29 + return 'An internal error occurred. Please try again later.'; 30 + } 31 + 32 + /** 33 + * Standard error response format. 34 + */ 35 + export interface ErrorResponse { 36 + error: string; 37 + message: string; 38 + } 39 + 40 + /** 41 + * Create a safe 500 error response. 42 + * 43 + * @param errorCode - Machine-readable error code 44 + * @param error - The caught error 45 + * @param context - Context for logging 46 + * @returns Error response object 47 + */ 48 + export function internalError( 49 + errorCode: string, 50 + error: unknown, 51 + context: string 52 + ): ErrorResponse { 53 + return { 54 + error: errorCode, 55 + message: sanitizeError(error, context), 56 + }; 57 + }
+10 -5
gateway/src/utils/hmac.ts
··· 59 59 .update(payloadBase64) 60 60 .digest('base64url'); 61 61 62 - // Constant-time comparison 63 - if (!crypto.timingSafeEqual( 64 - Buffer.from(providedSignature), 65 - Buffer.from(expectedSignature) 66 - )) { 62 + // Constant-time comparison with length check to prevent DoS 63 + // timingSafeEqual throws if buffer lengths differ, so we check first 64 + const providedBuf = Buffer.from(providedSignature); 65 + const expectedBuf = Buffer.from(expectedSignature); 66 + 67 + if (providedBuf.length !== expectedBuf.length) { 68 + return null; 69 + } 70 + 71 + if (!crypto.timingSafeEqual(providedBuf, expectedBuf)) { 67 72 return null; 68 73 } 69 74
+6
ts/src/index.ts
··· 67 67 buildLogoutUrl, 68 68 } from './oauth'; 69 69 70 + // Validation utilities 71 + export { 72 + parseOAuthState, 73 + isValidAppId, 74 + } from './validation'; 75 + 70 76 /** 71 77 * Library version 72 78 */
+35 -17
ts/src/oauth.ts
··· 5 5 import type { AtAuthConfig, OAuthState, OAuthCallbackResult } from './types'; 6 6 import { decodeToken } from './token'; 7 7 import { storeToken, storeOAuthState, getOAuthState, generateNonce } from './storage'; 8 + import { parseOAuthState } from './validation'; 8 9 9 10 /** 10 11 * Default configuration ··· 76 77 * Handle OAuth callback. 77 78 * 78 79 * Call this function on your callback page to process the authentication result. 80 + * Tokens are passed via URL fragment (hash) for security - they won't appear 81 + * in server logs, browser history, or referrer headers. 79 82 * 80 83 * @param config - Auth configuration 81 84 * @returns Callback result with token or error ··· 93 96 } 94 97 95 98 const url = new URL(window.location.href); 96 - const token = url.searchParams.get('token'); 99 + 100 + // Token is passed in URL fragment (hash) for security 101 + // Fragments are never sent to servers, keeping tokens out of logs 102 + const fragmentParams = new URLSearchParams(url.hash.slice(1)); 103 + const token = fragmentParams.get('token'); 104 + 105 + // Error may come via query param from gateway 97 106 const error = url.searchParams.get('error'); 98 - const stateParam = url.searchParams.get('state'); 107 + const stateParam = url.searchParams.get('state') || fragmentParams.get('state'); 99 108 100 109 // Handle error from gateway 101 110 if (error) { ··· 113 122 }; 114 123 } 115 124 116 - // Verify state/CSRF 125 + // Verify state/CSRF with proper validation 117 126 const storedState = getOAuthState(); 118 127 let returnTo: string | undefined; 119 128 120 129 if (stateParam) { 121 - try { 122 - const receivedState = JSON.parse(stateParam) as OAuthState; 130 + // Use secure parsing with validation 131 + const receivedState = parseOAuthState(stateParam); 123 132 124 - // Verify nonce matches 125 - if (storedState?.nonce && receivedState.nonce !== storedState.nonce) { 126 - return { 127 - success: false, 128 - error: 'Invalid OAuth state (CSRF protection)', 129 - }; 130 - } 133 + if (!receivedState) { 134 + return { 135 + success: false, 136 + error: 'Invalid OAuth state format', 137 + }; 138 + } 131 139 132 - returnTo = receivedState.returnTo; 133 - } catch { 134 - // State parse error - might be okay if state wasn't used 140 + // Verify nonce matches 141 + if (storedState?.nonce && receivedState.nonce !== storedState.nonce) { 142 + return { 143 + success: false, 144 + error: 'Invalid OAuth state (CSRF protection)', 145 + }; 135 146 } 147 + 148 + returnTo = receivedState.returnTo; 136 149 } 137 150 138 151 // Decode token ··· 169 182 if (typeof window === 'undefined') return; 170 183 171 184 const url = new URL(window.location.href); 172 - url.searchParams.delete('token'); 173 185 url.searchParams.delete('error'); 174 186 url.searchParams.delete('state'); 187 + 188 + // Clear the hash (contains sensitive token data) 189 + url.hash = ''; 175 190 176 191 // Update URL without reload 177 192 window.history.replaceState({}, document.title, url.pathname + url.search); ··· 186 201 if (typeof window === 'undefined') return false; 187 202 188 203 const url = new URL(window.location.href); 189 - return url.searchParams.has('token') || url.searchParams.has('error'); 204 + const fragmentParams = new URLSearchParams(url.hash.slice(1)); 205 + 206 + // Token comes via fragment, error via query param 207 + return fragmentParams.has('token') || url.searchParams.has('error'); 190 208 } 191 209 192 210 /**
+13 -11
ts/src/storage.ts
··· 118 118 * 119 119 * @param length - Nonce length (default: 32) 120 120 * @returns Random string 121 + * @throws Error if no secure random source is available 121 122 */ 122 123 export function generateNonce(length = 32): string { 123 124 const chars = 124 125 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 125 126 let result = ''; 126 127 127 - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { 128 - const values = new Uint32Array(length); 129 - crypto.getRandomValues(values); 130 - for (let i = 0; i < length; i++) { 131 - result += chars[values[i] % chars.length]; 132 - } 133 - } else { 134 - // Fallback for older environments 135 - for (let i = 0; i < length; i++) { 136 - result += chars[Math.floor(Math.random() * chars.length)]; 137 - } 128 + // Require crypto.getRandomValues - no insecure fallback 129 + if (typeof crypto === 'undefined' || !crypto.getRandomValues) { 130 + throw new Error( 131 + 'Secure random number generator not available. ' + 132 + 'crypto.getRandomValues is required for CSRF protection.' 133 + ); 134 + } 135 + 136 + const values = new Uint32Array(length); 137 + crypto.getRandomValues(values); 138 + for (let i = 0; i < length; i++) { 139 + result += chars[values[i] % chars.length]; 138 140 } 139 141 140 142 return result;
+148
ts/src/validation.ts
··· 1 + /** 2 + * Input validation utilities 3 + */ 4 + 5 + import type { OAuthState } from './types'; 6 + 7 + /** 8 + * Maximum allowed size for OAuth state string to prevent DoS. 9 + */ 10 + const MAX_STATE_SIZE = 4096; 11 + 12 + /** 13 + * Maximum nesting depth for state object. 14 + */ 15 + const MAX_NESTING_DEPTH = 3; 16 + 17 + /** 18 + * Validate a DID (Decentralized Identifier) format. 19 + * 20 + * @param did - The DID string to validate 21 + * @returns True if valid DID format 22 + */ 23 + export function isValidDid(did: unknown): did is string { 24 + if (typeof did !== 'string') return false; 25 + 26 + // AT Protocol DIDs start with "did:plc:" or "did:web:" 27 + return /^did:(plc|web):[a-zA-Z0-9._%-]+$/.test(did); 28 + } 29 + 30 + /** 31 + * Validate an AT Protocol handle format. 32 + * 33 + * @param handle - The handle to validate 34 + * @returns True if valid handle format 35 + */ 36 + export function isValidHandle(handle: unknown): handle is string { 37 + if (typeof handle !== 'string') return false; 38 + 39 + // Handles are domain-like strings (e.g., "user.bsky.social") 40 + // Must be lowercase alphanumeric with dots, 3-253 chars 41 + if (handle.length < 3 || handle.length > 253) return false; 42 + 43 + return /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(handle) && 44 + !handle.includes('..'); 45 + } 46 + 47 + /** 48 + * Check object nesting depth. 49 + */ 50 + function getDepth(obj: unknown, current = 0): number { 51 + if (current > MAX_NESTING_DEPTH) return current; 52 + if (typeof obj !== 'object' || obj === null) return current; 53 + 54 + let maxDepth = current; 55 + for (const value of Object.values(obj)) { 56 + const depth = getDepth(value, current + 1); 57 + if (depth > maxDepth) maxDepth = depth; 58 + } 59 + return maxDepth; 60 + } 61 + 62 + /** 63 + * Validate and parse OAuth state from a string. 64 + * Guards against malformed JSON, oversized payloads, and deeply nested objects. 65 + * 66 + * @param stateString - The state string from URL parameter 67 + * @returns Validated OAuthState or null if invalid 68 + */ 69 + export function parseOAuthState(stateString: unknown): OAuthState | null { 70 + // Must be a string 71 + if (typeof stateString !== 'string') { 72 + return null; 73 + } 74 + 75 + // Check size limit to prevent DoS 76 + if (stateString.length > MAX_STATE_SIZE) { 77 + console.warn('OAuth state exceeds maximum size'); 78 + return null; 79 + } 80 + 81 + // Parse JSON 82 + let parsed: unknown; 83 + try { 84 + parsed = JSON.parse(stateString); 85 + } catch { 86 + console.warn('OAuth state is not valid JSON'); 87 + return null; 88 + } 89 + 90 + // Must be an object 91 + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { 92 + console.warn('OAuth state must be an object'); 93 + return null; 94 + } 95 + 96 + // Check nesting depth 97 + if (getDepth(parsed) > MAX_NESTING_DEPTH) { 98 + console.warn('OAuth state exceeds maximum nesting depth'); 99 + return null; 100 + } 101 + 102 + // Validate known fields 103 + const state = parsed as Record<string, unknown>; 104 + 105 + // returnTo must be a string if present 106 + if ('returnTo' in state && typeof state.returnTo !== 'string') { 107 + console.warn('OAuth state returnTo must be a string'); 108 + return null; 109 + } 110 + 111 + // nonce must be a string if present 112 + if ('nonce' in state && typeof state.nonce !== 'string') { 113 + console.warn('OAuth state nonce must be a string'); 114 + return null; 115 + } 116 + 117 + // Validate returnTo is a safe URL (no javascript: etc.) 118 + if (typeof state.returnTo === 'string') { 119 + try { 120 + const url = new URL(state.returnTo, window?.location?.origin || 'https://example.com'); 121 + if (url.protocol !== 'http:' && url.protocol !== 'https:') { 122 + console.warn('OAuth state returnTo has invalid protocol'); 123 + return null; 124 + } 125 + } catch { 126 + // Relative URLs are OK, but must not contain dangerous schemes 127 + if (/^(javascript|data|vbscript):/i.test(state.returnTo)) { 128 + console.warn('OAuth state returnTo has dangerous scheme'); 129 + return null; 130 + } 131 + } 132 + } 133 + 134 + return state as OAuthState; 135 + } 136 + 137 + /** 138 + * Validate an app ID. 139 + * 140 + * @param appId - The app ID to validate 141 + * @returns True if valid 142 + */ 143 + export function isValidAppId(appId: unknown): appId is string { 144 + if (typeof appId !== 'string') return false; 145 + 146 + // App IDs should be alphanumeric with hyphens/underscores, 1-64 chars 147 + return /^[a-zA-Z0-9_-]{1,64}$/.test(appId); 148 + }