149 lines
7.7 KiB
JavaScript
149 lines
7.7 KiB
JavaScript
// 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(() => [])
|
|
if (!addrs.length) return false // fallo de DNS: puede ser pasajero, no se recuerda
|
|
const ok = 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()
|
|
}
|
|
}
|