127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
// Security middleware for GammaDesk
|
|
import type { Request, Response, NextFunction } from "express";
|
|
|
|
// Rate limiting store
|
|
interface RateLimitEntry {
|
|
count: number;
|
|
resetTime: number;
|
|
}
|
|
|
|
const rateLimitStore: Map<string, RateLimitEntry> = new Map();
|
|
|
|
// Whitelist for local development (home subnet)
|
|
const WHITELISTED_SUBNETS = [
|
|
"192.168.", // Home network
|
|
"127.", // Localhost
|
|
"10.", // Private network
|
|
"fc00:", // IPv6 local
|
|
"::1", // IPv6 localhost
|
|
];
|
|
|
|
function isWhitelisted(ip: string): boolean {
|
|
return WHITELISTED_SUBNETS.some(prefix => ip.startsWith(prefix));
|
|
}
|
|
|
|
// Cleanup old rate limit entries every 60 seconds
|
|
const cleanupInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
const expiredKeys: string[] = [];
|
|
rateLimitStore.forEach((entry: RateLimitEntry, key: string) => {
|
|
if (entry.resetTime < now) {
|
|
expiredKeys.push(key);
|
|
}
|
|
});
|
|
expiredKeys.forEach((key: string) => rateLimitStore.delete(key));
|
|
}, 60000);
|
|
if (cleanupInterval.unref) cleanupInterval.unref();
|
|
|
|
export function rateLimit({
|
|
windowMs = 900000, // 15 minutes
|
|
max = 500, // Relaxed from 100 for development
|
|
}: { windowMs?: number; max?: number } = {}) {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
|
|
|
// Skip rate limiting for whitelisted IPs (local development)
|
|
if (isWhitelisted(ip)) {
|
|
return next();
|
|
}
|
|
|
|
const now = Date.now();
|
|
let entry = rateLimitStore.get(ip);
|
|
|
|
if (!entry || now > entry.resetTime) {
|
|
entry = { count: 1, resetTime: now + windowMs };
|
|
rateLimitStore.set(ip, entry);
|
|
} else {
|
|
entry.count++;
|
|
}
|
|
|
|
res.set("X-RateLimit-Limit", String(max));
|
|
res.set("X-RateLimit-Remaining", String(Math.max(0, max - entry.count)));
|
|
res.set("X-RateLimit-Reset", String(Math.ceil(entry.resetTime / 1000)));
|
|
|
|
if (entry.count > max) {
|
|
return res.status(429).json({
|
|
error: "Too many requests. Please try again later.",
|
|
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
|
|
});
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
export function securityHeaders(_req: Request, res: Response, next: NextFunction) {
|
|
res.set("X-Content-Type-Options", "nosniff");
|
|
res.set("X-Frame-Options", "DENY");
|
|
res.set("X-XSS-Protection", "1; mode=block");
|
|
res.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
res.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
// Development CSP: Relaxed for Vite HMR + Google Fonts
|
|
const devCsp = [
|
|
"default-src 'self'",
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' http: https: data:",
|
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
"img-src 'self' data: blob: https:",
|
|
"font-src 'self' data: https://fonts.gstatic.com",
|
|
"connect-src 'self' ws: wss: http: https:",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join("; ");
|
|
|
|
// Production CSP: Strict
|
|
const prodCsp = [
|
|
"default-src 'self'",
|
|
"script-src 'self' 'unsafe-inline'",
|
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
"img-src 'self' data: blob:",
|
|
"font-src 'self' data: https://fonts.gstatic.com",
|
|
"connect-src 'self'",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join("; ");
|
|
|
|
res.set(
|
|
"Content-Security-Policy",
|
|
process.env.NODE_ENV === "production" ? prodCsp : devCsp,
|
|
);
|
|
|
|
if (process.env.NODE_ENV === "production") {
|
|
res.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
export function sanitizeInput(input: string): string {
|
|
return input
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|