/*********************************************************************************************************************************************************************** * File Name: originGuard.middleware.js * Type of Program: Middleware * Description: Three-layer server-side guard that blocks non-browser clients from reaching any API route. * * ── Layer 1 — Fetch Metadata family (ALL methods including GET) ───────────────────────────────── * * 1a) Sec-Fetch-Site presence + value * Browsers (Chrome 76+, Firefox 90+, Safari 16.4+) automatically attach Sec-Fetch-Site on * every request. It is a forbidden request header — JavaScript cannot set, override, or * remove it. Its absence reliably signals a non-browser client. The value cross-site is * also rejected; only same-origin, same-site, and none (direct navigation) are accepted. * * Tools blocked (default configurations): * ✓ Metasploit (Rex HTTP client) — no Sec-Fetch-Site * ✓ BurpSuite Repeater / Scanner — no Sec-Fetch-Site * ✓ Postman — no Sec-Fetch-Site * ✓ curl / wget / httpie / python-requests — no Sec-Fetch-Site * ✓ Nikto — no Sec-Fetch-Site * ✓ sqlmap — no Sec-Fetch-Site * ✓ dirb / gobuster / feroxbuster — no Sec-Fetch-Site * ✓ nmap HTTP scripts — no Sec-Fetch-Site * * 1b) Sec-Fetch-Mode + Sec-Fetch-Dest presence + valid combination * Browsers that send Sec-Fetch-Site always send Mode and Dest too (Chrome 80+, * Firefox 90+, Safari 16.4+). Missing headers or impossible combinations signal * manual header injection. Only combinations expected on an API server are allowed: * cors|empty — standard fetch() call from a cross-origin SPA * same-origin|empty — same-origin fetch() * navigate|document — direct browser navigation to an API URL * * Additional tools blocked: * ✓ Scripts that fake only Sec-Fetch-Site — missing Mode or Dest * ✓ Scripts with wrong Mode+Dest combos — no-cors, cors+document, etc. * * ── Layer 2 — Browser presence signals (ALL methods including GET) ───────────────────────────── * * At least one browser-native header must be present: * Sec-CH-UA — Chromium client hint, forbidden in non-browser contexts * Accept-Language — sent by all browsers (Chrome, Firefox, Safari) * Absence of both is a strong automation signal that catches tools sophisticated enough to * replicate the Sec-Fetch-* family but not the full browser header profile. * * ── Layer 3 — Origin allowlist (POST / PUT / PATCH / DELETE only) ────────────────────────────── * * State-mutating requests must also carry an Origin header that matches ALLOWED_ORIGINS. * Stops credential-stuffing and cross-origin mutation attempts from unlisted domains, * even if an attacker replicated all browser headers above. * * ── What this does NOT stop ────────────────────────────────────────────────────────────────────── * * ✗ BurpSuite running as MITM proxy through a real browser session. * ✗ Playwright / Puppeteer / Selenium controlling a real browser — they produce all correct * Sec-Fetch-* headers, Sec-CH-UA, and Accept-Language automatically. * ✗ A determined attacker who manually replicates all required headers. * The only defences at that point are rate limiting and valid credentials. * * ── Browser compatibility note ─────────────────────────────────────────────────────────────────── * * Sec-Fetch-Site is supported by Chrome 76+ (Aug 2019), Firefox 90+ (Jul 2021), * and Safari 16.4+ (Mar 2023). Requests from browsers older than these thresholds * will be rejected. Given this is a modern SPA (Vite + React), this is acceptable. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 20, 2026 * Date Modified: Jun. 29, 2026 ***********************************************************************************************************************************************************************/ "use strict"; const R = require('../utils/response.util'); const ALLOWED = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || '') .split(',') .map(o => o.trim()) .filter(Boolean); const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); // Routes that legitimately receive a top-level cross-site navigation — a third-party // IdP (Google) redirects the browser here after consent, so Sec-Fetch-Site is // correctly "cross-site" even though the request is a real browser, not an attacker. // Still gated on GET + navigate|document below, so this doesn't open the route to // cross-site fetch()/XHR — only actual browser navigations. const CROSS_SITE_NAVIGATION_PATHS = new Set(['/google/callback']); // Valid Sec-Fetch-Mode + Sec-Fetch-Dest combinations expected on this API server. const VALID_FETCH_COMBOS = new Set([ 'cors|empty', // standard fetch() from cross-origin SPA 'same-origin|empty', // same-origin fetch() 'navigate|document', // direct browser navigation to an API URL ]); module.exports = function originGuard(req, res, next) { // Dev bypass: set ORIGIN_GUARD_DISABLED=true in .env to allow Postman/curl through. // Hardcoded production lock — never bypassed even if the flag is accidentally set. if (process.env.ORIGIN_GUARD_DISABLED === 'true' && process.env.NODE_ENV !== 'production') { return next(); } if (req.method === 'OPTIONS') return next(); // preflight — handled by cors() // ── Layer 1a: Sec-Fetch-Site must be present and not cross-site ─────────── // Exception: OAuth callback routes receive a real cross-site top-level navigation // from the IdP's domain — allowed only in combination with the navigate|document // check in Layer 1b, so cross-site fetch()/XHR is still rejected everywhere. const fetchSite = req.headers['sec-fetch-site']; const isCrossSiteNavigationRoute = req.method === 'GET' && CROSS_SITE_NAVIGATION_PATHS.has(req.path); if (!fetchSite || (fetchSite === 'cross-site' && !isCrossSiteNavigationRoute)) { return R.error(res, 'Forbidden.', 403); } // ── Layer 1b: Fetch Metadata family must be complete and form a valid combo ─ const fetchMode = req.headers['sec-fetch-mode']; const fetchDest = req.headers['sec-fetch-dest']; if (!fetchMode || !fetchDest || !VALID_FETCH_COMBOS.has(`${fetchMode}|${fetchDest}`)) { return R.error(res, 'Forbidden.', 403); } if (isCrossSiteNavigationRoute && `${fetchMode}|${fetchDest}` !== 'navigate|document') { return R.error(res, 'Forbidden.', 403); } // ── Layer 2: At least one browser-native fingerprint header must be present ─ if (!req.headers['sec-ch-ua'] && !req.headers['accept-language']) { return R.error(res, 'Forbidden.', 403); } // ── Layer 3: Origin must be in allowlist for state-changing requests ─────── if (MUTATION_METHODS.has(req.method)) { const origin = req.headers['origin']; if (!origin || !ALLOWED.includes(origin)) { return R.error(res, 'Forbidden.', 403); } } next(); };