#!/usr/bin/env node 'use strict'; /* * Fluint Shield agent - a zero-dependency reverse proxy that sits in front of your website. * * visitors --> [ Fluint agent :80/:443 ] --> your real site (e.g. http://127.0.0.1:8081) * * It enforces the protection policy you control from the Fluint dashboard (WAF rules, rate * limiting, brute-force and scanner bans, IP/country blocks, security headers, lockdown) and * reports traffic and attacks back to Fluint. Everything starts in "monitor" mode: attacks are * reported to you, and only blocked once you choose to block them. * * Usage: * node fluint-agent.js --server https://fluint.example.com --key fla_xxx --upstream http://127.0.0.1:8081 --port 80 * Options (or env vars FLUINT_SERVER, FLUINT_KEY, FLUINT_UPSTREAM, FLUINT_PORT, FLUINT_HOST, * FLUINT_TRUST_PROXY, FLUINT_TLS_CERT, FLUINT_TLS_KEY): * --trust-proxy cloudflare|xff read the real visitor IP from CF-Connecting-IP / X-Forwarded-For * --tls-cert / --tls-key PEM files to serve HTTPS directly * Optional: `npm i geoip-lite` next to this file to enable country blocking without Cloudflare. */ const http = require('http'); const https = require('https'); const fs = require('fs'); const path = require('path'); const net = require('net'); const crypto = require('crypto'); const dns = require('dns').promises; const VERSION = '0.1.0'; // --- Config --------------------------------------------------------------------------------------- const argv = {}; for (let i = 2; i < process.argv.length; i++) { const m = process.argv[i].match(/^--([\w-]+)(?:=(.*))?$/); if (m) argv[m[1]] = m[2] !== undefined ? m[2] : (process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[++i] : 'true'); } const opt = (name, envName, def) => argv[name] ?? process.env[envName] ?? def; const CFG = { server: String(opt('server', 'FLUINT_SERVER', '')).replace(/\/$/, ''), key: opt('key', 'FLUINT_KEY', ''), upstream: new URL(opt('upstream', 'FLUINT_UPSTREAM', 'http://127.0.0.1:8081')), port: Number(opt('port', 'FLUINT_PORT', 8000)), host: opt('host', 'FLUINT_HOST', '0.0.0.0'), trustProxy: opt('trust-proxy', 'FLUINT_TRUST_PROXY', ''), tlsCert: opt('tls-cert', 'FLUINT_TLS_CERT', ''), tlsKey: opt('tls-key', 'FLUINT_TLS_KEY', ''), cacheFile: opt('cache', 'FLUINT_CACHE', path.join(__dirname, '.fluint-policy.json')), }; if (!CFG.server || !/^fla_[0-9a-f]{16}_/.test(CFG.key)) { console.error('Fluint agent: --server and --key are required (copy the install command from your Fluint dashboard).'); process.exit(1); } // Fluint's own scanner/uptime checks carry an HMAC signature so they aren't reported as attacks. const KEY_HASH = crypto.createHash('sha256').update(CFG.key.slice(21)).digest('hex'); function isFluintScan(req) { const [ts, mac] = String(req.headers['x-fluint-scan'] || '').split('.'); if (!ts || !mac || Math.abs(Date.now() - Number(ts)) > 300000) return false; const expected = crypto.createHmac('sha256', KEY_HASH).update(ts).digest('hex'); return mac.length === expected.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected)); } let geoip = null; try { geoip = require('geoip-lite'); } catch { /* optional */ } // --- Policy (cached to disk so protection survives restarts / Fluint outages) --------------------- let policy = { waf: { sqli: 'monitor', xss: 'monitor', traversal: 'monitor', cmdi: 'monitor', ssrf: 'monitor', sensitiveFiles: 'monitor', scanners: 'monitor' }, rateLimit: { mode: 'monitor', perMinute: 300, banMinutes: 15 }, bruteForce: { mode: 'monitor', paths: ['/wp-login.php', '/xmlrpc.php', '/login', '/admin'], maxAttempts: 10, windowMinutes: 5 }, probing: { mode: 'monitor', max404PerMinute: 40 }, headers: {}, blockedPaths: [], ipBlock: [], ipAllow: [], countryBlock: [], lockdown: false, }; let verifyToken = ''; try { const cached = JSON.parse(fs.readFileSync(CFG.cacheFile, 'utf8')); policy = cached.policy || policy; verifyToken = cached.verifyToken || ''; } catch { /* first run */ } let compiled = compile(policy); function ipToInt(ip) { return ip.split('.').reduce((a, o) => (a << 8) + Number(o), 0) >>> 0; } function compileIpList(list) { const exact = new Set(); const ranges = []; for (const entry of list || []) { const [ip, bits] = entry.split('/'); if (bits === undefined) exact.add(ip); else if (net.isIPv4(ip)) { const b = Number(bits); const mask = b === 0 ? 0 : (~0 << (32 - b)) >>> 0; ranges.push([ipToInt(ip) & mask, mask]); } } return (ip) => exact.has(ip) || (net.isIPv4(ip) && ranges.some(([n, m]) => ((ipToInt(ip) & m) >>> 0) === n)); } function compile(p) { return { blocked: compileIpList(p.ipBlock), allowed: compileIpList(p.ipAllow), countries: new Set(p.countryBlock || []), bruteRe: (p.bruteForce.paths || []).map((x) => x.toLowerCase()), }; } async function api(method, route, body) { const res = await fetch(CFG.server + route, { method, headers: { authorization: `Bearer ${CFG.key}`, 'content-type': 'application/json', 'x-fluint-agent': VERSION }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000), }); if (!res.ok) throw new Error(`${route} -> HTTP ${res.status}`); return res.json(); } let lastPolicyJson = ''; async function pullPolicy() { try { const r = await api('GET', '/api/agent/policy'); const json = JSON.stringify(r.policy); if (json !== lastPolicyJson) { policy = r.policy; compiled = compile(policy); verifyToken = r.verifyToken; if (lastPolicyJson) console.log(`[fluint] policy updated (lockdown=${policy.lockdown})`); lastPolicyJson = json; fs.writeFile(CFG.cacheFile, JSON.stringify({ policy, verifyToken }), () => {}); } } catch (e) { console.warn('[fluint] policy fetch failed:', e.message); } } // --- Detection rules ------------------------------------------------------------------------------- const RULES = { sqli: [ ['union-select', /\bunion\b[\s\S]{0,40}\bselect\b/i], ['schema-probe', /\b(information_schema|mysql\.user|pg_catalog|sysobjects|sqlite_master)\b/i], ['tautology', /['"`]\s*(or|and|\|\||&&)\s+['"`]?[\w-]+['"`]?\s*(=|like)\s*['"`]?[\w-]+/i], ['time-based', /\b(sleep|benchmark|pg_sleep)\s*\(\s*\d|\bwaitfor\s+delay\b/i], ['stacked-query', /;\s*(drop|truncate|alter|insert|update|delete)\s+(table|into|from|\w+\s+set)\b/i], ['error-based', /\b(extractvalue|updatexml)\s*\(/i], ['comment-terminator', /'\s*(--|#|\/\*)/], ], xss: [ ['script-tag', /<\s*script\b/i], ['js-uri', /\bjavascript\s*:/i], ['event-handler', /<[^>]*\bon(error|load|mouseover|focus|click|toggle|animationstart|pointerover)\s*=/i], ['dangerous-tag', /<\s*(iframe|object|embed|svg|math)\b/i], ['cookie-theft', /\bdocument\.(cookie|domain)\b|\bwindow\.location\s*=/i], ], traversal: [ ['dot-dot', /(?:\.\.[/\\]){2,}|(?:^|[/\\=])\.\.[/\\]/], ['system-file', /\/etc\/(passwd|shadow|hosts|group)\b|\b(boot|win)\.ini\b|\/proc\/self\//i], ['php-wrapper', /\b(php|zip|phar|expect|data):\/\//i], ['file-uri', /\bfile:\/\//i], ], cmdi: [ // (?!\s*=) so ordinary query strings like "&id=5" or "&cat=shoes" are not flagged. ['shell-chain', /(?:[;&|`]|\$\()\s*(?:cat|ls|id|whoami|uname|wget|curl|nc|ncat|bash|sh|powershell|cmd(?:\.exe)?|python|perl|chmod)\b(?!\s*=)/i], ['log4shell', /\$\{\s*(jndi|lower|upper|env|sys|::-)/i], ['php-exec', /\b(shell_exec|passthru|proc_open|popen|system)\s*\(/i], ], ssrf: [ ['cloud-metadata', /169\.254\.169\.254|metadata\.google\.internal|100\.100\.100\.200|fd00:ec2::254/i], ['odd-scheme', /\b(gopher|dict|ldap|tftp):\/\//i], ], sensitiveFiles: [ ['dotfile', /\/\.(git|svn|hg|env|aws|ssh|htpasswd|DS_Store|vscode|idea)(\/|$)/i], ['config-backup', /(wp-config|config|configuration|settings|database|db)\.(php|inc|ya?ml|json|ini)(\.|~)(bak|old|orig|save|swp|txt|1)?$/i], ['dump', /\/(backup|dump|db|database|site)\.(sql|tar|tar\.gz|tgz|zip|rar|7z)$/i], ['info-page', /\/(phpinfo|info|test)\.php$/i], ], }; const SCANNER_UA = /(sqlmap|nikto|nmap|masscan|zgrab|wpscan|acunetix|nessus|openvas|nuclei|gobuster|dirbuster|feroxbuster|ffuf|wfuzz|hydra|jaeles|whatweb|netsparker|commix|xsstrike|arachni)/i; function safeDecode(s) { let out = s; for (let i = 0; i < 2; i++) { try { const d = decodeURIComponent(out.replace(/\+/g, ' ')); if (d === out) break; out = d; } catch { break; } } return out; } function inspect(target, rules) { for (const [ruleName, re] of rules) { const m = target.match(re); if (m) return { rule: ruleName, sample: target.slice(Math.max(0, m.index - 30), m.index + 90) }; } return null; } // --- Telemetry buffers ---------------------------------------------------------------------------- let hits = [], threats = []; const counters = { requests: 0, blocked: 0, bytes: 0, peakRps: 0, challenged: 0, passed: 0 }; function reportThreat(t) { if (threats.length < 3000) threats.push({ ts: Date.now(), ...t }); } async function flush() { if (!hits.length && !threats.length && !counters.requests) return; const batch = { hits, threats, counters: { ...counters } }; hits = []; threats = []; Object.assign(counters, { requests: 0, blocked: 0, bytes: 0, peakRps: 0, challenged: 0, passed: 0 }); try { await api('POST', '/api/agent/ingest', batch); } catch (e) { console.warn('[fluint] report failed:', e.message); // Keep the most recent data for the next attempt. hits = batch.hits.concat(hits).slice(-5000); threats = batch.threats.concat(threats).slice(-2000); } } // --- Per-IP state --------------------------------------------------------------------------------- const bans = new Map(); // ip -> until const perIp = new Map(); // ip -> { minute, count, notFound, logins: [ts], flagged: {} } let secondCount = 0, currentSecond = 0, rpsAvg = 20, surgeSeconds = 0, surgeReportedAt = 0; function ipState(ip) { const minute = Math.floor(Date.now() / 60000); let s = perIp.get(ip); if (!s) { s = { minute, count: 0, notFound: 0, logins: [], flagged: {} }; perIp.set(ip, s); } if (s.minute !== minute) { s.minute = minute; s.count = 0; s.notFound = 0; s.flagged = {}; } return s; } setInterval(() => { const t = Date.now(), minute = Math.floor(t / 60000); for (const [ip, until] of bans) if (until < t) bans.delete(ip); for (const [ip, s] of perIp) if (s.minute < minute - 1 && !s.logins.length) perIp.delete(ip); }, 60000).unref(); // A surge = 3+ consecutive seconds far above the learned normal rate (one heavy page load is not a DDoS). const surgeLimit = () => Math.max(200, rpsAvg * 8); function underSurge() { return surgeSeconds >= 3; } function trackRps() { const sec = Math.floor(Date.now() / 1000); if (sec !== currentSecond) { if (currentSecond) { counters.peakRps = Math.max(counters.peakRps, secondCount); surgeSeconds = sec - currentSecond === 1 && secondCount > surgeLimit() ? surgeSeconds + 1 : 0; // Learn the normal rate only from non-surge traffic. if (!surgeSeconds) rpsAvg = Math.max(20, rpsAvg * 0.98 + secondCount * 0.02); } currentSecond = sec; secondCount = 0; } secondCount++; if (underSurge() && Date.now() - surgeReportedAt > 60000) { surgeReportedAt = Date.now(); const top = [...perIp.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, 5).map(([ip, s]) => `${ip} (${s.count}/min)`); reportThreat({ type: 'ddos', rule: 'surge', ip: '-', sample: `${counters.peakRps} req/s peak vs normal ~${Math.round(rpsAvg)} req/s. Top sources: ${top.join(', ')}`, action: policy.rateLimit.mode === 'block' ? 'blocked' : 'logged' }); } } // --- Request handling ----------------------------------------------------------------------------- function clientIp(req) { let ip = req.socket.remoteAddress || ''; if (CFG.trustProxy === 'cloudflare' && req.headers['cf-connecting-ip']) ip = req.headers['cf-connecting-ip']; else if (CFG.trustProxy === 'xff' && req.headers['x-forwarded-for']) ip = req.headers['x-forwarded-for'].split(',').pop().trim(); return ip.replace(/^::ffff:/, ''); } function countryOf(req, ip) { if (CFG.trustProxy === 'cloudflare' && req.headers['cf-ipcountry']) return req.headers['cf-ipcountry']; if (geoip) { try { return (geoip.lookup(ip) || {}).country || null; } catch { /* ignore */ } } return null; } const PAGE = (title, msg) => `${title}

