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):

// 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":

// 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í:

// 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:

// 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:

// 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:

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:

// 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;
}
// 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:

# 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.

# 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.