builder / lib /analytics /security.ts
Leon4gr45's picture
Upload folder using huggingface_hub (part 2)
391c43e verified
Raw
History Blame Contribute Delete
7 kB
/**
* Analytics Security Utilities
*
* Token generation and validation for secure analytics tracking.
* Prevents unauthorized data injection and replay attacks.
*/
import crypto from 'crypto';
const TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days (for static sites)
/**
* Generate a signed analytics tracking token
* Token format (base64-encoded): deploymentId:timestamp:nonce:signature
*
* @param deploymentId - Deployment identifier
* @returns Base64-encoded signed token
*/
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');
}
/**
* Verify an analytics tracking token
*
* @param token - Base64-encoded token from client
* @param expectedDeploymentId - Expected deployment ID
* @returns true if valid, false otherwise
*/
export function verifyAnalyticsToken(
token: string,
expectedDeploymentId: string
): boolean {
try {
const secret = getAnalyticsSecret();
// Decode token
const decoded = Buffer.from(token, 'base64').toString('utf-8');
const parts = decoded.split(':');
if (parts.length !== 4) {
return false; // Invalid format
}
const [deploymentId, timestamp, nonce, signature] = parts;
// Verify deployment ID matches
if (deploymentId !== expectedDeploymentId) {
return false;
}
// Verify timestamp is recent (prevent replay attacks)
const tokenAge = Date.now() - parseInt(timestamp, 10);
if (tokenAge > TOKEN_EXPIRY_MS || tokenAge < 0) {
return false; // Token expired or from future
}
// Verify signature
const payload = `${deploymentId}:${timestamp}:${nonce}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
} catch (error) {
// Invalid token format or other error
return false;
}
}
/**
* Get analytics secret from environment
* Generates a random secret if not configured (dev only)
*/
function getAnalyticsSecret(): string {
const secret = process.env.ANALYTICS_SECRET;
if (!secret) {
// In development, use a stable secret to persist across restarts
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;
}
/**
* Validate request origin against allowed domains
*
* @param request - Incoming request
* @param allowedOrigins - Array of allowed origin URLs
* @returns true if origin is allowed, false otherwise
*/
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);
});
}
/**
* Get allowed origins for a deployment
*
* @param deploymentId - Deployment identifier
* @param customDomain - Optional custom domain
* @returns Array of allowed origin URLs
*/
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}`, // Published deployment path
appUrl // Base app URL (for development/testing)
];
// Add localhost variations for development
if (appUrl.includes('localhost')) {
origins.push('http://localhost:3000');
origins.push('http://127.0.0.1:3000');
}
// Add custom domain if configured
if (customDomain) {
origins.push(`https://${customDomain}`);
origins.push(`http://${customDomain}`);
}
// Allow subdomain-routed deployments (e.g., my-site.oswstudio.com)
const appHost = appUrl.replace(/^https?:\/\//, '').split(':')[0];
if (appHost && !appHost.includes('localhost')) {
origins.push(`https://*.${appHost}`);
origins.push(`http://*.${appHost}`);
}
return origins;
}
/**
* Generate token hash for storage (to verify tokens without storing plaintext)
*
* @param token - Token to hash
* @returns SHA-256 hash of token
*/
export function hashToken(token: string): string {
return crypto
.createHash('sha256')
.update(token)
.digest('hex');
}
/**
* Check if user agent appears to be a bot
*
* @param userAgent - User agent string
* @returns true if likely a bot, false otherwise
*/
export function isLikelyBot(userAgent: string): boolean {
if (!userAgent) return true; // No user agent = suspicious
const lowerUA = userAgent.toLowerCase();
// Common bot indicators
const botPatterns = [
'bot',
'crawl',
'spider',
'scrape',
'curl',
'wget',
'python',
'java',
'http',
'go-http-client',
'axios',
'fetch',
'node-fetch',
'requests', // Python
'urllib',
'headless',
'phantom',
'selenium',
'puppeteer',
'playwright'
];
return botPatterns.some((pattern) => lowerUA.includes(pattern));
}
/**
* Detect suspicious request patterns
*
* @param data - Analytics data to validate
* @returns true if suspicious, false otherwise
*/
export function isSuspiciousRequest(data: {
pagePath?: string;
referrer?: string;
userAgent?: string;
}): boolean {
// Check for obviously fake/malicious data
if (data.pagePath && data.pagePath.length > 500) {
return true; // Unreasonably long path
}
if (data.referrer && data.referrer.length > 500) {
return true; // Unreasonably long referrer
}
if (data.userAgent && data.userAgent.length > 500) {
return true; // Unreasonably long user agent
}
// Check for SQL injection attempts
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;
}