Rust als Host für WASM-Plugins – sichere Erweiterbarkeit neu gedacht

Rust als Host für WASM-Plugins – sichere Erweiterbarkeit neu gedacht
By Matthias Petermann / on 30.10.2025

Einführung

Erweiterbarkeit ist ein altes Versprechen der Softwaretechnik – und zugleich ihre größte Sicherheitslücke.

Hier kommt WebAssembly (WASM) ins Spiel – ursprünglich für Browser entwickelt, heute eine universelle Plattform für sichere, portable Module. Rust ergänzt dieses Konzept perfekt: präzise, schnell und speichersicher.

🏗️ Kurz gesagt
WASM = isolierte Ausführung, Rust = sicherer Host. Zusammen entsteht ein Plugin-System, das portabel, performant und kontrollierbar ist.

Was ist WebAssembly?

WebAssembly (WASM) ist ein plattformunabhängiges Binärformat für eine kompakte virtuelle Maschine. Ein .wasm-Artefakt läuft überall dort, wo eine Engine (z. B. wasmtime, wasmer) vorhanden ist – unabhängig vom Betriebssystem.

  • Portabel: Ein Artefakt für viele Plattformen (Server, Edge, Desktop, Browser).
  • Schnell: Durch JIT- oder AOT-Kompilierung oft nahe an nativer Performance.
  • Sicher: Keine impliziten Systemzugriffe – standardmäßig keine Datei-, Netzwerk- oder Prozessrechte.

WASM hat seinen Ursprung im Web, wird heute aber zunehmend serverseitig, in Edge-Runtimes oder als Plugin-Format genutzt.

ℹ️ Begriffe
  • Engine: Führt das WASM-Modul aus (z. B. wasmtime, wasmer).
  • WASI: WebAssembly System Interface – standardisierte APIs für Dateien, Zeit, Umgebungsvariablen usw.
  • Component Model / WIT: Ein modernes Schnittstellenmodell für modulare, sprachübergreifende WASM-Komponenten.

Warum WASM als Plugin-Format?

Ein Plugin-System soll neuen Code laden können – ohne Risiko für Stabilität oder Sicherheit.

  • Isolation: Jedes Plugin läuft in einer Sandbox mit eigenem Speicher.
  • Kontrollierte Schnittstellen: Nur explizit freigegebene Funktionen sind sichtbar.
  • Mehrsprachigkeit: Rust, Zig, C/C++, TinyGo oder AssemblyScript – alles kann nach WASM kompilieren.
  • Portabilität: Plugins laufen auf Linux, macOS, Windows oder am Edge.
  • Performance: Für CPU-intensive Aufgaben nahezu nativ, IO läuft kontrolliert über den Host.
⚠️ Grenzen
WASM ersetzt kein Betriebssystem. Ohne Host-APIs gibt es kein Dateisystem, kein Netzwerk und keine Threads (außer explizit aktiviert). Zudem kosten Grenzübergänge zwischen Host und Plugin Zeit – daher lieber gröbere Schnittstellen designen.

Sandboxing & Speicher – wie Sicherheit entsteht

  • Default-deny: Ein WASM-Modul darf standardmäßig nichts – der Host muss explizit Rechte vergeben.
  • Linear Memory: Ein Modul verwaltet seinen Speicher selbst; Pointer in den Host sind ausgeschlossen.
  • Imports/Exports: Kommunikation erfolgt nur über klar definierte Funktionen und Datentransfers.
🔒 Capabilities statt globaler Rechte
Statt globaler Rechte erhalten Plugins Capabilities: gezielt freigegebene Funktionen oder Ressourcen. Ein Beispiel ist ein preopened directory /data (WASI), das nur Zugriff auf genau diesen Ordner erlaubt.

Rust als Host – warum das passt

  • Sicherheit: Keine „Undefined Behavior“-Überraschungen.
  • Performance: Geringer Overhead, ideal für die Einbettung einer Engine.
  • Ökosystem: Crates wie wasmtime, wasmer oder extism sind ausgereift.
  • Transparenz: Policies und Schnittstellen sind explizit und leicht überprüfbar.

Beispielprojekt: Rust-Host + WASM-Plugin (Addition)

