You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

184 lines
7.9 KiB

/**
* Server-side hooks for gitrepublic-web
* Initializes security middleware
*/
import type { Handle } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { rateLimiter } from './lib/services/security/rate-limiter.js';
import { auditLogger } from './lib/services/security/audit-logger.js';
import logger from './lib/services/logger.js';
if (typeof process !== 'undefined') {
// Handle unhandled promise rejections to prevent crashes from relay errors
process.on('unhandledRejection', (reason, promise) => {
// Log the error but don't crash - relay errors (like payment requirements) are expected
if (reason instanceof Error && reason.message.includes('restricted')) {
logger.debug({ error: reason.message }, 'Relay access restricted (expected for paid relays)');
} else {
logger.warn({ error: reason, promise }, 'Unhandled promise rejection (non-fatal)');
}
});
// Cleanup on server shutdown
const cleanup = (signal: string) => {
logger.info({ signal }, 'Received shutdown signal, cleaning up...');
// Give a moment for cleanup, then exit
setTimeout(() => {
process.exit(0);
}, 1000);
};
process.on('SIGTERM', () => cleanup('SIGTERM'));
process.on('SIGINT', () => {
// SIGINT (Ctrl-C) - exit immediately after cleanup
cleanup('SIGINT');
// Force exit after 2 seconds if cleanup takes too long
setTimeout(() => {
logger.warn('Forcing exit after SIGINT');
process.exit(0);
}, 2000);
});
}
export const handle: Handle = async ({ event, resolve }) => {
// Get client IP, with fallback for dev/internal requests
let clientIp: string;
try {
clientIp = event.getClientAddress();
} catch {
// Fallback for internal Vite dev server requests or when client address can't be determined
clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
event.request.headers.get('x-real-ip') ||
'127.0.0.1';
}
const url = event.url;
// Skip rate limiting for Vite internal requests in dev mode
const isViteInternalRequest = url.pathname.startsWith('/@') ||
url.pathname.startsWith('/src/') ||
url.pathname.startsWith('/node_modules/') ||
url.pathname.includes('react-refresh') ||
url.pathname.includes('vite-plugin-pwa');
// Determine rate limit type based on path
let rateLimitType = 'api';
if (url.pathname.startsWith('/api/git/')) {
rateLimitType = 'git';
} else if (url.pathname.startsWith('/api/repos/') && url.pathname.includes('/files')) {
rateLimitType = 'file';
} else if (url.pathname.startsWith('/api/search')) {
rateLimitType = 'search';
}
// Extract user pubkey for rate limiting (authenticated users get higher limits)
const userPubkey = event.request.headers.get('X-User-Pubkey') ||
event.request.headers.get('x-user-pubkey') ||
url.searchParams.get('userPubkey') ||
null;
// Use user pubkey as identifier if authenticated, otherwise use IP
// This allows authenticated users to have per-user limits (can't bypass by changing IP)
// and anonymous users are limited by IP (prevents abuse)
const rateLimitIdentifier = userPubkey ? `user:${userPubkey}` : `ip:${clientIp}`;
const isAnonymous = !userPubkey;
// Skip rate limiting for read-only GET requests to repo endpoints (page loads)
// These are necessary for normal page functionality and are not write operations
const isReadOnlyRepoRequest = event.request.method === 'GET' &&
url.pathname.startsWith('/api/repos/') &&
!url.pathname.includes('/file') && // File operations are rate limited separately
!url.pathname.includes('/delete') &&
!url.pathname.includes('/transfer') &&
(url.pathname.endsWith('/fork') || // GET /fork is read-only
url.pathname.endsWith('/verify') || // GET /verify is read-only
url.pathname.endsWith('/readme') || // GET /readme is read-only
url.pathname.endsWith('/branches') || // GET /branches is read-only
url.pathname.endsWith('/tags') || // GET /tags is read-only
url.pathname.endsWith('/tree') || // GET /tree is read-only
url.pathname.endsWith('/commits') || // GET /commits is read-only
url.pathname.endsWith('/access') || // GET /access is read-only
url.pathname.endsWith('/maintainers')); // GET /maintainers is read-only
// Skip rate limiting for read-only GET requests to user endpoints (profile pages)
const isReadOnlyUserRequest = event.request.method === 'GET' &&
url.pathname.startsWith('/api/users/') &&
(url.pathname.endsWith('/repos')); // GET /users/[npub]/repos is read-only
// Check rate limit (skip for Vite internal requests and read-only requests)
const rateLimitResult = (isViteInternalRequest || isReadOnlyRepoRequest || isReadOnlyUserRequest)
? { allowed: true, resetAt: Date.now() }
: rateLimiter.check(rateLimitType, rateLimitIdentifier, isAnonymous);
if (!rateLimitResult.allowed) {
auditLogger.log({
ip: clientIp,
action: `rate_limit.${rateLimitType}`,
result: 'denied',
metadata: {
path: url.pathname,
identifier: rateLimitIdentifier,
isAnonymous,
userPubkey: userPubkey || null
}
});
return error(429, `Rate limit exceeded. Try again after ${new Date(rateLimitResult.resetAt).toISOString()}`);
}
// Audit log the request (basic info)
// Detailed audit logging happens in individual endpoints
const startTime = Date.now();
try {
const response = await resolve(event);
// Add security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// Add CSP header (Content Security Policy)
// Allow frames from common git hosting platforms for web URL previews
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed for Svelte
"script-src-elem 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self' wss: https:",
"frame-src 'self' https:", // Allow iframes from same origin and HTTPS URLs (for web URL previews)
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
].join('; ');
response.headers.set('Content-Security-Policy', csp);
// Log successful request if it's a security-sensitive operation
if (url.pathname.startsWith('/api/')) {
const duration = Date.now() - startTime;
auditLogger.log({
ip: clientIp,
action: `request.${event.request.method.toLowerCase()}`,
resource: url.pathname,
result: 'success',
metadata: { status: response.status, duration }
});
}
return response;
} catch (err) {
// Log failed request
auditLogger.log({
ip: clientIp,
action: `request.${event.request.method.toLowerCase()}`,
resource: url.pathname,
result: 'failure',
error: err instanceof Error ? err.message : String(err)
});
throw err;
}
};