Surtido — barrido anti-abuso / anti-LLMs (2026-09-06)
Deploy base: surtido-9nk9y63qu (Production, hace ~12m al auditar). Dominio: surtido.xyz en Cloudflare Free (proxied ON hacia Vercel), sin WAF custom, sin rate limit, HSTS off.
Superficie (asgi.py)
GET /, /hn*, /feed, /radio, /radio/en, /radio/audio/{mp3}, /salto?u=, /leer?url=, /leer/descargar, POST /subscribe, GET /health, GET /api/proponer, GET /api/cron, GET /aprobar/{token}/{n}. Rewrites /vivo* → http://[IP] (IP directa, HTTP).
Hallazgos (ordenados)
- CRÍTICO —
/api/crony/api/proponerpúblicos:if secreto and ...→ sin CRON_SECRET en .env-prod no hay auth. Dispara feed+nlp+portada (RapidAPI)+broadcast Resend+Supabase. Cualquiera quema cuota y manda correos. - CRÍTICO —
/leer?url== fetch server-side arbitrario: solo chequea string del host (bloquea 127./10./192.168./172.16-31 literales). NO bloquea [IP], 0x/decimal/octal, ni dominio que resuelva a IP privada (DNS rebinding). Cada hit = fetch 25s + DeepL 60s + LLM + Supabase write, con maxDuration 300s. Un bot con lista de URLs te funde Vercel Functions + DeepL + Supabase. - ALTO —
/salto?u=open redirect 302 directo (desde último deploy ya sin intersticial):surtido.xyz/salto?u=https://evilsirve para phishing. Además cada hit = 1 INSERT ensalidas→ amplificación a Supabase sin rate limit. - ALTO —
/subscribesin rate limit ni Turnstile: honeypot solo. Cada POST = Resend API + INSERT. Bombardeo de emails + costo. - ALTO — Sin defensa anti-LLMs: no hay robots.txt, ni llms.txt, ni AI Crawl Control, ni Bot Fight Mode, ni WAF custom (solo Managed Free + DDoS L7). Crawlers agresivos (GPTBot/ClaudeBot/CCBot/Bytespider) consumen ancho de banda Vercel directo.
- MEDIO —
/aprobar/{token}/{n}con token en tablaartefactosde lectura pública (using (true)). Si la anon key se expone, cualquiera aprueba ediciones. - MEDIO — IP de origen expuesta:
/vivo*a[IP]por HTTP. Atacante bypasea Cloudflare y pega directo al Linode (radio = tráfico pesado). - MEDIO — Headers: solo
Referrer-Policy: no-referrer. Faltan HSTS (off en CF), X-Content-Type-Options, X-Frame-Options, CSP. Sin caché Cloudflare para/,/feed,/radio. - BAJO — favicon por
google.com/s2/faviconspor fila (dependencia + leak de referrer a Google), prefetch de tabs multiplica hits de bots.
Qué hacer (mínimo viable, en este orden)
CRON_SECRET=$(openssl rand -hex 32)→ Vercel env Production + cambiarif secreto andpor exigir siempre auth en /api/*.- Endurecer
/leer: allowlist de hosts de medios del feed + límite tamaño (ej. 2MB), timeout corto, 1 redirect, bloquear tras resolver DNS (ipaddress.is_private/is_reserved/is_multicast + [IP]/::/0), rate limit por IP en Cloudflare (ej. 10 req/min a /leer*), y exigir caché (si no hay caché, encolar, no traducir en request). /salto: volver a intersticial o allowlist +rel=noreferrer; quitar el INSERT síncrono (cola/batch) + rate limit./subscribe: Turnstile + rate limit 5/h por IP + doble opt-in.- Cloudflare (Free, dashboard surtido.xyz): AI Crawl Control → Block AI crawlers; Caching → cachear
/,/feed,/radio(bypass en /api/, /salto, /leer, /subscribe); WAF custom: (a) bloquear /api/ sin Bearer, (b) rate limit /leer|/salto|/subscribe, (c) challenge a ASNs/bots con?url=fuzz; activar HSTS. - Radio: no exponer IP en vercel.json público; poner Tunnel o hostname tras Cloudflare + HTTPS; rate limit al stream.
- Mover
propuesta_edicion.tokenfuera deartefactospúblicos (tabla privada) + expiración <24h. - Agregar
GET /robots.txten asgi.py (disallow /api/, /leer, /salto, /aprobar + lista de AI bots) y headers de seguridad vía middleware o Transform Rules.
Verificación pendiente
- Fijar CRON_SECRET y re-deploy; probar
curl /api/cronsin auth → 401. - Probar
/leer?url=http://[IP]/→ debe dar 4xx sin fetch. - Revisar Cloudflare Analytics + Vercel Usage tras activar reglas (7 días).
RESUELTO — deployado en producción 2026-09-06 (rama main, vercel —prod)
Verificado live en www.surtido.xyz:
- /api/cron y /api/proponer: 401 sin Bearer (CRON_SECRET en Vercel; cron de Vercel lo manda auto).
- /leer: SSRF bloqueado (169.254 etc.), fetch 10s + 2MB + content-type, tope 12k chars de traducción (anti-quema DeepL), cache mala se auto-sanea y no se re-guarda.
- /salto: 302 + X-Robots-Tag noindex + no-store.
- /subscribe: rate limit 5/h por IP en memoria (+ Turnstile listo para cuando exista TURNSTILE_SECRET).
- /aprobar: token expira en 24h.
- Headers: nosniff, X-Frame-Options DENY, Permissions-Policy, HSTS (Vercel).
- DeepL: auth por header DeepL-Auth-Key (la vieja por body devolvía 403 silencioso).
- _guardar() tolera FS read-only de Vercel (roto silencioso del título en /leer).
- chatarra: umbral proporcional CJK — DeepL conserva marcas (量子位→QuantumBit OK).
- Home: fuera 💬 fabricado (score//7); columna top estilo Reddit con score editorial.
Auditoría externa (eXTReMe Scraping API, location=usa, 2026-09-06)
Score 9783/10000. Página render completa, 552 palabras, hrefs sin /api ni tokens.
- 26 requests: solo surtido.xyz + Matomo (1 pixel + _pk_id cookie ANTES de consent — único punto de “compliance” pendiente; audience MX, low priority) + Google favicons s2→gstatic (13 requests, 3x 400 por favicons inexistentes — candidatos a self-host o
<link rel=icon>local). - 0 cookies third-party, 0 ads/marketing.
- El scraper headless obtuvo cf_clearance sin desafío y NUNCA pidió /robots.txt: confirma que AI Crawl Control solo frena bots VERIFICADOS por UA; un browser automatizado genético pasa. Con Free + BFM off, la mitigación real es el hardening del origen (ya deployado) + Turnstile en /subscribe.
- La API key usada quedó expuesta en el chat — rotarla en extreme-scraping-api.com.
Aislamiento Docker del Google server-side (2026-09-07)
docker/gbridge/: sidecar con el unico codigo autorizado a hablar con Google
(/gnews passthrough RSS, /yt/channel con key del entorno, sin argumentos libres).
Pipeline aislada: docker/gbridge/run.sh pulso.py --html — corre con
GBRIDGE_URL=http://gbridge:8799 y extra_hosts blackholeando .google.* a
[IP] (code que retroceda y llame Google directo truena, verificado).
Client switching: sentimiento.collect_gnews y feed._avatar_channel prefieren
GBRIDGE_URL si existe; sin GBRIDGE_URL comportamiento historico (host normal).
Nota: YOUTUBE_API_KEY no existe en .env.local hoy → /yt/channel da 503 honesto
y el avatar degrada a og:image (url yt3.googleusercontent = CDN Google, hoy
dormida: nadie renderiza avatares; si algn da se renderizan, cambiar a monograma).
Vercel/Cloudflare: sin cambios (all no hay Docker; /api/cron no usa gnews;
el worker yt usa su propio secret, invisible al lector).