| |
| |
| |
| |
| |
| |
|
|
| import crypto from 'crypto'; |
|
|
| const TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function generateAnalyticsToken(deploymentId: string): string { |
| const secret = getAnalyticsSecret(); |
| const timestamp = Date.now().toString(); |
| const nonce = crypto.randomBytes(8).toString('hex'); |
| const payload = `${deploymentId}:${timestamp}:${nonce}`; |
|
|
| const signature = crypto |
| .createHmac('sha256', secret) |
| .update(payload) |
| .digest('hex'); |
|
|
| const token = `${payload}:${signature}`; |
| return Buffer.from(token).toString('base64'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function verifyAnalyticsToken( |
| token: string, |
| expectedDeploymentId: string |
| ): boolean { |
| try { |
| const secret = getAnalyticsSecret(); |
|
|
| |
| const decoded = Buffer.from(token, 'base64').toString('utf-8'); |
| const parts = decoded.split(':'); |
|
|
| if (parts.length !== 4) { |
| return false; |
| } |
|
|
| const [deploymentId, timestamp, nonce, signature] = parts; |
|
|
| |
| if (deploymentId !== expectedDeploymentId) { |
| return false; |
| } |
|
|
| |
| const tokenAge = Date.now() - parseInt(timestamp, 10); |
| if (tokenAge > TOKEN_EXPIRY_MS || tokenAge < 0) { |
| return false; |
| } |
|
|
| |
| const payload = `${deploymentId}:${timestamp}:${nonce}`; |
| const expectedSignature = crypto |
| .createHmac('sha256', secret) |
| .update(payload) |
| .digest('hex'); |
|
|
| |
| return crypto.timingSafeEqual( |
| Buffer.from(signature), |
| Buffer.from(expectedSignature) |
| ); |
| } catch (error) { |
| |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| function getAnalyticsSecret(): string { |
| const secret = process.env.ANALYTICS_SECRET; |
|
|
| if (!secret) { |
| |
| if (process.env.NODE_ENV === 'development') { |
| console.warn( |
| '[Analytics Security] ANALYTICS_SECRET not set, using development secret (not for production)' |
| ); |
| return 'dev-analytics-secret-do-not-use-in-production-change-this-value'; |
| } |
|
|
| throw new Error( |
| 'ANALYTICS_SECRET environment variable must be set in production' |
| ); |
| } |
|
|
| return secret; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function validateOrigin( |
| request: Request, |
| allowedOrigins: string[] |
| ): boolean { |
| const origin = request.headers.get('origin') || ''; |
| const referer = request.headers.get('referer') || ''; |
|
|
| return allowedOrigins.some((allowed) => { |
| if (allowed.includes('*')) { |
| const suffix = allowed.replace(/^https?:\/\/\*/, ''); |
| const matchesOrigin = origin.endsWith(suffix) && /^https?:\/\//.test(origin); |
| const matchesReferer = referer.endsWith(suffix) || referer.includes(suffix + '/'); |
| return matchesOrigin || matchesReferer; |
| } |
| return origin.startsWith(allowed) || referer.startsWith(allowed); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function getAllowedOrigins( |
| deploymentId: string, |
| customDomain?: string | null |
| ): string[] { |
| const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; |
|
|
| const origins: string[] = [ |
| `${appUrl}/deployments/${deploymentId}`, |
| appUrl |
| ]; |
|
|
| |
| if (appUrl.includes('localhost')) { |
| origins.push('http://localhost:3000'); |
| origins.push('http://127.0.0.1:3000'); |
| } |
|
|
| |
| if (customDomain) { |
| origins.push(`https://${customDomain}`); |
| origins.push(`http://${customDomain}`); |
| } |
|
|
| |
| const appHost = appUrl.replace(/^https?:\/\//, '').split(':')[0]; |
| if (appHost && !appHost.includes('localhost')) { |
| origins.push(`https://*.${appHost}`); |
| origins.push(`http://*.${appHost}`); |
| } |
|
|
| return origins; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function hashToken(token: string): string { |
| return crypto |
| .createHash('sha256') |
| .update(token) |
| .digest('hex'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function isLikelyBot(userAgent: string): boolean { |
| if (!userAgent) return true; |
|
|
| const lowerUA = userAgent.toLowerCase(); |
|
|
| |
| const botPatterns = [ |
| 'bot', |
| 'crawl', |
| 'spider', |
| 'scrape', |
| 'curl', |
| 'wget', |
| 'python', |
| 'java', |
| 'http', |
| 'go-http-client', |
| 'axios', |
| 'fetch', |
| 'node-fetch', |
| 'requests', |
| 'urllib', |
| 'headless', |
| 'phantom', |
| 'selenium', |
| 'puppeteer', |
| 'playwright' |
| ]; |
|
|
| return botPatterns.some((pattern) => lowerUA.includes(pattern)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function isSuspiciousRequest(data: { |
| pagePath?: string; |
| referrer?: string; |
| userAgent?: string; |
| }): boolean { |
| |
| if (data.pagePath && data.pagePath.length > 500) { |
| return true; |
| } |
|
|
| if (data.referrer && data.referrer.length > 500) { |
| return true; |
| } |
|
|
| if (data.userAgent && data.userAgent.length > 500) { |
| return true; |
| } |
|
|
| |
| const sqlPatterns = /(union|select|insert|update|delete|drop|create|alter)/i; |
| if ( |
| (data.pagePath && sqlPatterns.test(data.pagePath)) || |
| (data.referrer && sqlPatterns.test(data.referrer)) |
| ) { |
| return true; |
| } |
|
|
| return false; |
| } |
|
|