Librería con listas, recomendaciones, descarga anticipada, app instalable con lectura offline y seguridad para exponerlo a internet

- Historial pasa a Librería: pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y listas propias (Favoritos, Pendientes, Abandonados por defecto)
- Recomendaciones en el inicio según tus géneros más leídos (populares de MangaDex)
- Descarga anticipada de los N capítulos siguientes (opcionalmente en HD) al pasar la mitad de uno
- PWA: manifest, iconos y service worker; capítulos guardados en el dispositivo para leer sin conexión, progreso offline sincronizado después
- Seguridad: baneo de IP tras 6 intentos fallidos (configurable), IP real detrás de proxies de confianza, CSP y cabeceras, cookie Secure/HSTS en HTTPS, login sin diferencias de tiempo, registro cerrado por defecto, contraseñas de 8+, protección SSRF en el proxy de imágenes, errores 500 genéricos
- Ajustes: sección Seguridad con IPs bloqueadas, intentos recientes y la IP vista por el servidor
- LeerCapitulo: reintentos ante caídas (522/5xx), copia anterior si sigue caído y mensaje claro

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 07:52:13 -06:00
co-authored by Claude Opus 5.5
parent 9086896a89
commit d3e196a2f3
16 changed files with 931 additions and 59 deletions
+147
View File
@@ -0,0 +1,147 @@
// 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()
}
}