Leon4gr45/builder
0
1/**2 * Analytics Security Utilities3 *4 * Token generation and validation for secure analytics tracking.5 * Prevents unauthorized data injection and replay attacks.6 */7 8import crypto from 'crypto';9 10const TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days (for static sites)11 12/**13 * Generate a signed analytics tracking token14 * Token format (base64-encoded): deploymentId:timestamp:nonce:signature15 *16 * @param deploymentId - Deployment identifier17 * @returns Base64-encoded signed token18 */19export function generateAnalyticsToken(deploymentId: string): string {20 const secret = getAnalyticsSecret();21 const timestamp = Date.now().toString();22 const nonce = crypto.randomBytes(8).toString('hex');23 const payload = `${deploymentId}:${timestamp}:${nonce}`;24 25 const signature = crypto26 .createHmac('sha256', secret)27 .update(payload)28 .digest('hex');29 30 const token = `${payload}:${signature}`;31 return Buffer.from(token).toString('base64');32}33 34/**35 * Verify an analytics tracking token36 *37 * @param token - Base64-encoded token from client38 * @param expectedDeploymentId - Expected deployment ID39 * @returns true if valid, false otherwise40 */41export function verifyAnalyticsToken(42 token: string,43 expectedDeploymentId: string44): boolean {45 try {46 const secret = getAnalyticsSecret();47 48 // Decode token49 const decoded = Buffer.from(token, 'base64').toString('utf-8');50 const parts = decoded.split(':');51 52 if (parts.length !== 4) {53 return false; // Invalid format54 }55 56 const [deploymentId, timestamp, nonce, signature] = parts;57 58 // Verify deployment ID matches59 if (deploymentId !== expectedDeploymentId) {60 return false;61 }62 63 // Verify timestamp is recent (prevent replay attacks)64 const tokenAge = Date.now() - parseInt(timestamp, 10);65 if (tokenAge > TOKEN_EXPIRY_MS || tokenAge < 0) {66 return false; // Token expired or from future67 }68 69 // Verify signature70 const payload = `${deploymentId}:${timestamp}:${nonce}`;71 const expectedSignature = crypto72 .createHmac('sha256', secret)73 .update(payload)74 .digest('hex');75 76 // Constant-time comparison to prevent timing attacks77 return crypto.timingSafeEqual(78 Buffer.from(signature),79 Buffer.from(expectedSignature)80 );81 } catch (error) {82 // Invalid token format or other error83 return false;84 }85}86 87/**88 * Get analytics secret from environment89 * Generates a random secret if not configured (dev only)90 */91function getAnalyticsSecret(): string {92 const secret = process.env.ANALYTICS_SECRET;93 94 if (!secret) {95 // In development, use a stable secret to persist across restarts96 if (process.env.NODE_ENV === 'development') {97 console.warn(98 '[Analytics Security] ANALYTICS_SECRET not set, using development secret (not for production)'99 );100 return 'dev-analytics-secret-do-not-use-in-production-change-this-value';101 }102 103 throw new Error(104 'ANALYTICS_SECRET environment variable must be set in production'105 );106 }107 108 return secret;109}110 111/**112 * Validate request origin against allowed domains113 *114 * @param request - Incoming request115 * @param allowedOrigins - Array of allowed origin URLs116 * @returns true if origin is allowed, false otherwise117 */118export function validateOrigin(119 request: Request,120 allowedOrigins: string[]121): boolean {122 const origin = request.headers.get('origin') || '';123 const referer = request.headers.get('referer') || '';124 125 return allowedOrigins.some((allowed) => {126 if (allowed.includes('*')) {127 const suffix = allowed.replace(/^https?:\/\/\*/, '');128 const matchesOrigin = origin.endsWith(suffix) && /^https?:\/\//.test(origin);129 const matchesReferer = referer.endsWith(suffix) || referer.includes(suffix + '/');130 return matchesOrigin || matchesReferer;131 }132 return origin.startsWith(allowed) || referer.startsWith(allowed);133 });134}135 136/**137 * Get allowed origins for a deployment138 *139 * @param deploymentId - Deployment identifier140 * @param customDomain - Optional custom domain141 * @returns Array of allowed origin URLs142 */143export function getAllowedOrigins(144 deploymentId: string,145 customDomain?: string | null146): string[] {147 const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';148 149 const origins: string[] = [150 `${appUrl}/deployments/${deploymentId}`, // Published deployment path151 appUrl // Base app URL (for development/testing)152 ];153 154 // Add localhost variations for development155 if (appUrl.includes('localhost')) {156 origins.push('http://localhost:3000');157 origins.push('http://127.0.0.1:3000');158 }159 160 // Add custom domain if configured161 if (customDomain) {162 origins.push(`https://${customDomain}`);163 origins.push(`http://${customDomain}`);164 }165 166 // Allow subdomain-routed deployments (e.g., my-site.oswstudio.com)167 const appHost = appUrl.replace(/^https?:\/\//, '').split(':')[0];168 if (appHost && !appHost.includes('localhost')) {169 origins.push(`https://*.${appHost}`);170 origins.push(`http://*.${appHost}`);171 }172 173 return origins;174}175 176/**177 * Generate token hash for storage (to verify tokens without storing plaintext)178 *179 * @param token - Token to hash180 * @returns SHA-256 hash of token181 */182export function hashToken(token: string): string {183 return crypto184 .createHash('sha256')185 .update(token)186 .digest('hex');187}188 189/**190 * Check if user agent appears to be a bot191 *192 * @param userAgent - User agent string193 * @returns true if likely a bot, false otherwise194 */195export function isLikelyBot(userAgent: string): boolean {196 if (!userAgent) return true; // No user agent = suspicious197 198 const lowerUA = userAgent.toLowerCase();199 200 // Common bot indicators201 const botPatterns = [202 'bot',203 'crawl',204 'spider',205 'scrape',206 'curl',207 'wget',208 'python',209 'java',210 'http',211 'go-http-client',212 'axios',213 'fetch',214 'node-fetch',215 'requests', // Python216 'urllib',217 'headless',218 'phantom',219 'selenium',220 'puppeteer',221 'playwright'222 ];223 224 return botPatterns.some((pattern) => lowerUA.includes(pattern));225}226 227/**228 * Detect suspicious request patterns229 *230 * @param data - Analytics data to validate231 * @returns true if suspicious, false otherwise232 */233export function isSuspiciousRequest(data: {234 pagePath?: string;235 referrer?: string;236 userAgent?: string;237}): boolean {238 // Check for obviously fake/malicious data239 if (data.pagePath && data.pagePath.length > 500) {240 return true; // Unreasonably long path241 }242 243 if (data.referrer && data.referrer.length > 500) {244 return true; // Unreasonably long referrer245 }246 247 if (data.userAgent && data.userAgent.length > 500) {248 return true; // Unreasonably long user agent249 }250 251 // Check for SQL injection attempts252 const sqlPatterns = /(union|select|insert|update|delete|drop|create|alter)/i;253 if (254 (data.pagePath && sqlPatterns.test(data.pagePath)) ||255 (data.referrer && sqlPatterns.test(data.referrer))256 ) {257 return true;258 }259 260 return false;261}262 