🛡️ sandbox-labs GitHub ↗

11 · Renderizado de documentos#

En una frase, para cualquiera: abrir un PDF que te mandaron es entregarle un fichero muy complicado a un programa muy antiguo. Ese programa lleva décadas siendo la forma favorita de entrar en los ordenadores ajenos.

Estado real: 🟡 building — hay código y 6 comprobaciones automáticas, sin levantarse bajo bwrap en CI · Carpeta: cases/11-document-render/ · Puerto: 8811


Por qué se realiza este caso#

Un PDF no es una hoja de papel: es un formato con lenguaje propio, tipografías embebidas, imágenes comprimidas de seis maneras distintas y, en muchos lectores, JavaScript. Los programas que lo interpretan están escritos en C por razones de rendimiento, y llevan treinta años acumulando esquinas.

Lo mismo vale para los documentos ofimáticos, las imágenes y las tipografías.

El fichero traeLo que puede provocar
Una imagen con dimensiones absurdasReserva de memoria descontrolada
Una tipografía manipuladaUn fallo de memoria en el intérprete de fuentes
Un objeto que se referencia a sí mismoUn bucle infinito en el parser
Una referencia a un fichero externoQue el lector abra algo tuyo
Metadatos con rutasEscritura fuera del destino previsto
Un documento de 4 KB que ocupa 4 GB al descomprimirAgotar la memoria

El documento no tiene que parecer malicioso. Basta con que el parser tenga un fallo, y los tiene.

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

Aislar el parser, no el fichero. En los demás casos lo desconocido es el código; aquí el código es tuyo —una biblioteca conocida y respetable— y lo desconocido son los datos. La conclusión incómoda es que hay que aislar tu propio software, porque el fallo va a estar ahí.

Casos de uso reales#

Cómo funcionará#

flowchart LR
  D["📄 Documento"] --> S["🧭 Coordinador"]
  S --> J
  subgraph J["🔒 Jaula desechable por documento"]
    P["📖 Parser<br/>sin red · sin disco del host<br/>memoria y CPU acotadas"]
  end
  J --> T["📝 Texto extraído"]
  J --> I["🖼️ Vista previa"]
  J --> R["🚫 Referencias externas<br/>no resueltas"]
flowchart TB
  A["Documento"] --> B{"¿Tipo MIME real<br/>coincide con la extensión?"}
  B -- no --> B1["📣 Se anota y se trata por el tipo REAL"]
  B -- sí --> C{"¿Supera los techos de<br/>tamaño, páginas o memoria?"}
  C -- sí --> C1["🚫 Rechazado"]
  C -- no --> D["📖 Parsear en jaula"]
  D --> E{"¿El parser murió?"}
  E -- sí --> E1["📣 Fallo contenido:<br/>el servicio sigue vivo"]
  E -- no --> F["✅ Texto y vista previa"]

Esquemas#

Entrada#

El documento en crudo, más los techos aplicables:

{ "maxBytes": 26214400, "maxPages": 500, "memoryLimitMb": 256, "timeoutSeconds": 30 }

Salida#

{
  "detectedType": "application/pdf",
  "declaredType": "image/png",
  "pages": 12,
  "text": "…",
  "previewPng": "base64…",
  "externalReferences": [
    { "target": "file:///etc/passwd", "outcome": "no resuelta: el parser no tiene disco" }
  ],
  "parserCrashed": false,
  "resources": { "peakMemoryMb": 84, "elapsedMs": 1240 }
}

dice ser una imagen y es un PDF ya merece atención antes de abrirlo.

Software necesario#

ComponentePara qué¿Obligatorio?
Rust 1.75+El coordinador y los límites
bubblewrap 0.6+La jaula por documento
systemd modo usuariomemory.max es el control aquíMuy recomendado
Una biblioteca de parseo (pdfium, poppler, libvips…)Interpretar el formato
Linux o WSL2Namespaces sin privilegios

Instalación#

sudo apt install bubblewrap poppler-utils libvips-tools
cargo build --release
cargo run -p sandboxctl -- doctor   # comprueba que hay cgroups para memory.max

Si doctor dice que no hay cgroups, este caso debería negarse a ejecutar con política estricta: sin techo de memoria, una imagen de dimensiones absurdas se lleva por delante el equipo, y prometer un control que no se aplica está prohibido por la regla central del proyecto.

Procesos que se crearán#

sandboxctl render <documento>
  │
  └─ systemd --user scope      ← memory.max: el control que importa
      └─ bwrap                 ← sin red, sin disco del host, sin dispositivos
          └─ el parser         ← uno por documento, desechable

Un parser por documento, y desechable. Si revienta con el documento número cuarenta, los treinta y nueve anteriores ya terminaron y el cuarenta y uno arranca limpio.

Tiempo de carga estimado#

OperaciónCoste esperado
Arranque de la jaula5–15 ms
Envoltura en cgroup30–80 ms
PDF de pocas páginas100–500 ms
Documento grandesegundos, con techo por política
Muerte por memory.maxinmediata al alcanzar el techo

Qué hace falta para construirlo#

  1. Detección de tipo MIME real, por contenido y no por extensión.
  2. Adaptador para al menos un parser de PDF y uno de imagen.
  3. Techos de memoria y CPU obligatorios bajo política estricta.
  4. Registro de referencias externas no resueltas.
  5. Un corpus de documentos sintéticos hostiles: bomba de descompresión,

referencia externa, tipografía manipulada, anidamiento profundo.

Si algo falla#

El caso ya tiene código: el núcleo en core.py y el servicio en app.py. Lo que sigue son sus fallos, la causa y la salida:

SituaciónCausaCómo se resuelve
El parser muere sin devolver nadaEl cgroup lo mató al alcanzar memory.maxEs contención. Si el documento es legítimo, subir memoryLimitMb. Sin cgroups este caso debe negarse a ejecutar con política estricta: una imagen de dimensiones absurdas se lleva el equipo
detectedType no coincide con declaredTypeEl fichero dice ser una cosa y es otraSe procesa por el tipo real, nunca por la extensión, y la discrepancia se anota. Es un dato útil por sí solo
El texto extraído sale vacíoEl documento es una imagen escaneada, o el parser no lo soporta1. Comprobar pages y parserCrashed. 2. Si hace falta OCR, es otro proceso y otro caso: no se mete dentro del parser
externalReferences lleno de entradas no resueltasEl documento pide ficheros o direcciones de fueraCorrecto: el parser no tiene disco ni red. Si alguna referencia es legítima, resolverla fuera y volver a entrar con el resultado
El renderizado tarda demasiadoDocumento grande o parser lentoSubir timeoutSeconds, o partir el documento por páginas: cada página en su propia jaula desechable

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.

Cómo se comprueba#

node scripts/verify-cases.mjs

Llama al núcleo del caso con situaciones concretas y comprueba qué hizo con ellas, no cómo está escrito. Son 6 comprobaciones, y corren en cada commit.

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

Levanta el caso como producto en 127.0.0.1:8811. POST /api/run acepta el cuerpo que describen los esquemas de arriba.

Sigue en building, no en functional. El núcleo se comprueba, pero el servicio no se levanta bajo bwrap dentro de CI y el caso no emite evidencia firmada. La regla completa está en el ROADMAP.

Ver también: Catálogo completo · Caso 01 · contenido no confiable · Caso 03 · archivos comprimidos