${title}

${msg}

`; function deny(res, status, title, msg, extra = {}) { counters.blocked++; res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-fluint': 'blocked', ...extra }); res.end(PAGE(title, msg)); } const isAsset = (p) => /\.(css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|eot|mp4|webm|mp3|json|txt|xml)$/i.test(p); /** Returns true if the request was handled (blocked). Runs before the body is read. */ function preChecks(req, res, ctx) { const { ip, pathname, decoded, ua } = ctx; const p = policy; if (compiled.allowed(ip)) { ctx.trusted = true; return false; } if (p.lockdown) { deny(res, 503, 'Down for maintenance', 'We\'ll be back shortly.', { 'retry-after': '600' }); return true; } if (compiled.blocked(ip)) { deny(res, 403, 'Access denied', 'Your network has been blocked by this site\'s security policy.'); return true; } const banned = bans.get(ip); if (banned && banned > Date.now()) { deny(res, 429, 'Slow down', 'Too many requests. Please try again later.', { 'retry-after': String(Math.ceil((banned - Date.now()) / 1000)) }); return true; } if (compiled.countries.size) { const c = countryOf(req, ip); if (c && compiled.countries.has(c)) { deny(res, 403, 'Not available', 'This site is not available in your region.'); return true; } } const lowerPath = pathname.toLowerCase(); if ((p.blockedPaths || []).some((bp) => lowerPath === bp.toLowerCase() || lowerPath.startsWith(bp.toLowerCase().replace(/\/?$/, '/')))) { counters.blocked++; res.writeHead(404, { 'content-type': 'text/plain' }); res.end('Not found'); return true; } // Signed Fluint scans see the site exactly as visitors do (blocks above apply) but are never reported. if (ctx.fluint) return false; // Rate limiting. const st = ipState(ip); st.count++; const limit = underSurge() && p.rateLimit.mode === 'block' ? Math.ceil(p.rateLimit.perMinute / 2) : p.rateLimit.perMinute; if (p.rateLimit.mode !== 'off' && st.count > limit) { const block = p.rateLimit.mode === 'block'; if (!st.flagged.rate) { st.flagged.rate = true; reportThreat({ type: 'ratelimit', rule: 'per-ip', ip, method: req.method, path: pathname, sample: `${st.count} requests in the last minute (limit ${limit})`, ua, action: block ? 'blocked' : 'logged' }); } if (block) { bans.set(ip, Date.now() + p.rateLimit.banMinutes * 60000); deny(res, 429, 'Slow down', 'Too many requests. Please try again later.', { 'retry-after': String(p.rateLimit.banMinutes * 60) }); return true; } } // Brute force on login endpoints. if (req.method === 'POST' && p.bruteForce.mode !== 'off' && compiled.bruteRe.some((bp) => lowerPath === bp || lowerPath.startsWith(`${bp}/`))) { const windowMs = p.bruteForce.windowMinutes * 60000; st.logins = st.logins.filter((t) => t > Date.now() - windowMs); st.logins.push(Date.now()); if (st.logins.length > p.bruteForce.maxAttempts) { const block = p.bruteForce.mode === 'block'; if (!st.flagged.brute) { st.flagged.brute = true; reportThreat({ type: 'bruteforce', rule: 'login-attempts', ip, method: req.method, path: pathname, sample: `${st.logins.length} login attempts in ${p.bruteForce.windowMinutes} min`, ua, action: block ? 'blocked' : 'logged' }); } if (block) { bans.set(ip, Date.now() + 30 * 60000); deny(res, 429, 'Too many login attempts', 'Please wait a while before trying again.'); return true; } } } // Already flagged as a scanner this minute. if (p.probing.mode === 'block' && st.notFound > p.probing.max404PerMinute) { bans.set(ip, Date.now() + 60 * 60000); deny(res, 403, 'Access denied', 'Automated scanning is not allowed.'); return true; } // WAF on URL + headers. const targets = [ ['scanners', ua, [['scanner-ua', SCANNER_UA]]], ['sensitiveFiles', pathname, RULES.sensitiveFiles], ]; const urlTarget = decoded; const headerTarget = `${req.headers.referer || ''} ${ua} ${req.headers['x-forwarded-host'] || ''} ${req.headers['x-api-version'] || ''}`; for (const type of ['sqli', 'xss', 'traversal', 'cmdi', 'ssrf']) targets.push([type, urlTarget, RULES[type]]); targets.push(['cmdi', headerTarget, [RULES.cmdi[1]]]); // Log4Shell hides in headers for (const [type, target, rules] of targets) { if (wafHit(req, res, ctx, type, target, rules)) return true; } return false; } function wafHit(req, res, ctx, type, target, rules) { const mode = policy.waf[type]; if (!mode || mode === 'off' || !target) return false; const hit = inspect(target, rules); if (!hit) return false; const block = mode === 'block'; reportThreat({ type, rule: hit.rule, ip: ctx.ip, method: req.method, path: ctx.pathname, sample: hit.sample, ua: ctx.ua, action: block ? 'blocked' : 'logged' }); if (block) { deny(res, 403, 'Request blocked', 'This request looked like an attack and was blocked by the site\'s firewall.'); return true; } return false; } function hardenHeaders(headers) { const h = policy.headers || {}; const out = { ...headers }; const setIfMissing = (k, v) => { if (out[k] === undefined) out[k] = v; }; if (h.hsts) setIfMissing('strict-transport-security', 'max-age=31536000; includeSubDomains'); if (h.nosniff) setIfMissing('x-content-type-options', 'nosniff'); if (h.frameDeny) setIfMissing('x-frame-options', 'SAMEORIGIN'); if (h.referrerPolicy) setIfMissing('referrer-policy', 'strict-origin-when-cross-origin'); if (h.permissionsPolicy) setIfMissing('permissions-policy', 'camera=(), microphone=(), geolocation=()'); if (h.csp) setIfMissing('content-security-policy', h.csp); if (h.hideServer) for (const k of ['server', 'x-powered-by', 'x-aspnet-version', 'x-aspnetmvc-version', 'x-generator']) delete out[k]; if (h.secureCookies && out['set-cookie']) { out['set-cookie'] = [].concat(out['set-cookie']).map((c) => { let v = c; const name = c.split('=')[0]; if (!/;\s*secure/i.test(v)) v += '; Secure'; if (!/;\s*samesite=/i.test(v)) v += '; SameSite=Lax'; if (/sess|sid|token|auth|login|jwt/i.test(name) && !/;\s*httponly/i.test(v)) v += '; HttpOnly'; return v; }); } return out; } const upstreamMod = CFG.upstream.protocol === 'https:' ? https : http; const HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'te', 'trailer']; function forwardHeaders(req, ip) { const headers = { ...req.headers }; for (const k of HOP) delete headers[k]; delete headers['x-fluint-scan']; const prior = CFG.trustProxy ? req.headers['x-forwarded-for'] : null; headers['x-forwarded-for'] = prior ? `${prior}, ${ip}` : ip; headers['x-real-ip'] = ip; headers['x-forwarded-proto'] = req.socket.encrypted ? 'https' : (CFG.trustProxy && req.headers['x-forwarded-proto']) || 'http'; headers['x-forwarded-host'] = req.headers.host || ''; return headers; } function proxy(req, res, ctx, bodyBuf) { const started = Date.now(); const preq = upstreamMod.request({ protocol: CFG.upstream.protocol, hostname: CFG.upstream.hostname, port: CFG.upstream.port || (CFG.upstream.protocol === 'https:' ? 443 : 80), method: req.method, path: req.url, headers: forwardHeaders(req, ctx.ip), timeout: 60000, }, (pres) => { const headers = hardenHeaders(pres.headers); for (const k of HOP) delete headers[k]; res.writeHead(pres.statusCode, pres.statusMessage, headers); pres.on('data', (d) => { counters.bytes += d.length; }); pres.pipe(res); pres.on('end', () => record(req, ctx, pres.statusCode, Date.now() - started)); }); preq.on('timeout', () => preq.destroy(new Error('Upstream timeout'))); preq.on('error', (e) => { if (!res.headersSent) { res.writeHead(502, { 'content-type': 'text/html; charset=utf-8' }); res.end(PAGE('Site unavailable', 'The website is not responding right now. Please try again in a moment.')); } else res.destroy(); record(req, ctx, 502, Date.now() - started); console.warn('[fluint] upstream error:', e.message); }); if (bodyBuf) preq.end(bodyBuf); else req.pipe(preq); } function record(req, ctx, status, ms) { if (ctx.fluint) return; if (status === 404) { const st = ipState(ctx.ip); st.notFound++; if (policy.probing.mode !== 'off' && st.notFound > policy.probing.max404PerMinute && !st.flagged.probe && !ctx.trusted) { st.flagged.probe = true; reportThreat({ type: 'probing', rule: '404-flood', ip: ctx.ip, method: req.method, path: ctx.pathname, sample: `${st.notFound} not-found pages in one minute`, ua: ctx.ua, action: policy.probing.mode === 'block' ? 'blocked' : 'logged' }); if (policy.probing.mode === 'block') bans.set(ctx.ip, Date.now() + 60 * 60000); } } if (!isAsset(ctx.pathname) || status >= 400) { if (hits.length < 8000) hits.push({ ts: Date.now(), ip: ctx.ip, method: req.method, path: ctx.pathname.slice(0, 300), status, ms, ua: ctx.ua, ref: req.headers.referer || '' }); } } // --- Visitor challenge (captcha for new IPs) -------------------------------------------------------- const CHALLENGE_PATH = '/.fluint/challenge'; const CHALLENGE_KEY = crypto.randomBytes(32); const PASS_FILE = path.join(path.dirname(CFG.cacheFile), '.fluint-passes.json'); const passes = new Map(); // ip -> { at, exp } const usedTokens = new Map(); // token -> expiry (one use only) const botCache = new Map(); // ip -> { ok, exp } let passesDirty = false; try { for (const [ip, v] of Object.entries(JSON.parse(fs.readFileSync(PASS_FILE, 'utf8')))) passes.set(ip, v); } catch { /* first run */ } function savePasses() { if (!passesDirty) return; passesDirty = false; const now = Date.now(); const obj = {}; for (const [ip, v] of passes) if (v.exp > now) obj[ip] = v; fs.writeFile(PASS_FILE, JSON.stringify(obj), () => {}); } setInterval(() => { savePasses(); const now = Date.now(); for (const [t, exp] of usedTokens) if (exp < now) usedTokens.delete(t); for (const [ip, v] of botCache) if (v.exp < now) botCache.delete(ip); }, 30000).unref(); const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex'); const pathMatch = (lowerPath, p) => { const lp = p.toLowerCase(); return lowerPath === lp || lowerPath.startsWith(lp.replace(/\/?$/, '/')) || (lp.endsWith('/') && lowerPath.startsWith(lp)); }; function hasPassed(ip) { const v = passes.get(ip); if (!v) return false; if (v.exp < Date.now() || v.at < (policy.challenge.resetAt || 0)) { passes.delete(ip); passesDirty = true; return false; } return true; } function needsChallenge(ctx) { const c = policy.challenge; if (!c || !c.enabled || ctx.trusted || ctx.fluint) return false; if (c.when === 'surge' && !underSurge()) return false; const lp = ctx.pathname.toLowerCase(); if ((c.exemptPaths || []).some((p) => pathMatch(lp, p))) return false; if ((c.onlyPaths || []).length && !c.onlyPaths.some((p) => pathMatch(lp, p))) return false; return !hasPassed(ctx.ip); } // Real search-engine crawlers are verified by reverse + forward DNS (a user agent alone can be faked). const SEARCH_UA = /Googlebot|Google-InspectionTool|AdsBot-Google|bingbot|Applebot|YandexBot|Baiduspider/i; const SEARCH_HOSTS = /\.(googlebot\.com|google\.com|googleusercontent\.com|search\.msn\.com|applebot\.apple\.com|yandex\.(ru|net|com)|baidu\.(com|jp))$/i; async function isVerifiedSearchBot(ip, ua) { if (!SEARCH_UA.test(ua)) return false; const cached = botCache.get(ip); if (cached && cached.exp > Date.now()) return cached.ok; let ok = false; try { for (const name of await dns.reverse(ip)) { if (!SEARCH_HOSTS.test(name)) continue; if ((await dns.lookup(name, { all: true })).some((a) => a.address === ip)) { ok = true; break; } } } catch { /* no PTR record */ } botCache.set(ip, { ok, exp: Date.now() + 6 * 3600000 }); return ok; } function makeToken(ip) { const ts = Date.now().toString(36); const rand = crypto.randomBytes(8).toString('hex'); const mac = crypto.createHmac('sha256', CHALLENGE_KEY).update(`${ip}|${ts}|${rand}`).digest('hex').slice(0, 32); return `${ts}.${rand}.${mac}`; } function checkToken(token, ip) { const [ts, rand, mac] = String(token || '').split('.'); if (!ts || !rand || !mac || usedTokens.has(token)) return false; if (Date.now() - parseInt(ts, 36) > 15 * 60000) return false; const expected = crypto.createHmac('sha256', CHALLENGE_KEY).update(`${ip}|${ts}|${rand}`).digest('hex').slice(0, 32); return mac.length === expected.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected)); } const safeReturn = (r) => (typeof r === 'string' && r.startsWith('/') && !r.startsWith('//') && !/[\\\r\n]/.test(r) && r.length < 2000 ? r : '/'); // Public-domain compact SHA-256 so the proof-of-work also runs on plain-HTTP pages (no crypto.subtle there). const SHA256_JS = "function sha256(a){function r(v,n){return(v>>>n)|(v<<(32-n))}var P=Math.pow,M=P(2,32),i,j,out='',w=[],L=a.length*8,h=sha256.h=sha256.h||[],k=sha256.k=sha256.k||[],pc=k.length,c={};for(var q=2;pc<64;q++){if(!c[q]){for(i=0;i<313;i+=q)c[i]=q;h[pc]=(P(q,.5)*M)|0;k[pc++]=(P(q,1/3)*M)|0}}a+='\\x80';while(a.length%64-56)a+='\\x00';for(i=0;i>8)return;w[i>>2]|=j<<((3-i)%4)*8}w[w.length]=((L/M)|0);w[w.length]=L;for(j=0;j>>3))+W[i-7]+(r(y,17)^r(y,19)^(y>>>10)))|0),t2=(r(A,2)^r(A,13)^r(A,22))+((A&h[1])^(A&h[2])^(h[1]&h[2]));h=[(t1+t2)|0].concat(h);h[4]=(h[4]+t1)|0}for(i=0;i<8;i++)h[i]=(h[i]+o[i])|0}for(i=0;i<8;i++){for(j=3;j+1;j--){var b=(h[i]>>(j*8))&255;out+=((b<16)?0:'')+b.toString(16)}}return out}"; function challengePage(ctx, returnTo, error) { const c = policy.challenge; const token = makeToken(ctx.ip); const hidden = ``; const body = c.provider === 'turnstile' ? `
${hidden}
` : `

