<!-- Canonical: https://lucioduran.com/es/blog/capability-based-security-wasi-preview2
Language: es
Content-SHA256: da982e9cda9006030e5e1a6ddf803ad3c273c542cc743a90dfeaebeada5e9b16
-->

Security

# Seguridad Basada en Capabilities con WASI Preview 2: Sistemas de Plugins con Least-Privilege a Nivel de Runtime

2025-11-258 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/capability-based-security-wasi-preview2>)

## El Modelo de Capabilities: De ACLs a Object Capabilities

Los sistemas operativos tradicionales usan Access Control Lists. Un proceso tiene un user ID, y el kernel chequea ese user ID contra los permise es del archivo. El problema es la autoridad ambiente, un proceso puede intentar acceder a cualquier recurso, y el kernel decide sí o no después del hecho. Esto significa que un proceso comprometido hereda todos los permise es de su usuario.

La seguridad basada en capabilities invierte esto. En vez de preguntar "¿quién se es y qué teners permitido hacer?", el runtime dice "acá teners handles a exactamente los recurse es que es posible usar, y nada más existe". Si no teners un handle al filesystem, ni siquiera es posible intentar abrir un archivo. No hay un ` open("/etc/passwd") ` que retorne "permission denied", la función ` open ` simplemente no existe en tu universo.

WASI Preview 2 implementa esto a través del Component Model y WIT (WebAssembly Interface Types):

```wit
// Esta es una definición de mundo WIT, define qué puede hacer un plugin
world data-transformer {
 // Lo que el plugin recibe del host
 import wasi:io/streams@0.2.1;
 import wasi:filesystem/types@0.2.1;
 import wasi:filesystem/preopens@0.2.1;
 import wasi:logging/logging@0.1.0;

 // Lo que el plugin debe implementar
 export transform: func(input: list<u8>) -> result<list<u8>, string>;
}
```

Un componente compilado contra este mundo literalmente no puede hacer requests HTTP, porque ` wasi:http ` no está en sus imports. No puede acceder al reloj, no puede crear threads, no puede abrir sockets. El runtime Wasm aplica esto en link time, si un componente intenta importar una interfaz que no está en su mundo, la instanciación falla antes de que una sola instrucción se ejecute.

## Sandboxing del Filesystem: Preopens y Directory Handles

El modelo de filesystem de WASI merece atención especial porque es donde la mayoría de los sistemas de plugins fallan. En WASI Preview 2, un componente no obtiene acceso a un filesystem, obtiene handles a directorios específicos que el host "preabre":

```rust
// Host-side: configurando lo que el plugin puede ver
use wasmtime::component::*;
use wasmtime_wasi::{WasiCtxBuilder, DirPerms, FilePerms};

let mut wasi_ctx = WasiCtxBuilder::new();

// Plugin obtiene acceso read-only a /data/input
wasi_ctx.preopened_dir(
 "/data/input",
 "input",
 DirPerms::READ,
 FilePerms::READ,
)?;

// Plugin obtiene acceso read-write a /data/output
wasi_ctx.preopened_dir(
 "/data/output",
 "output",
 DirPerms::all(),
 FilePerms::all(),
)?;

// Eso es todo. Ningún otro acceso al filesystem existe.
```

Dentro del plugin, el filesystem se ve así:

```rust
// Plugin-side: este es TODO el filesystem visible
// "input/" → mapea a /data/input en el host (read-only)
// "output/" → mapea a /data/output en el host (read-write)

fn transform(data: Vec<u8>) -> Result<Vec<u8>, String> {
 // Esto funciona, "input" es un directorio preabierto
 let config = std::fs::read_to_string("input/config.json")
 .map_err(|e| e.to_string())?;

 // Esto funciona, "output" está preabierto con acceso de escritura
 std::fs::write("output/result.csv", &processed_data)
 .map_err(|e| e.to_string())?;

 // Esto fallaría en runtime, no hay preopen para "/"
 // std::fs::read_to_string("/etc/passwd") // ERROR: no existe tal preopen

 // Esto fallaría, no hay preopen para ".."
 // std::fs::read_to_string("../../../etc/shadow") // ERROR: el path escapa del preopen

 Ok(processed_data)
}
```

Los ataques de path traversal (` ../../../etc/passwd `) son estructuralmente imposibles porque el runtime resuelve paths relativos a preopens y rechaza cualquier cosa que escape. Esto no es un filtro de strings en paths, se aplica a nivel del handle del directorio.

## Capabilities de Red: wasi:http

Para plugins que necesitan hacer requests HTTP salientes (buscar tipos de cambio, llamar APIs, etc.), WASI Preview 2 provee ` wasi:http/outgoing-handler `. La decisión de diseño crítica es que el host controla a dónde puede conectarse el plugin:

```rust
// Host-side: creando una capability HTTP con allowlist de dominios
use wasmtime_wasi_http::WasiHttpCtx;

struct PluginHttpHandler {
 allowed_domains: HashSet<String>,
}

impl PluginHttpHandler {
 fn handle_request(&self, request: OutgoingRequest) -> Result<IncomingResponse> {
 let authority = request.authority()
 .ok_or_else(|| anyhow!("falta authority"))?;

 // Aplicar allowlist de dominios
 if !self.allowed_domains.contains(&authority) {
 return Err(anyhow!(
 "plugin intentó conectarse a dominio no autorizado: {}",
 authority
 ));
 }

 // Reenviar el request al cliente HTTP real
 self.inner_client.send(request)
 }
}

// El plugin solo obtiene acceso a dominios específicos
let http_handler = PluginHttpHandler {
 allowed_domains: HashSet::from([
 "api.exchangerate.host".into(),
 "data.provider.com".into(),
 ]),
};
```

Desde la perspectiva del plugin, simplemente hace requests HTTP. No sabe nada del allowlist, un request a un dominio no autorizado simplemente falla:

```rust
// Plugin-side: haciendo requests HTTP
use wasi::http::outgoing_handler;

fn fetch_exchange_rate(currency: &str) -> Result<f64, String> {
 let request = OutgoingRequest::new(
 Method::Get,
 Some("api.exchangerate.host"),
 Some(&format!("/latest?base={}", currency)),
 Some(Scheme::Https),
 );

 let response = outgoing_handler::handle(request, None)
 .map_err(|e| format!("Request HTTP falló: {:?}", e))?;

 // Parsear respuesta...
 Ok(rate)
}
```

## Construyendo un Runtime de Plugins Seguro

La siguiente arquitectura demuestra un enfoque de nivel producción. La clave es que cada invocación de plugin obtiene una instancia fresca de componente con un set de capabilities a medida:

```rust
use wasmtime::*;
use wasmtime::component::*;
use wasmtime_wasi::*;

struct PluginRuntime {
 engine: Engine,
 linker: Linker<PluginState>,
 plugin_components: HashMap<String, Component>,
}

impl PluginRuntime {
 fn execute_plugin(
 &self,
 plugin_id: &str,
 tenant_id: &str,
 input: &[u8],
 ) -> Result<Vec<u8>> {
 let component = self.plugin_components.get(plugin_id)
 .ok_or_else(|| anyhow!("plugin desconocido"))?;

 // Cada invocación obtiene estado aislado
 let mut store = Store::new(&self.engine, PluginState {
 wasi: self.build_wasi_ctx(tenant_id)?,
 fuel_remaining: 1_000_000_000, // presupuesto CPU
 memory_limit: 256 * 1024 * 1024, // 256MB
 });

 // El metering de fuel previene loops infinitos
 store.set_fuel(1_000_000_000)?;

 let instance = self.linker.instantiate(&mut store, component)?;

 let transform = instance.get_typed_func::<(Vec<u8>,), (Result<Vec<u8>, String>,)>(
 &mut store, "transform"
 )?;

 let (result,) = transform.call(&mut store, (input.to_vec(),))?;
 result.map_err(|e| anyhow!("error del plugin: {}", e))
 }

 fn build_wasi_ctx(&self, tenant_id: &str) -> Result<WasiCtx> {
 let mut builder = WasiCtxBuilder::new();

 // Filesystem aislado por tenant
 builder.preopened_dir(
 &format!("/data/tenants/{}/input", tenant_id),
 "input",
 DirPerms::READ,
 FilePerms::READ,
 )?;

 builder.preopened_dir(
 &format!("/data/tenants/{}/output", tenant_id),
 "output",
 DirPerms::all(),
 FilePerms::all(),
 )?;

 // Sin red, sin reloj, sin env vars, sin args
 // stdout/stderr capturados para logging
 builder.stdout(LogCapture::new(tenant_id));
 builder.stderr(LogCapture::new(tenant_id));

 Ok(builder.build())
 }
}
```

## Composición de Componentes: Virtualizando Capabilities

Una de las features más poderosas de Preview 2 es la composición de componentes. Puede interponer un componente entre el plugin y el host para transformar o restringir capabilities sin modificar ninguno de los dos:

```wit
// Un componente de virtualización de logging que redacta datos sensibles
world logging-filter {
 import wasi:logging/logging@0.1.0;
 export wasi:logging/logging@0.1.0;
}
```

```rust
// El componente filtro intercepta todas las llamadas de log
impl wasi::logging::Logging for LogFilter {
 fn log(level: Level, context: &str, message: &str) {
 // Redactar cualquier cosa que parezca un número de tarjeta de crédito
 let redacted = CREDIT_CARD_REGEX.replace_all(message, "[REDACTADO]");
 // Reenviar a la implementación real de logging
 wasi::logging::log(level, context, &redacted);
 }
}
```

Los componés en link time:

```bash
# Componer plugin con filtro de logging
wasm-tools compose plugin.wasm \
 --definitions logging-filter.wasm \
 -o composed-plugin.wasm
```

El componente resultante tiene comportamiento idéntico desde la perspectiva del plugin, pero todo el logging pasa por el filtro. El plugin no puede bypass-earlo porque la composición se aplica estructuralmente.

## Chequeo de Realidad en Performance