Ein einfaches, aber vollständiges Beispiel:

  • Plugin plugin_add: exportiert add(a, b) -> i32
  • Host host: lädt das Plugin und ruft add auf

Projektstruktur:

wasm-plugin-mvp/
├─ host/
└─ plugin_add/

Plugin plugin_add:

plugin_add/Cargo.toml

[package]
name = "plugin_add"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

plugin_add/src/lib.rs

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

Build (WASM):

cd plugin_add
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
# → target/wasm32-unknown-unknown/release/plugin_add.wasm

Host host

host/Cargo.toml

[package]
name = "host"
version = "0.1.0"
edition = "2021"

[dependencies]
wasmtime = "26"
anyhow = "1"

host/src/main.rs

use anyhow::Result;
use wasmtime::{Engine, Module, Store, Instance, TypedFunc};

fn main() -> Result<()> {
    let wasm_path = "../plugin_add/target/wasm32-unknown-unknown/release/plugin_add.wasm";
    let engine = Engine::default();
    let module = Module::from_file(&engine, wasm_path)?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;

    let add: TypedFunc<(i32, i32), i32> = instance.get_typed_func(&mut store, "add")?;
    let result = add.call(&mut store, (21, 21))?;
    println!("add(21, 21) = {}", result);
    Ok(())
}

Ausführen:

cd host
cargo run
# add(21, 21) = 42
📝 Build-Checkliste
  1. rustup target add wasm32-unknown-unknown
  2. Plugin im Release-Modus bauen
  3. Host auf das richtige .wasm-Artefakt verweisen

Fortgeschritten: Datei-Zugriff – kontrolliert aus der Sandbox

Standard-WASM kann keine Dateien öffnen. Zugriff ist nur über explizite Schnittstellen möglich. Es gibt zwei Wege, diesen Zugriff gezielt zu erlauben:

  1. Custom-Host-API: Der Host stellt eine eigene Funktion bereit (z. B. append_result(path, value)).
  2. WASI: Der Host öffnet ein Verzeichnis vorab („preopened directory“) und erlaubt Zugriff nur dort.

Im Folgenden werden beide Varianten gezeigt.

Variante A: Custom-API (Host-Funktion)

Hier implementiert der Host selbst, wie und wohin geschrieben wird – inklusive Pfadvalidierung.

Plugin plugin_append:

extern "C" {
    // Importierte Host-Funktion: (path_ptr, path_len, value) -> status
    fn append_result(ptr: i32, len: i32, value: i32) -> i32;
}

#[no_mangle]
pub extern "C" fn add_and_append(path_ptr: i32, path_len: i32, a: i32, b: i32) -> i32 {
    let sum = a + b;
    unsafe { append_result(path_ptr, path_len, sum); }
    sum
}

Host (mit Pfad-Policy):

use anyhow::Result;
use wasmtime::{Engine, Module, Store, Linker, Caller};
use std::fs::OpenOptions;
use std::io::Write;

fn main() -> Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "../plugin_append/target/wasm32-unknown-unknown/release/plugin_append.wasm")?;
    let mut store = Store::new(&engine, ());
    let mut linker = Linker::new(&engine);

    linker.func_wrap("env", "append_result", move |mut caller: Caller<'_, ()>, ptr: i32, len: i32, value: i32| {
        let mem = caller.get_export("memory").unwrap().into_memory().unwrap();
        let data = mem.data(&caller);
        let start = ptr as usize;
        let end = start + len as usize;
        if end > data.len() { return 1; }
        let path = std::str::from_utf8(&data[start..end]).unwrap_or("");
        if !path.starts_with("./data/") {
            eprintln!("Verbotener Pfad: {}", path);
            return 1;
        }
        let _ = std::fs::create_dir_all("./data");
        match OpenOptions::new().create(true).append(true).open(path) {
            Ok(mut f) => { let _ = writeln!(f, "{}", value); 0 }
            Err(e) => { eprintln!("IO-Fehler: {e}"); 1 }
        }
    })?;

    let instance = linker.instantiate(&mut store, &module)?;
    let memory = instance.get_memory(&mut store, "memory").expect("memory export");
    let path = "./data/result.txt";
    let offset = 1024usize;
    memory.data_mut(&mut store)[offset..offset + path.len()].copy_from_slice(path.as_bytes());

    let func = instance.get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "add_and_append")?;
    let res = func.call(&mut store, (offset as i32, path.len() as i32, 7, 11))?;
    println!("sum={}, written→{}", res, path);
    Ok(())
}
🏗️ Warum Custom-API?
  • Maximale Feingranularität: Der Host kontrolliert Pfade, Inhalte und Quoten.
  • Kein WASI nötig – funktioniert mit wasm32-unknown-unknown.
  • Ideal, wenn Sicherheit und Auditierbarkeit höchste Priorität haben.