${hidden}
`; return `Quick check

Quick check

To keep this site safe from bots, please confirm you're human. You'll only need to do this once.

${error ? `

${escHtml(error)}

` : ''}${body}

Protected by Fluint

`; } function serveChallenge(req, res, ctx, error) { counters.challenged++; res.writeHead(403, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-fluint': 'challenge' }); res.end(challengePage(ctx, safeReturn(req.method === 'GET' ? req.url : '/'), error)); } /** Resolves true if the visitor was shown/answered a challenge instead of the site. */ async function challengeGate(req, res, ctx) { const c = policy.challenge; if (c.allowSearchBots && await isVerifiedSearchBot(ctx.ip, ctx.ua)) return false; if (req.method === 'GET' && /text\/html|\*\/\*/.test(String(req.headers.accept || '')) && !/^(application\/json)/.test(String(req.headers.accept || ''))) { serveChallenge(req, res, ctx); } else { res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'x-fluint': 'challenge' }); res.end('Verification required - open this site in a web browser first.'); } return true; } function challengeSubmit(req, res, ctx) { let body = ''; req.setEncoding('utf8'); req.on('data', (d) => { body += d; if (body.length > 16384) req.destroy(); }); req.on('end', async () => { const f = new URLSearchParams(body); const token = f.get('t'); const c = policy.challenge; if (!c || !c.enabled) { res.writeHead(303, { location: safeReturn(f.get('r')) }); return res.end(); } if (!checkToken(token, ctx.ip)) return serveChallenge(req, res, ctx, 'That check expired - please try again.'); let ok = false; if (c.provider === 'turnstile') { try { const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body: new URLSearchParams({ secret: c.turnstileSecret, response: f.get('cf-turnstile-response') || '', remoteip: ctx.ip }), signal: AbortSignal.timeout(10000), }); ok = !!(await r.json()).success; } catch (e) { console.warn('[fluint] turnstile verify failed:', e.message); } } else { const n = f.get('n') || ''; ok = /^\d{1,12}$/.test(n) && sha256hex(token + n).startsWith('0'.repeat(c.difficulty)); } if (!ok) return serveChallenge(req, res, ctx, 'Verification failed - please try again.'); usedTokens.set(token, Date.now() + 20 * 60000); if (passes.size < 500000) passes.set(ctx.ip, { at: Date.now(), exp: Date.now() + c.passDays * 86400000 }); passesDirty = true; counters.passed++; res.writeHead(303, { location: safeReturn(f.get('r')), 'cache-control': 'no-store' }); res.end(); }); } const TEXTUAL = /^(application\/(x-www-form-urlencoded|json|xml|graphql|.*\+json)|text\/)/i; function handler(req, res) { counters.requests++; trackRps(); let pathname = '/'; try { pathname = new URL(req.url, 'http://x').pathname; } catch { /* keep / */ } const ctx = { fluint: isFluintScan(req), ip: clientIp(req), pathname, decoded: safeDecode(req.url || ''), ua: String(req.headers['user-agent'] || '').slice(0, 300) }; if (pathname === '/.well-known/fluint-verify.txt' && verifyToken) { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(verifyToken); } if (preChecks(req, res, ctx)) return; if (pathname === CHALLENGE_PATH && req.method === 'POST') return challengeSubmit(req, res, ctx); if (needsChallenge(ctx)) { challengeGate(req, res, ctx) .then((handled) => { if (!handled) proceed(req, res, ctx); }) .catch(() => proceed(req, res, ctx)); return; } proceed(req, res, ctx); } function proceed(req, res, ctx) { // Inspect small textual bodies (forms, JSON APIs). File uploads stream straight through. const len = Number(req.headers['content-length'] || 0); const type = String(req.headers['content-type'] || ''); const bodyRules = ['sqli', 'xss', 'cmdi', 'ssrf', 'traversal'].filter((t) => policy.waf[t] && policy.waf[t] !== 'off'); if (!ctx.trusted && !ctx.fluint && req.method !== 'GET' && req.method !== 'HEAD' && len > 0 && len <= 1_000_000 && TEXTUAL.test(type) && bodyRules.length) { const chunks = []; let size = 0; req.on('data', (d) => { size += d.length; if (size <= 1_000_000) chunks.push(d); }); req.on('end', () => { const buf = Buffer.concat(chunks); const text = safeDecode(buf.toString('utf8')); for (const t of bodyRules) if (wafHit(req, res, ctx, t, text, RULES[t])) return; proxy(req, res, ctx, buf); }); return; } proxy(req, res, ctx, null); } // WebSockets pass through after the same IP checks. function onUpgrade(req, socket, head) { const ip = clientIp(req); if (!compiled.allowed(ip) && (policy.lockdown || compiled.blocked(ip) || (bans.get(ip) || 0) > Date.now())) return socket.destroy(); let wsPath = '/'; try { wsPath = new URL(req.url, 'http://x').pathname; } catch { /* keep / */ } if (needsChallenge({ ip, pathname: wsPath, trusted: compiled.allowed(ip), fluint: false })) return socket.destroy(); const preq = upstreamMod.request({ hostname: CFG.upstream.hostname, port: CFG.upstream.port || (CFG.upstream.protocol === 'https:' ? 443 : 80), method: req.method, path: req.url, headers: { ...forwardHeaders(req, ip), connection: 'Upgrade', upgrade: req.headers.upgrade }, }); preq.on('upgrade', (pres, psock, phead) => { socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(pres.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n')}\r\n\r\n`); if (phead && phead.length) socket.write(phead); if (head && head.length) psock.write(head); psock.pipe(socket).pipe(psock); psock.on('error', () => socket.destroy()); socket.on('error', () => psock.destroy()); }); preq.on('response', (pres) => { socket.end(`HTTP/1.1 ${pres.statusCode} ${pres.statusMessage}\r\n\r\n`); }); preq.on('error', () => socket.destroy()); preq.end(); } // --- Start ----------------------------------------------------------------------------------------- const server = CFG.tlsCert && CFG.tlsKey ? https.createServer({ cert: fs.readFileSync(CFG.tlsCert), key: fs.readFileSync(CFG.tlsKey) }, handler) : http.createServer(handler); server.on('upgrade', onUpgrade); server.headersTimeout = 20000; // slowloris protection server.requestTimeout = 120000; server.keepAliveTimeout = 5000; (async () => { await pullPolicy(); setInterval(pullPolicy, 10000).unref(); setInterval(flush, 5000).unref(); server.listen(CFG.port, CFG.host, () => { console.log(`Fluint agent ${VERSION} protecting ${CFG.upstream.origin} on ${CFG.tlsCert ? 'https' : 'http'}://${CFG.host}:${CFG.port}`); console.log(`Reporting to ${CFG.server}${geoip ? ' (country blocking enabled)' : ''}`); }); })(); function saveNow() { passesDirty = true; const now = Date.now(); const obj = {}; for (const [ip, v] of passes) if (v.exp > now) obj[ip] = v; try { fs.writeFileSync(PASS_FILE, JSON.stringify(obj)); } catch { /* ignore */ } } process.on('SIGINT', async () => { saveNow(); await flush(); process.exit(0); }); process.on('SIGTERM', async () => { saveNow(); await flush(); process.exit(0); });