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)

  1. CRÍTICO — /api/cron y /api/proponer pú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.
  2. 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.
  3. ALTO — /salto?u= open redirect 302 directo (desde último deploy ya sin intersticial): surtido.xyz/salto?u=https://evil sirve para phishing. Además cada hit = 1 INSERT en salidas → amplificación a Supabase sin rate limit.
  4. ALTO — /subscribe sin rate limit ni Turnstile: honeypot solo. Cada POST = Resend API + INSERT. Bombardeo de emails + costo.
  5. 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.
  6. MEDIO — /aprobar/{token}/{n} con token en tabla artefactos de lectura pública (using (true)). Si la anon key se expone, cualquiera aprueba ediciones.
  7. MEDIO — IP de origen expuesta: /vivo* a [IP] por HTTP. Atacante bypasea Cloudflare y pega directo al Linode (radio = tráfico pesado).
  8. 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.
  9. BAJO — favicon por google.com/s2/favicons por fila (dependencia + leak de referrer a Google), prefetch de tabs multiplica hits de bots.

Qué hacer (mínimo viable, en este orden)

  1. CRON_SECRET=$(openssl rand -hex 32) → Vercel env Production + cambiar if secreto and por exigir siempre auth en /api/*.
  2. 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).
  3. /salto: volver a intersticial o allowlist + rel=noreferrer; quitar el INSERT síncrono (cola/batch) + rate limit.
  4. /subscribe: Turnstile + rate limit 5/h por IP + doble opt-in.
  5. 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.
  6. Radio: no exponer IP en vercel.json público; poner Tunnel o hostname tras Cloudflare + HTTPS; rate limit al stream.
  7. Mover propuesta_edicion.token fuera de artefactos públicos (tabla privada) + expiración <24h.
  8. Agregar GET /robots.txt en 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/cron sin 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).