File size: 7,002 Bytes
391c43e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
/**
 * 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;
}