// Seguridad para exponer el servidor a internet: IP real del cliente (detrás de un proxy de // confianza), baneo de IP tras varios intentos fallidos, cabeceras de seguridad y protección SSRF // del proxy de imágenes. import net from 'node:net' import dns from 'node:dns/promises' const MAX_FAILS = Number(process.env.BAN_MAX_FAILS || 6) // intentos fallidos… const FAIL_WINDOW = Number(process.env.BAN_WINDOW_MIN || 60) * 60e3 // …dentro de esta ventana const BAN_MS = Number(process.env.BAN_HOURS || 24) * 3600e3 // duración del baneo // Cabecera con la IP real que pone el proxy (p. ej. x-forwarded-for, cf-connecting-ip, x-real-ip). // Solo se cree si la conexión viene de una IP privada (el proxy), nunca directamente de internet. const REAL_IP_HEADER = (process.env.REAL_IP_HEADER || '').toLowerCase().trim() const PRIVATE_V4 = [['10.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16], ['100.64.0.0', 10], ['0.0.0.0', 8]] const v4num = ip => ip.split('.').reduce((n, x) => n * 256 + Number(x), 0) export function isPrivateIp(ip) { if (!ip) return true ip = ip.replace(/^::ffff:/i, '') if (net.isIPv4(ip)) { const n = v4num(ip) return PRIVATE_V4.some(([base, bits]) => Math.floor(n / 2 ** (32 - bits)) === Math.floor(v4num(base) / 2 ** (32 - bits))) } const l = ip.toLowerCase() return l === '::1' || l === '::' || /^f[cd]/.test(l) || /^fe[89ab]/.test(l) } // IPs que nunca se banean: loopback, red local, Tailscale y lo que se añada en BAN_IGNORE. const IGNORE = (process.env.BAN_IGNORE || '').split(',').map(s => s.trim()).filter(Boolean) const neverBan = ip => isPrivateIp(ip) || IGNORE.includes(ip) export function setupSecurity({ db }) { db.exec(` CREATE TABLE IF NOT EXISTS ip_bans ( ip TEXT PRIMARY KEY, until INTEGER NOT NULL, reason TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS ip_fails ( ip TEXT NOT NULL, at INTEGER NOT NULL, what TEXT ); CREATE INDEX IF NOT EXISTS idx_ip_fails ON ip_fails(ip, at); `) const prune = () => { db.prepare('DELETE FROM ip_bans WHERE until < ?').run(Date.now()) db.prepare('DELETE FROM ip_fails WHERE at < ?').run(Date.now() - Math.max(FAIL_WINDOW, 24 * 3600e3)) } prune() setInterval(prune, 3600e3).unref() function clientIp(req) { const direct = (req.socket.remoteAddress || '').replace(/^::ffff:/i, '') if (REAL_IP_HEADER && isPrivateIp(direct)) { // x-forwarded-for: "cliente, proxy1, proxy2". Cada proxy añade al final la IP que le habló, así // que se recorre desde la derecha saltando los proxies propios (IPs privadas: VPS por Tailscale, // Nginx Proxy Manager en Docker…). La primera pública es el cliente real; lo que haya más a la // izquierda lo pudo escribir el propio cliente y no se cree. const chain = String(req.headers[REAL_IP_HEADER] || '').split(',').map(s => s.trim().replace(/^::ffff:/i, '')).filter(s => net.isIP(s)) const ip = chain.findLast(s => !isPrivateIp(s)) ?? chain[0] if (ip) return ip } return direct } // HTTPS real (directo o por el proxy de confianza): cookie Secure y HSTS. const isHttps = req => !!req.socket.encrypted || (isPrivateIp(req.socket.remoteAddress) && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https') const banned = ip => db.prepare('SELECT until FROM ip_bans WHERE ip = ? AND until > ?').get(ip, Date.now()) // Intento fallido (contraseña incorrecta, registro cerrado…). Al llegar a MAX_FAILS, baneo. function fail(ip, what) { if (neverBan(ip)) return const now = Date.now() db.prepare('INSERT INTO ip_fails (ip, at, what) VALUES (?, ?, ?)').run(ip, now, what) const n = db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, now - FAIL_WINDOW).n if (n >= MAX_FAILS) { db.prepare(`INSERT INTO ip_bans (ip, until, reason, created_at) VALUES (?, ?, ?, ?) ON CONFLICT (ip) DO UPDATE SET until = excluded.until, reason = excluded.reason, created_at = excluded.created_at`) .run(ip, now + BAN_MS, `${n} intentos fallidos (${what})`, now) db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip) console.warn(`Seguridad: IP ${ip} baneada ${BAN_MS / 3600e3} h tras ${n} intentos fallidos (${what})`) } } const success = ip => db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip) const remaining = ip => (neverBan(ip) ? null : Math.max(0, MAX_FAILS - db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, Date.now() - FAIL_WINDOW).n)) // Cabeceras para todas las respuestas. function headers(req, res) { res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('X-Frame-Options', 'DENY') res.setHeader('Referrer-Policy', 'same-origin') res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()') res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') res.setHeader('Content-Security-Policy', [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "connect-src 'self'", "worker-src 'self'", "manifest-src 'self'", "font-src 'self'", "object-src 'none'", "base-uri 'none'", "form-action 'self'", "frame-ancestors 'none'", ].join('; ')) if (isHttps(req)) res.setHeader('Strict-Transport-Security', 'max-age=15552000') } function route(p, method, req, res, json) { if (p === '/api/bans' && method === 'GET') { return json(res, 200, { bans: db.prepare('SELECT ip, until, reason, created_at FROM ip_bans WHERE until > ? ORDER BY created_at DESC').all(Date.now()), fails: db.prepare('SELECT ip, COUNT(*) n, MAX(at) last FROM ip_fails WHERE at > ? GROUP BY ip ORDER BY last DESC LIMIT 50').all(Date.now() - FAIL_WINDOW), maxFails: MAX_FAILS, banHours: BAN_MS / 3600e3, windowMin: FAIL_WINDOW / 60e3, trustedHeader: REAL_IP_HEADER || null, you: { ip: clientIp(req), protected: neverBan(clientIp(req)) }, }), true } const m = /^\/api\/bans\/([^/]+)$/.exec(p) if (m && method === 'DELETE') { const ip = decodeURIComponent(m[1]) db.prepare('DELETE FROM ip_bans WHERE ip = ?').run(ip) db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip) return json(res, 200, { ok: true }), true } return false } return { clientIp, isHttps, banned, fail, success, remaining, headers, route } } // ---------- SSRF: el proxy de imágenes no debe alcanzar la red interna ---------- const dnsCache = new Map() async function hostIsPublic(hostname) { if (net.isIP(hostname)) return !isPrivateIp(hostname) if (/^(localhost|.*\.local|.*\.internal|.*\.lan)$/i.test(hostname)) return false const hit = dnsCache.get(hostname) if (hit && hit.exp > Date.now()) return hit.ok const addrs = await dns.lookup(hostname, { all: true }).catch(() => []) const ok = addrs.length > 0 && addrs.every(a => !isPrivateIp(a.address)) dnsCache.set(hostname, { ok, exp: Date.now() + 5 * 60e3 }) if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value) return ok } // fetch que solo va a hosts públicos y comprueba también cada redirección. export async function safeFetch(url, opts = {}, redirects = 4) { for (let i = 0; ; i++) { const u = new URL(url) if (!/^https?:$/.test(u.protocol) || !(await hostIsPublic(u.hostname))) throw new Error('Host no permitido') const r = await fetch(u, { ...opts, redirect: 'manual' }) const loc = r.status >= 300 && r.status < 400 && r.headers.get('location') if (!loc) return r if (i >= redirects) throw new Error('Demasiadas redirecciones') url = new URL(loc, u).toString() } }