gammanexus/server/middleware.ts

101 lines
3.0 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=()");
res.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
);
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;");
}