🛡️ sandbox-labs GitHub ↗

01 · Contenido web no confiable#

En una frase, para cualquiera: cuando tu programa muestra un texto que escribió un desconocido, ese texto puede pedirle cosas a tu computador. Este caso pone a quien lee ese texto en una habitación sin puertas.

Estado real: 🟡 building · Carpeta: cases/01-untrusted-render/ · Puerto: 8801


Por qué se realiza este caso#

Imagina que recibes una carta y, para leerla, tienes que hacer todo lo que la carta diga mientras la lees. Eso es lo que hace un programa cuando interpreta contenido ajeno: leerlo es ejecutar las instrucciones de quien lo escribió.

La parte que sorprende es que no hace falta que la carta traiga un programa. Basta con que quien la lee tenga acceso a algo. Tres ejemplos, los tres reales:

Lo que trae el contenidoLo que consigueNombre técnico
Una «entidad externa» declarada al principio del documentoQue el lector abra /etc/passwd y lo devuelva dentro de la respuestaXXE
Una imagen apuntando a http://169.254.169.254/Que el servidor pida a la nube sus propias credenciales y te las entregueSSRF
Un enlace a file:///home/tu-usuario/.ssh/id_rsaQue el lector lea tu clave privadaTravesía de rutas

Ninguno de los tres ejecuta JavaScript. Los tres funcionan solo porque el lector tenía permiso para abrir ficheros o para conectarse a algún sitio.

De ahí la respuesta de este caso: quitarle esos permisos al lector. No vigilarlos, no filtrarlos: quitarlos.

La idea que enseña, y que ningún otro caso enseña#

Separar por proceso. Hay dos programas, no uno:

informe por su salida, y no tiene ninguna otra forma de comunicarse con el mundo.

La diferencia con «filtrar el contenido» es la que hay entre cerrar una puerta y poner un cartel de prohibido el paso. Cuando el contenido pide leer un fichero, aquí no ocurre un «permiso denegado»: es que no existe la función que abriría el fichero. El intento se anota y se sigue.

Y hay un segundo efecto, menos obvio y igual de importante: si el intérprete falla —porque un contenido raro lo hace reventar—, el que revienta es el intérprete, no el servicio. El coordinador recoge el fallo y responde.

Casos de uso reales#

Todos comparten la misma forma: texto que llega de fuera, y un programa tuyo que lo interpreta.

Cómo funciona#

flowchart LR
  U["👤 Alguien pega<br/>contenido ajeno"] --> C
  subgraph J["🔒 Jaula (bubblewrap · sin red)"]
    C["🧭 Coordinador<br/>app.py<br/>conoce el disco"]
    I["🧪 Intérprete<br/>interpreter.py<br/>SIN disco · SIN red"]
    C -- "texto por stdin" --> I
    I -- "JSON por stdout" --> C
  end
  C --> R["📄 Vista segura"]
  C --> L["🚫 Lista de lo que<br/>el contenido intentó hacer"]

El paso que importa es la flecha del medio. Entre el coordinador y el intérprete solo pasan dos tuberías de texto. No hay memoria compartida, no hay ficheros comunes, no hay sockets. Esa estrechez es el control.

Qué hace el intérprete con lo que recibe#

flowchart TB
  A["Contenido"] --> B{"¿Trae una<br/>entidad externa?"}
  B -- sí --> B1["🚫 XXE anotado<br/>y el DOCTYPE se retira entero"]
  B -- no --> C{"¿La etiqueta está<br/>en la lista de permitidas?"}
  C -- no --> C1["🚫 Se descarta"]
  C -- sí --> D{"¿El atributo está<br/>permitido para ella?"}
  D -- no --> D1["🚫 Se descarta<br/>(aquí caen los on*)"]
  D -- sí --> E{"¿Es una URL?"}
  E -- no --> F["✅ Se conserva"]
  E -- sí --> G{"¿Esquema y destino<br/>aceptables?"}
  G -- no --> G1["🚫 javascript: · data: · file: · metadatos"]
  G -- sí --> F

Es una lista de permitidos, no de prohibidos. La diferencia importa: una lista de prohibidos hay que actualizarla cada vez que alguien inventa una etiqueta nueva; una lista de permitidos deja fuera lo que se invente mañana sin tocar nada.

Esquemas#

Entrada — POST /api/render#

