153 lines
4.8 KiB
TypeScript
153 lines
4.8 KiB
TypeScript
import { Context, Next } from "@oak/oak";
|
|
import { config } from "../config/env.ts";
|
|
|
|
// Rate limiting store (in-memory, use Redis in production)
|
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
|
|
|
// Rate limiting middleware
|
|
export async function rateLimit(ctx: Context, next: Next): Promise<void> {
|
|
const ip = ctx.request.ip || "unknown";
|
|
const now = Date.now();
|
|
const windowMs = config.RATE_LIMIT_WINDOW_MS;
|
|
const maxRequests = config.RATE_LIMIT_MAX_REQUESTS;
|
|
|
|
const record = rateLimitStore.get(ip);
|
|
|
|
if (!record || now > record.resetTime) {
|
|
rateLimitStore.set(ip, { count: 1, resetTime: now + windowMs });
|
|
} else {
|
|
record.count++;
|
|
|
|
if (record.count > maxRequests) {
|
|
ctx.response.status = 429;
|
|
ctx.response.body = {
|
|
error: "Too many requests",
|
|
retryAfter: Math.ceil((record.resetTime - now) / 1000)
|
|
};
|
|
return;
|
|
}
|
|
}
|
|
|
|
await next();
|
|
}
|
|
|
|
// Security headers middleware
|
|
export async function securityHeaders(ctx: Context, next: Next): Promise<void> {
|
|
await next();
|
|
|
|
// Prevent clickjacking
|
|
ctx.response.headers.set("X-Frame-Options", "DENY");
|
|
|
|
// Prevent MIME type sniffing
|
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
|
|
|
// XSS protection
|
|
ctx.response.headers.set("X-XSS-Protection", "1; mode=block");
|
|
|
|
// Referrer policy
|
|
ctx.response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
|
|
// Content Security Policy
|
|
ctx.response.headers.set(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
|
|
);
|
|
|
|
// Strict Transport Security (only in production with HTTPS)
|
|
if (config.isProduction()) {
|
|
ctx.response.headers.set(
|
|
"Strict-Transport-Security",
|
|
"max-age=31536000; includeSubDomains"
|
|
);
|
|
}
|
|
}
|
|
|
|
// CORS middleware
|
|
export async function cors(ctx: Context, next: Next): Promise<void> {
|
|
const origin = ctx.request.headers.get("Origin");
|
|
const allowedOrigins = config.CORS_ORIGIN.split(",").map(o => o.trim());
|
|
|
|
// Check if origin is allowed
|
|
if (origin && (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))) {
|
|
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
|
} else if (config.isDevelopment()) {
|
|
// Allow all origins in development
|
|
ctx.response.headers.set("Access-Control-Allow-Origin", origin || "*");
|
|
}
|
|
|
|
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
|
ctx.response.headers.set("Access-Control-Max-Age", "86400");
|
|
|
|
// Handle preflight requests
|
|
if (ctx.request.method === "OPTIONS") {
|
|
ctx.response.status = 204;
|
|
return;
|
|
}
|
|
|
|
await next();
|
|
}
|
|
|
|
// Request logging middleware
|
|
export async function requestLogger(ctx: Context, next: Next): Promise<void> {
|
|
const start = Date.now();
|
|
const { method, url } = ctx.request;
|
|
|
|
await next();
|
|
|
|
const ms = Date.now() - start;
|
|
const status = ctx.response.status;
|
|
|
|
// Color code based on status
|
|
let statusColor = "\x1b[32m"; // Green for 2xx
|
|
if (status >= 400 && status < 500) statusColor = "\x1b[33m"; // Yellow for 4xx
|
|
if (status >= 500) statusColor = "\x1b[31m"; // Red for 5xx
|
|
|
|
console.log(
|
|
`${new Date().toISOString()} - ${method} ${url.pathname} ${statusColor}${status}\x1b[0m ${ms}ms`
|
|
);
|
|
}
|
|
|
|
// Input sanitization helper
|
|
export function sanitizeInput(input: string): string {
|
|
return input
|
|
.replace(/[<>]/g, "") // Remove angle brackets
|
|
.replace(/javascript:/gi, "") // Remove javascript: protocol
|
|
.replace(/on\w+=/gi, "") // Remove event handlers
|
|
.trim();
|
|
}
|
|
|
|
// Validate email format
|
|
export function isValidEmail(email: string): boolean {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(email);
|
|
}
|
|
|
|
// Validate password strength
|
|
export function isStrongPassword(password: string): { valid: boolean; message?: string } {
|
|
if (password.length < 8) {
|
|
return { valid: false, message: "Password must be at least 8 characters long" };
|
|
}
|
|
if (!/[A-Z]/.test(password)) {
|
|
return { valid: false, message: "Password must contain at least one uppercase letter" };
|
|
}
|
|
if (!/[a-z]/.test(password)) {
|
|
return { valid: false, message: "Password must contain at least one lowercase letter" };
|
|
}
|
|
if (!/[0-9]/.test(password)) {
|
|
return { valid: false, message: "Password must contain at least one number" };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
// Clean up old rate limit entries periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, record] of rateLimitStore.entries()) {
|
|
if (now > record.resetTime) {
|
|
rateLimitStore.delete(ip);
|
|
}
|
|
}
|
|
}, 60000); // Clean up every minute
|