Variante B: WASI mit preopened Directory

Hier kann das Plugin über die Standardbibliothek schreiben – aber nur in ein vorab freigegebenes Verzeichnis.

Plugin plugin_append_wasi:

use std::fs::OpenOptions;
use std::io::Write;

#[no_mangle]
pub extern "C" fn add_and_append_wasi(a: i32, b: i32) -> i32 {
    let sum = a + b;
    let path = "/data/result.txt";
    if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
        let _ = writeln!(f, "{}", sum);
    }
    sum
}

Host mit WASI-Kontext:

use anyhow::Result;
use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::{preview1, DirPerms, FilePerms, WasiCtxBuilder};

fn main() -> Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(
        &engine,
        "../plugin_append_wasi/target/wasm32-wasip1/release/plugin_append_wasi.wasm",
    )?;

    let wasi_p1 = WasiCtxBuilder::new()
        .inherit_stdio()
        .preopened_dir("./data", "/data", DirPerms::all(), FilePerms::all())?
        .build_p1();

    let mut store = Store::new(&engine, wasi_p1);

    let mut linker = Linker::new(&engine);
    preview1::add_to_linker_sync(&mut linker, |cx| cx)?;

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

    let run = instance.get_typed_func::<(i32, i32), i32>(&mut store, "add_and_append_wasi")?;

    let result = run.call(&mut store, (5, 10))?;
    println!("sum={}, written→./data/result.txt", result);

    Ok(())
}

Ergänzung der Dependencies:

[package]
name = "host"
version = "0.1.0"
edition = "2021"

[dependencies]
wasmtime = "26"
anyhow = "1"
wasmtime-wasi = "26"
cap-std = "3"

Abweichender Build des Plugins:

rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
🏗️ Warum WASI?
  • Plugin-Autoren nutzen normale std::fs-APIs.
  • Sehr entwicklerfreundlich, klar begrenzt durch preopened directories.
  • Ideal für vertrauenswürdige Plugin-Entwickler, die in einem kontrollierten Umfeld arbeiten.

Performance – worauf es ankommt

  • Batching: Wenige, größere Aufrufe über die Host/Guest-Grenze.
  • Release-Builds: Sowohl Host als auch Plugins in --release kompilieren.
  • Effiziente Datenformate: Binär (z. B. Flatbuffers, Protobuf) statt vieler kleiner Strings.
  • AOT (optional): Mit Wasmtime vorcompilierte Module starten deutlich schneller.
⏱️ Realistische Erwartungen
In CPU-lastigen Aufgaben ist WASM oft nur 5–20 % langsamer als nativer Rust-Code. Für Plugin-Systeme ist das ein sehr fairer Preis für Sicherheit und Portabilität.

Fazit

WASM liefert die Sandbox, Rust den präzisen Host. Gemeinsam schaffen sie eine vertrauenswürdige Modularität: Plugins lassen sich sicher laden, laufen portabel und greifen nur auf freigegebene Ressourcen zu.

Das Beispiel mit Addition und Datei-Append zeigt den Weg:

  • minimal starten,
  • Schnittstellen klar definieren,
  • Erweiterbarkeit gezielt erlauben.

So entstehen Plattformen, die offen wirken – und sicher bleiben.

„Nicht Vertrauen macht Systeme stabil, sondern das Fehlen von impliziten Möglichkeiten.“


Bildnachweis: Das Rust-Logo ist eine eingetragene Marke der Rust Foundation. Verwendung im Rahmen redaktioneller Berichterstattung gemäß den Markenrichtlinien der Rust Foundation.