{ "content": "<p>el HTML o Markdown que llegó de fuera</p>" }
CampoTipoObligatorioLímite
contenttexto256 KB en el coordinador; 200 000 caracteres en el intérprete

Salida#

{
  "ok": true,
  "elapsedMs": 41,
  "capabilities": {
    "filesystem": false, "network": false, "subprocess": false,
    "clock": false, "environment": false
  },
  "safeHtml": "<p>Hola </p><a>mira</a>",
  "rejections": [
    {
      "kind": "entidad-externa",
      "detail": "<!ENTITY x SYSTEM \"file:///etc/passwd\">",
      "why": "XXE: una entidad externa haría que el parser leyese un fichero por ti"
    }
  ],
  "rejectionsByKind": { "entidad-externa": 1, "ssrf": 1 },
  "stats": { "inputBytes": 335, "nodes": 19, "maxDepth": 2, "inputTruncated": false, "outputTruncated": false }
}
CampoQué es
capabilitiesLas capacidades del intérprete. Todas en false, siempre. Si alguna apareciera en true, el caso estaría roto
safeHtmlLo que sobrevivió a la interpretación
rejectionsEl producto de verdad de este caso: qué pidió el contenido y por qué no se le dio
rejectionsByKindEl mismo dato contado, para poder vigilarlo
statsCuánto trabajo costó, para detectar contenidos que buscan agotar el proceso

Los tipos de rechazo#

kindCuándo aparece
entidad-externaXXE: una entidad SYSTEM o PUBLIC en el DOCTYPE
etiqueta-descartadascript, style, iframe, object, embed, link, meta, base
etiqueta-no-permitidaCualquier etiqueta fuera de la lista de permitidas
manejador-de-eventoUn atributo on*: código escondido en un atributo
atributo-no-permitidoUn atributo que esa etiqueta no admite
script-en-urljavascript: o vbscript: en un href o src
data-uridata: — trae su propio contenido y se salta el origen
acceso-a-ficherofile://, /etc/… o ..
ssrfDirecciones de metadatos de nube, localhost, 127.0.0.1
esquema-desconocidoCualquier otro esquema de URL
red-no-concedidaUna referencia http(s) legítima que queda sin resolver, porque no hay red
enlace-markdownUn enlace Markdown con esquema hostil
comentario-con-marcadoUn comentario HTML que esconde etiquetas
anidamientoMás de 100 niveles: revienta parsers recursivos
presupuestoMás de 20 000 nodos: un documento sin fin es una denegación de servicio

Software necesario#

ComponenteVersiónPara qué¿Obligatorio?
Python3.11+El coordinador y el intérprete. Sin dependencias externas: solo biblioteca estándar
Rust1.75+sandboxctl, el supervisor que levanta el casoSolo para levantarlo como servicio
bubblewrap0.6+La jaula que aplica los controlesSolo para el servicio con aislamiento
Linux o WSL2kernel 5.10+Namespaces de usuario sin privilegiosSolo para el servicio
Node.js20+La prueba de comportamiento y el panelSolo para comprobarlo

El intérprete por sí solo funciona en cualquier sistema con Python, incluido Windows: no necesita jaula porque no usa nada que haya que enjaular.

Instalación#

git clone https://github.com/vladimiracunadev-create/sandbox-labs
cd sandbox-labs
sudo apt install bubblewrap util-linux python3
cargo build --release
cargo run -p sandboxctl -- doctor

completos, incluida la configuración de WSL2, están en Instalación.

Cómo se ejecuta#

El caso completo, como producto en su propio localhost:

cargo run -p sandboxctl -- service up untrusted-render

Solo el intérprete, sin levantar nada:

python3 cases/01-untrusted-render/interpreter.py < contenido.html

Para bajarlo:

cargo run -p sandboxctl -- service down untrusted-render

Procesos que se crean#

sandboxctl service up untrusted-render
  │
  ├─ systemd --user scope              ← cgroup: memory.max, pids.max, cpu.max
  │   └─ bwrap                         ← namespaces, montajes, seccomp, sin red
  │       └─ python3 app.py            ← el coordinador, escucha en socket Unix
  │           └─ python3 interpreter.py ← uno por petición, nace y muere con ella
  │
  └─ sandboxctl service forward        ← puente TCP :8801 ↔ socket Unix

Dos detalles que explican la forma:

es el puente —que vive fuera— quien publica 127.0.0.1:8801. Si matas el puente, el servicio queda inalcanzable pero sigue contenido, que es el fallo correcto.

