gammanexus/server/security.ts

251 lines
9.2 KiB
TypeScript

/**
* Security Middleware & Utilities for GammaDesk
*
* Provides:
* 1. Input sanitization (XSS prevention)
* 2. Rate limiting (general + auth-specific)
* 3. Security headers (CSP, HSTS, etc.)
* 4. CORS configuration
* 5. Request ID tracking
* 6. Password/email validation
*/
import type { Request, Response, NextFunction } from "express";
/* ──────────────────────────────────────────────────────────────
* 1. INPUT SANITIZATION (XSS Prevention)
* ────────────────────────────────────────────────────────────── */
/** Sanitize a string by removing HTML tags and encoding special chars */
export function sanitize(input: string): string {
if (typeof input !== "string") return "";
return input
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;");
}
/** Sanitize all string values in a body object */
export function sanitizeBody(body: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body)) {
if (typeof value === "string") {
sanitized[key] = sanitize(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
/* ──────────────────────────────────────────────────────────────
* 2. RATE LIMITING
* ────────────────────────────────────────────────────────────── */
interface RateLimitEntry {
count: number;
resetTime: number;
}
export function rateLimit(
windowMs: number,
maxRequests: number,
message: string = "Too many requests. Please try again later.",
) {
const store = new Map<string, RateLimitEntry>();
// Cleanup expired entries every 60 seconds
const cleanupInterval = setInterval(() => {
const now = Date.now();
store.forEach((entry, key) => {
if (now >= entry.resetTime) store.delete(key);
});
}, 60_000);
// Allow process to exit
if (cleanupInterval.unref) cleanupInterval.unref();
return (req: Request, res: Response, next: NextFunction) => {
const ip = req.ip || req.socket.remoteAddress || "unknown";
const now = Date.now();
const entry = store.get(ip);
if (!entry || now >= entry.resetTime) {
store.set(ip, { count: 1, resetTime: now + windowMs });
res.set("X-RateLimit-Limit", String(maxRequests));
res.set("X-RateLimit-Remaining", String(maxRequests - 1));
res.set("X-RateLimit-Reset", String(Math.ceil((now + windowMs) / 1000)));
return next();
}
entry.count++;
res.set("X-RateLimit-Limit", String(maxRequests));
res.set("X-RateLimit-Remaining", String(Math.max(0, maxRequests - entry.count)));
res.set("X-RateLimit-Reset", String(Math.ceil(entry.resetTime / 1000)));
if (entry.count > maxRequests) {
res.set("Retry-After", String(Math.ceil((entry.resetTime - now) / 1000)));
return res.status(429).json({
error: message,
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
});
}
next();
};
}
/** General rate limit: 100 requests per 15 minutes per IP */
export const generalRateLimit = rateLimit(15 * 60 * 1000, 100);
/** Auth rate limit: 10 requests per 15 minutes per IP */
export const authRateLimit = rateLimit(
15 * 60 * 1000,
10,
"Too many authentication attempts. Please try again later.",
);
/* ──────────────────────────────────────────────────────────────
* 3. SECURITY HEADERS
* ────────────────────────────────────────────────────────────── */
export function securityHeaders(
_req: Request,
res: Response,
next: NextFunction,
) {
// Prevent MIME sniffing
res.set("X-Content-Type-Options", "nosniff");
// Clickjacking protection
res.set("X-Frame-Options", "DENY");
// XSS filter (legacy browsers)
res.set("X-XSS-Protection", "1; mode=block");
// Referrer policy
res.set("Referrer-Policy", "strict-origin-when-cross-origin");
// Permissions policy
res.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()",
);
// Content Security Policy
res.set(
"Content-Security-Policy",
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'", // Required for Tailwind JIT
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
);
// HSTS (production only)
if (process.env.NODE_ENV === "production") {
res.set(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload",
);
}
next();
}
/* ──────────────────────────────────────────────────────────────
* 4. CORS
* ────────────────────────────────────────────────────────────── */
export function corsMiddleware(
_req: Request,
res: Response,
next: NextFunction,
) {
const origin = _req.headers.origin as string;
const allowedOrigins = (process.env.CORS_ORIGINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (origin && allowedOrigins.includes(origin)) {
res.set("Access-Control-Allow-Origin", origin);
}
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID");
res.set("Access-Control-Allow-Credentials", "true");
res.set("Access-Control-Max-Age", "86400");
// Handle preflight
if (_req.method === "OPTIONS") {
return res.status(204).end();
}
next();
}
/* ──────────────────────────────────────────────────────────────
* 5. REQUEST ID
* ────────────────────────────────────────────────────────────── */
export function requestIdMiddleware(
_req: Request,
res: Response,
next: NextFunction,
) {
const id =
(_req.headers["x-request-id"] as string) ||
`req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
res.set("X-Request-ID", id);
next();
}
/* ──────────────────────────────────────────────────────────────
* 6. BODY SIZE LIMIT
* ────────────────────────────────────────────────────────────── */
export function bodyLimit(maxBytes: number = 1_048_576) {
// 1MB default
return (_req: Request, res: Response, next: NextFunction) => {
const cl = parseInt(_req.headers["content-length"] || "0", 10);
if (cl > maxBytes) {
return res.status(413).json({ error: "Request body too large" });
}
next();
};
}
/* ──────────────────────────────────────────────────────────────
* 7. VALIDATION HELPERS
* ────────────────────────────────────────────────────────────── */
/** Validate email format (RFC 5322 simplified) */
export function isValidEmail(email: string): boolean {
return /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
email,
);
}
/** Validate password strength */
export function validatePassword(password: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (password.length < 8) errors.push("Password must be at least 8 characters");
if (password.length > 128) errors.push("Password must be at most 128 characters");
if (!/[A-Z]/.test(password)) errors.push("Password must contain an uppercase letter");
if (!/[a-z]/.test(password)) errors.push("Password must contain a lowercase letter");
if (!/[0-9]/.test(password)) errors.push("Password must contain a number");
if (!/[!@#$%^&*()_+\-=[\]{};:'",.<>?\/\\|`~]/.test(password))
errors.push("Password must contain a special character");
return { valid: errors.length === 0, errors };
}