Cloudflare Workers AI: Postmortem de Seguridad — Un Runbook para Fugas de Caché KV entre Inquilinos
En resumen
Detecta y corrige fugas de caché KV entre inquilinos en Cloudflare Workers AI con este runbook de seguridad práctico para desarrolladores de juegos.
Una función de IA orientada al jugador devolvió una respuesta con el texto del prompt de otro estudio. No un crash. No un 500. Una respuesta coherente y pertinente entregada con HTTP 200 y un recuento de tokens que coincidía con la propia solicitud del llamante.
Esa es la peor forma que puede adoptar un bug multi-tenant: el resultado es plausible, así que nadie abre un ticket. Cloudflare se topó exactamente con esto el 27 de agosto de 2026, cuando 155 respuestas del modelo GLM-5.3 Workers AI contenían datos de sesiones de otros clientes. Todas las solicitudes afectadas superaban los 681.280 tokens. El arreglo requirió dos cambios — una configuración y un parche del motor — y la parte de detección es más instructiva que el arreglo.
Este es el runbook: qué falló, cómo detectar la misma clase de fallo en tu stack, cómo remediarlo y cómo evitar que se repita.
Qué falló realmente
Dos problemas independientes se superpusieron. Cualquiera de los dos por separado habría sido sobrevivible.
Problema uno: un desajuste de capacidad que nadie verificaba
Cloudflare desplegó GLM-5.3 con una ventana de contexto de 1.048.576 tokens. El pool de caché KV en las GPUs de prefill contenía 681.280 tokens.
Ese desfase importa porque el tamaño del pool no es un valor de configuración. SGLang lo dimensiona al arrancar a partir de la memoria de GPU que queda tras cargar los pesos del modelo. La longitud de contexto anunciada es un valor de configuración aparte. Nada obliga a que ambos coincidan y nada avisa cuando divergen.
Las pruebas previas al lanzamiento pasaron. Los códigos HTTP eran 200, los cuerpos de streaming no tenían tramas de error y el throughput parecía normal. Las pruebas validaban el transporte, no el contenido.
Problema dos: una solicitud rechazada que seguía reportándose viva
Cuando un prompt superaba el pool, el scheduler de SGLang lo rechazaba. La ruta de rechazo registraba el error para el cliente y sustituía el prompt por un placeholder de un token para que la solicitud pudiera drenar a través de la maquinaria de batch sin asignar memoria. En un solo nodo, eso funcionaba bien: el placeholder se ejecutaba, el batch terminaba y el cliente recibía un error.
En el modo prefill/decode disaggregado — donde las GPUs de prefill construyen la caché KV y la envían por RDMA a las GPUs de decode que generan tokens — la secuencia era esta:
- Bootstrap. El router le dijo al decode que el prompt tenía 8 páginas. Decode reservó 8 páginas KV.
- Admisión en prefill. La cola admitía cualquier solicitud donde
req.finished()devolviera False. Una solicitud rechazada guardaba su rechazo ento_finish, no enfinished_reason, así quefinished()seguía devolviendo False. La solicitud rechazada era admitida. - Forward pass de prefill. El prompt ya había sido sustituido por un placeholder de un token. Prefill ejecutaba un forward pass trivial sobre ese único token.
- Transferencia KV. Prefill enviaba una página de KV al decode y señalaba "transfer complete".
- Admisión en decode. La cola de decode usaba el mismo chequeo
finished()y aceptaba la transferencia. - Atención en decode. Decode generaba tokens atendiendo sobre las 8 páginas reservadas. Solo la página 0 había sido escrita por esta solicitud. Las páginas 1 a 7 aún contenían prompts y generaciones parciales de otros inquilinos.
- Respuesta. El cliente recibía HTTP 200, un recuento de
prompt_tokenscoincidente y una respuesta coherente ensamblada a partir de páginas de caché obsoletas.
Toda solicitud por encima del umbral de 681.280 tokens se comportaba así. El bug se había reportado upstream como issue #30233 el 6 de julio de 2026, y el despliegue afectado aún no había incorporado el arreglo.
Detéctalo antes de que lo hagan tus jugadores
No puedes probar el aislamiento con códigos de estado. Necesitas tres señales.
Señal 1: tamaño del pool frente a longitud de contexto, leído desde el motor en ejecución
Verifica esto al arrancar y en CI, no desde un archivo de configuración. El pool se deriva de la memoria sobrante, así que un cambio de quantización, un cambio de tensor parallelism o un ajuste del tamaño de los CUDA graphs lo mueven silenciosamente.
# deploy_gate.py - run before promoting any disaggregated inference config
import json, os, sys, urllib.request
CONTEXT_LEN = 1_048_576 # the model's advertised window
HEADROOM = 1.25 # pool must exceed context, not merely match it
def server_info(base_url: str) -> dict:
with urllib.request.urlopen(f"{base_url}/get_server_info", timeout=10) as r:
return json.load(r)
def kv_pool_tokens(info: dict) -> int:
# SGLang reports the pool it sized at startup; field names vary by version
for key in ("max_total_num_tokens", "kv_pool_tokens", "max_total_tokens"):
if key in info:
return int(info[key])
raise RuntimeError("KV pool size not exposed by this build - fail closed")
def main(prefill_url: str) -> None:
pool = kv_pool_tokens(server_info(prefill_url))
required = int(CONTEXT_LEN * HEADROOM)
print(f"kv_pool={pool} required={required} context={CONTEXT_LEN}")
if pool < required:
print("FAIL: pool smaller than context window; over-length prompts reach the rejection path")
sys.exit(1)
print("OK: pool covers the full context window")
if __name__ == "__main__":
main(os.environ.get("PREFILL_URL", "http://localhost:30000"))
Ejecútalo contra cada nodo de prefill antes de que el load balancer reciba tráfico. Una salida distinta de cero bloquea el despliegue.
Señal 2: marcadores canario en la caché KV
Planta una cadena centinela única en un prompt largo, deja que la solicitud termine y luego envía un segundo prompt largo que nunca debería contenerla. Revisa el texto devuelto, no el código de estado.
import uuid, requests
def canary_check(base_url: str, token: str, long_prompt_tokens: int) -> None:
sentinel = f"CANARY-{uuid.uuid4()}"
filler = "lorem ipsum " * (long_prompt_tokens // 2)
requests.post(base_url, json={
"prompt": f"{sentinel} {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
# A fresh, unrelated request must never surface the previous sentinel
r = requests.post(base_url, json={
"prompt": f"Summarize this: {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
assert sentinel not in r.text, f"KV cache leak: {sentinel} surfaced in a fresh response"
Ejecuta esto contra el prompt más largo que planeas aceptar, exactamente en el límite, y en límite + 1 token. El caso del límite es donde se dispara la ruta de rechazo.
Señal 3: latencia demasiado buena
Un prefill de longitud completa lleva un tiempo medible. Si un prompt de 8 páginas responde en el tiempo que debería tardar uno de 1 página, estás leyendo la caché de otro. Registra la duración del prefill indexada por longitud del prompt y alerta sobre el p5 del bucket de contexto largo. Un prompt largo anómalamente rápido es una firma de fuga, no una victoria.
El arreglo, en orden
1. Haz que el pool contenga todo el contexto
Cloudflare cambió la configuración de GLM-5.3 para que coincidiera con GLM-5.2, liberando suficiente memoria de GPU para que el pool de prefill ahora contenga aproximadamente 2,04 millones de tokens — cerca del doble del contexto de 1.048.576 tokens. Un prompt que cabe en la ventana de contexto ahora siempre cabe en el pool, así que la ruta de rechazo nunca se alcanza para una solicitud válida.
2. Haz que la ruta de rechazo sea segura de todos modos
Dimensionar el pool por sí solo es frágil. Cualquier cambio en la quantización, el tensor parallelism, los tamaños de CUDA graphs o la versión del modelo mueve el pool de una forma fácil de pasar por alto.
El parche del motor retira una solicitud con un rechazo pendiente en la cola de admisión del disaggregation, con su error original, antes de que ocurra cualquier bootstrap, asignación o transferencia KV. El nodo de decode también verifica que el número de tokens transferidos coincida con el que reservó. Eso aterrizó upstream como PR #38704, con seguimientos en #38935, y se distribuyó en SGLang v0.5.20 hasta v0.5.22.
Si ejecutas SGLang en cualquier lugar, fija la versión a v0.5.22 o posterior. Esta no es una actualización para «programarla en el próximo sprint».
3. Verifica con el repro, no con corazonadas
Cloudflare validó ambos cambios contra el script de reproducción del investigador y sus propias pruebas de marcadores canario antes de restaurar el modelo. Dos pruebas independientes, una adversarial y una interna. Ese es el estándar correcto antes de reactivar un modelo o una flota.
Por qué «Devolvió 200» no es una prueba
La falla raíz de las pruebas fue estrecha y completamente ordinaria: el equipo validó códigos de respuesta HTTP y rendimiento del motor con longitudes de contexto largas, sin validar la salida y coherencia del modelo. Las benchmarks pasaron porque preguntan «¿respondió?», no «¿respondió correctamente?».
Para una función de IA alojada, la verificación de corrección es barata. Para tu propio backend de juego, la brecha equivalente suele ser más amplia. Esta es la regla transferible: cualquier pool de recursos compartidos necesita una aserción que ate el tamaño del pool al tamaño de la solicitud más grande que el sistema aceptará, y esa aserción debe ejecutarse contra el proceso en vivo, no contra un archivo de configuración.
La misma clase de bug vive en los backends de juegos
Sustituye «páginas de caché KV» por «session slots» y el incidente se reproduce casi línea por línea.
Asignas un pool fijo de match slots, player state slots o actor buffers. Una solicitud reserva 8 slots. Un chequeo de admisión prueba state != Finished en lugar de state == Valid, así que una reserva rechazada se admite de todos modos. El slot se entrega al siguiente lobby con solo el slot 0 inicializado. Los slots 1 a 7 aún contienen los IDs de jugador, el inventario y los datos de posición del partido anterior.
Este es el mismo modo de fallo que cubrimos en arquitectura de backends de juegos para sobrevivir compromisos: la fuga no necesita ser un ataque sofisticado, solo necesita un modelo de propiedad que el código no aplica de verdad.
Aquí está el patrón con bug y el arreglo, en C#:
public sealed class SessionSlotPool
{
private readonly SessionSlot[] _slots;
private readonly Queue<int> _free = new();
// BUG: release without invalidation leaves stale state for the next tenant
public void ReleaseUnsafe(int[] indices)
{
foreach (var i in indices) _free.Enqueue(i);
}
// FIX: invalidate on release, stamp a generation, verify on read
public void Release(int[] indices)
{
foreach (var i in indices)
{
_slots[i].Reset(); // zero player list, inventory, match id
_slots[i].OwnerId = null;
_slots[i].Generation++; // stale handles now fail the check
_free.Enqueue(i);
}
}
public bool TryRead(int index, long expectedGeneration, out SessionSlot slot)
{
slot = _slots[index];
return slot.Generation == expectedGeneration && slot.OwnerId != null;
}
}
El contador de generación es la parte barata. Convierte «esperamos que el slot haya sido sobrescrito» en un rechazo duro y comprobable. Cualquier lectura contra un handle obsoleto falla en lugar de devolver silenciosamente los datos de otro jugador.
La checklist de aislamiento
- Verifica el tamaño del pool contra la longitud de contexto al arrancar, desde el proceso en ejecución. No desde configuración. Una salida distinta de cero bloquea el despliegue.
- Trata «rejected» como un estado terminal distinto de «finished». Aborta en la admisión, antes de cualquier asignación o transferencia, y devuelve el error original.
- Verifica los conteos de transferencia en ambos lados. Si decode reservó 8 páginas, debe confirmar que llegaron 8 páginas. Una transferencia de una página que reporta «complete» es exactamente el bug.
- Prueba la ruta de fallo con aserciones de contenido. Marcadores canario, cadenas centinela, prompts en límite + 1. Revisa el cuerpo de la respuesta, nunca el código de estado.
- Vuelve a ejecutar la compuerta en cada cambio de capacidad. La quantización, el tensor parallelism, el batch size y la versión del modelo mueven el pool. Trata cada uno como un cambio relevante para el aislamiento.
El punto 5 es el que los equipos se saltan. Cada cambio de capacidad es también un cambio de seguridad, porque el pool se deriva de la memoria sobrante en lugar de declararse. El mismo pensamiento de suma cero aplica a cualquier pool de recursos que dimensiones a mano — la matemática de trade-offs en el análisis de hibernación de servidores zero-waste es la misma aritmética.
Qué cuesta esto
El aislamiento no es gratis, y el trade-off es honesto.
Duplicar el pool KV de 681.280 a aproximadamente 2,04 millones de tokens consume memoria de GPU que de otro modo sostendría más solicitudes concurrentes. Menos solicitudes concurrentes significa un mayor costo por token generado. En una flota de prefill, ese headroom es capacidad que no puedes vender.
Hay una alternativa más barata: mantener el pool pequeño y rechazar los prompts que excedan la longitud con un HTTP 400 explícito en el edge, antes de que lleguen al scheduler. Eso preserva el throughput pero falla las solicitudes legítimas de contexto largo — que, para un modelo de codificación agéntico, son la mayor parte del producto.
Cloudflare eligió la corrección al costo de capacidad. Para una función orientada al jugador, eso casi siempre es lo correcto. La alternativa — una respuesta rápida, barata y plausible que contiene los datos de otro jugador — no es un trade-off que puedas defender en un postmortem.
Dónde recae realmente la carga del aislamiento
Todo sistema multi-tenant toma esta decisión: qué recursos compartidos agrupar y reutilizar, y quién es dueño de la invalidación. La respuesta para las páginas KV de GPU es «el motor de inferencia, mal, hasta v0.5.22».
Para el estado de jugador de tu juego, la respuesta suele ser «tu base de datos, y te toca a ti». Si prefieres no ser dueño de la semántica del pool en absoluto, los servicios de estado vinculados a la cuenta mueven la superficie: horizOn ofrece Cloud Save vinculado a la cuenta con lecturas y escrituras conscientes de revisión, donde el cliente resuelve conflictos en lugar de que tus servidores adivinen qué slot está obsoleto.
Eso no elimina el trabajo de aislamiento — nada lo hace. Lo reduce a los límites que realmente controlas.
Qué hacer esta semana
Audita cada pool compartido que poseas y hazte tres preguntas. ¿El tamaño del pool cubre de forma demostrable la solicitud más grande que el sistema acepta? ¿Una solicitud rechazada termina antes de asignar algo? ¿El lado receptor verifica que lo que llegó coincide con lo que reservó?
Si alguna respuesta es «asumimos que sí», tienes el mismo bug esperando una solicitud lo bastante larga. Empieza con el pool cuyo contenido sería más dañino en la respuesta de otro jugador.
Si estás listo para dejar de mantener ese pool para el estado de jugador, prueba horizOn gratis — autenticación, Cloud Save vinculado a la cuenta, leaderboards, remote config y crash reports, sin levantar una base de datos y un modelo de sesión desde cero. La documentación de la API muestra la superficie de integración en unos diez minutos.