responder. Si tarda más de 5 segundos, se le corta.

Tiempo de carga#

Medido en WSL2 (Ubuntu 24.04, bubblewrap 0.9.0) sobre un portátil corriente:

OperaciónCoste típico
service up hasta que /health responde0,5–2 s
Arranque de la jaula bwrap5–15 ms
Envoltura en cgroup (systemd-run)30–80 ms
Una interpretación de un documento normal15–60 ms
Una interpretación de un documento de 200 KB150–400 ms
Corte por tiempo del intérprete5 s (techo fijo)

Casi todo el tiempo de arranque es el intérprete de Python encendiéndose, no el aislamiento: la jaula cuesta milisegundos.

Cómo se comprueba que funciona#

node scripts/verify-cases.mjs

Le da al intérprete nueve entradas hostiles concretas y comprueba dos cosas por cada una: que el rechazo esperado aparece en el informe, y que el fragmento peligroso no sobrevive en la salida.

EntradaDebe rechazarNo puede sobrevivir
Entidad externa en el DOCTYPEentidad-externa/etc/passwd
<script>fetch(…cookie)</script>etiqueta-descartadafetch, document.cookie, <script
<img src="http://169.254.169.254/…">ssrfla dirección
<img onerror="alert(1)">manejador-de-eventoonerror, alert
<a href="file:///…/id_rsa">acceso-a-ficheroid_rsa
<a href="javascript:alert(1)">script-en-urljavascript:
<img src="data:text/html;base64,…">data-uridata:text/html
Un enlace Markdown con esquema javascript:enlace-markdown
30 000 párrafos seguidospresupuesto

Y una décima comprobación: que el intérprete no declara ninguna capacidad concedida.

Estado real y qué falta#

Construido: el coordinador, el intérprete, la política de capacidades vacía, los quince tipos de rechazo y la prueba de comportamiento con diez comprobaciones.

Falta para llegar a functional:

no solo el intérprete por separado.

Falta para llegar a verified: que cada interpretación emita evidencia firmada con los controles solicitados, aplicados y observados.

Si algo falla#

SíntomaCausaCómo se soluciona
el intérprete no terminó en 5s y se le cortóEl contenido hace que el parser tarde mucho. Se llama ReDoS1. Mirar stats.nodes y stats.maxDepth en la respuesta: dicen si el documento es desproporcionado. 2. Subir INTERPRETER_TIMEOUT en app.py si el contenido legítimo es grande. 3. Bajar MAX_TEXT para cortar antes
el intérprete terminó con código NEl parser reventó con ese contenidoEl servicio sigue en pie —para eso son dos procesos—. Reproducirlo con python3 cases/01-untrusted-render/interpreter.py < fichero.html y leer el stderr completo, que la respuesta trunca a 500 caracteres
contenido de más de 262144 bytesLa entrada supera el techo del coordinador1. Trocear el contenido y interpretarlo por partes. 2. Subir MAX_CONTENT en app.py, sabiendo que un techo alto convierte el servicio en un blanco fácil
Aparecen muchos red-no-concedidaEl contenido trae enlaces http(s) que no se resuelven, porque el intérprete no tiene redSi necesitas resolverlos, hazlo fuera del intérprete y con lista de permitidos —el proxy de salida ya existe— y vuelve a entrar solo con el resultado
python3: command not foundEn algunos sistemas el binario se llama pythonPYTHON=python node scripts/verify-cases.mjs, y en app.py el intérprete se lanza con sys.executable, que ya usa el correcto
Una comprobación de verify-cases.mjs falla tras tocar el intérpreteUna regla dejó de detectar lo que declarabaEl mensaje dice qué entrada, qué rechazo esperaba y qué obtuvo. Arreglar la regla en interpreter.py. Relajar la prueba para que pase deja el caso mintiendo
Sale HTML que esperabas conservarLa etiqueta no está en ALLOWED_TAGS, o el atributo no está en ALLOWED_ATTRSAñadirla a la lista de permitidos y añadir una entrada en verify-cases.mjs que fije qué no debe pasar con ella

Los fallos que afectan a cualquier caso —no se puede crear el sandbox, no hay cgroups, un puerto ocupado, procesos huérfanos, la compilación en Windows— están resueltos uno a uno en Cuando algo falla.


Ver también: Catálogo completo · Estado del proyecto · Qué es un sandbox · Glosario