El overhead es una preocupación común. Estos son números reales de un sistema de producción:

```
Instanciación de plugin: ~50μs (component model, Wasmtime 28)
Transform simple (1KB input): ~120μs ejecución total
Transform complejo (1MB input): ~8ms ejecución total
Overhead de memoria por instancia: ~2MB base + asignación del plugin

Para comparar:
Cold start de container Docker: ~200ms-2s
Creación de isolate V8: ~5ms
Creación de estado Lua: ~100μs (pero sin memory safety)
```

La instanciación de 50 microsegundos significa que se puede crear una instancia de plugin fresca, completamente aislada, por request sin necesidad de batching. Eso elimina toda una clase de bugs de state-leakage entre requests.

## Lecciones de Despliegues en Producción

Varias lecciones emergen del uso extendido en producción:

**El metering de fuel es esencial.** Sin él, un plugin con un loop infinito accidental bloquea un worker thread para siempre. Establecer límites de fuel basados en la complejidad esperada de cada tipo de plugin y terminar instancias que agotan su presupuesto es crítico.

**La captura de logs previene información leaking.** Los plugins escriben a stdout/stderr pensando que están debuggeando, pero en un sistema multi-tenant, ese es logs necesitan ir al stream de logs del tenant correcto. Capturar stdout vía el contexto WASI maneja esto limpiamente.

**Pre-compilar los componentes.** Wasmtime soporta compilación ahead-of-time con ` wasmtime compile `. Un componente pre-compilado carga en ~5μs en vez de ~50μs. Para plugins de hot-path, esto importa.

```bash
# Pre-compilar para deploy en producción
wasmtime compile --target x86_64-unknown-linux plugin.wasm -o plugin.cwasm
```

**Versionar las interfaces WIT.** Esta lección se hace evidente cuando una actualización de interfaz WIT rompe plugins compilados contra la versión vieja. El matching de versiones del component model previene roturas silenciosas, pero igual se necesita mantener interfaces backward-compatible.

El modelo de seguridad basado en capabilities de WASI Preview 2 no es solo una propiedad teórica linda, es una herramienta práctica de ingeniería que elimina categorías enteras de bugs de seguridad. Cuando un sistema de plugins literalmente no puede acceder a recurse es que no le fueron otorgados, la postura de seguridad mejora dramáticamente. Comparado con construir sandboxes con cgroups, filtros seccomp, y esperanzas, eso vale bastante.

## Preguntas frecuentes

### ¿En qué se diferencia WASI Preview 2 de WASI Preview 1?

Preview 1 usaba una API tipo POSIX con file descriptors. Preview 2 reemplaza esto con el Component Model, donde las capabilities se expresan como interfaces tipadas (WIT). En vez de números de file descriptor, obteners handles tipados a recurse es específicos. También es composable, es posible virtualizar cualquier capability interponiendo un componente.

### ¿Puede un componente WASI escapar de su sandbox?

No a través de la superficie de API de WASI. Un componente solo puede acceder a capabilities que fueron explícitamente otorgadas por el host en el momento de instanciación. No hay equivalente a abrir /etc/passwd porque la capability de filesystem literalmente no incluye ese path. La única superficie de ataque es el runtime Wasm en sí (bugs de memory safety en Wasmtime, etc.).

### ¿Está WASI Preview 2 listo para producción?

A principios de 2026, Wasmtime, WAMR y Jco soportan WASI Preview 2 con el component model. Cloudflare Workers usa un subconjunto. Está listo para producción en sistemas de plugins y edge compute. Para workloads de servidor de propósito general, el ecosistema todavía está madurando, algunas interfaces como wasi:sockets se están estabilizando.

## Tips

1. **Tip:** Nunca se debe otorgar ` wasi:filesystem/preopens ` con acceso de escritura a directorios que el plugin no necesita de forma absoluta. El principio de las capabilities es deny-by-default, si se otorga todo porque resulta más fácil durante el desarrollo, se construye un sandbox sin paredes.
2. **Tip:** Se recomienda utilizar ` wasi:http/outgoing-handler ` en vez de capabilities de sockets directos. Permite aplicar allowlists de dominios destino a nivel del host, de modo que un plugin comprometido no pueda contactar a ` evil-c2-server.ru ` aunque lo intente.
3. **Tip:** Al probar componentes WASI, es conveniente utilizar ` wasmtime serve --addr 127.0.0.1:8080 ` con el flag ` -S common ` para obtener capabilities default razonables. En producción, sin embargo, conviene definir siempre un conjunto de capabilities explícito, los defaults implícitos son un pasivo de seguridad.
4. **Tip:** Se recomienda fijar las versiones de las interfaces WIT. Un plugin compilado contra ` wasi:http@0.2.1 ` no adquirirá accidentalmente capabilities de ` wasi:http@0.2.3 ` porque el component model aplica matching exacto de versiones en link time.

- wasi
- webassembly
- seguridad-basada-en-capabilities
- wasm
- sandboxing
- sistemas-de-plugins
- wasi-http
- component-model

[Volver al Blog](<https://lucioduran.com/es/blog>)
