251 lines
9.2 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/** 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 };
|
|
}
|