Skip to content
Sandboxed Processes

Sandboxed Processes

How to run untrusted processes with pkg/sbox so that they cannot read the data directory of the app. sbox.Init() must be the first statement in main. The sandbox needs Linux; on other systems sbox runs the process without isolation and logs a warning.

Sandboxed Processes

// Tutorial 104 – sbox: untrusted Prozesse sicher aus einer Nago-Anwendung
// heraus ausführen.
//
// Eine Nago-Anwendung hält Secrets und ihre Datenbank im DataDir(). Wenn sie
// untrusted Prozesse startet (den Go-Compiler, git, go test/vet oder fremde
// Programme, die untersucht werden sollen), darf ein solcher Prozess dieses
// DataDir niemals lesen oder anderweitig ausbrechen. Das Paket pkg/sbox baut
// dafür ausschließlich mit Linux-Kernel-Primitiven (user/mount/pid/ipc/uts/net
// Namespaces, pivot_root, landlock, seccomp) eine engere Sandbox – ohne externe
// Programme wie bubblewrap.
//
// Dieses Beispiel demonstriert an einer Seite:
//
//  1. Einen erlaubten Prozess ("go version") erfolgreich in der Sandbox laufen
//     lassen und seine Ausgabe anzeigen.
//  2. Den Kern-Sicherheitsbeweis: derselbe Sandbox-Prozess versucht, das
//     App-Secret aus dem DataDir zu lesen – und scheitert, weil der Pfad im
//     Mount-Namespace gar nicht existiert und landlock zusätzlich kein Recht
//     darauf gewährt.
//
// WICHTIG: sbox benötigt ein Re-Exec-Trampolin. Deshalb MUSS sbox.Init() die
// allererste Anweisung in main() sein. Auf Nicht-Linux-Systemen (z. B. dem
// macOS-Dev-Host) fällt sbox auf einen UNSANDBOXED-Passthrough zurück, der bei
// jedem Aufruf laut warnt – Produktion ist immer Linux.
package main

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"time"

	"go.wdy.de/nago/application"
	"go.wdy.de/nago/pkg/sbox"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/presentation/ui"
	"go.wdy.de/nago/web/vuejs"
)

// secretRelPath ist der Ort des (Demo-)Secrets relativ zum DataDir der App.
const secretRelPath = "secrets/db-password.txt"

// demo bündelt die zur Laufzeit benötigten Pfade.
type demo struct {
	dataDir    string // DataDir() der App – enthält Secrets und DB
	secretPath string // absoluter Pfad des Demo-Secrets im DataDir
	workDir    string // eine harmlose, beschreibbare Arbeitskopie für die Sandbox
}

func main() {
	// sbox.Init MUSS als Erstes stehen. Im normalen Prozess ein No-op; im
	// re-exec'ten Sandbox-Kind übernimmt es und kehrt nie zurück.
	sbox.Init()

	// Headless-Selbsttest für die reproduzierbare Docker-Demo (siehe README).
	// Läuft ohne Web-UI, führt beide Sandbox-Läufe aus und beendet sich mit
	// Exit-Code 0, wenn das Secret nachweislich geschützt ist, sonst 1.
	if os.Getenv("SBOX_SELFCHECK") == "1" {
		os.Exit(selfcheck())
	}

	application.Configure(func(cfg *application.Configurator) {
		cfg.SetApplicationID("de.worldiety.tutorial_104")
		cfg.Serve(vuejs.Dist())

		d, err := setup(cfg)
		if err != nil {
			panic(err)
		}

		cfg.RootView(".", func(wnd core.Window) core.View {
			return page(wnd, d)
		})
	}).Run()
}

// selfcheck führt die beiden Sandbox-Läufe headless aus und prüft, dass das
// Secret aus der Sandbox NICHT lesbar ist. Rückgabe ist der Prozess-Exit-Code.
func selfcheck() int {
	dataDir := os.Getenv("SBOX_SELFCHECK_DATADIR")
	if dataDir == "" {
		dataDir = filepath.Join(os.TempDir(), "tut104-selfcheck")
	}
	d, err := setupDir(dataDir)
	if err != nil {
		fmt.Fprintln(os.Stderr, "setup:", err)
		return 1
	}

	ctx := context.Background()

	// 1) erlaubter Prozess
	if goBin := lookPath("go"); goBin != "" {
		out, err := runInSandbox(ctx, d, goBin, "version")
		fmt.Printf("[go version] err=%v\n%s\n", err, out)
	} else {
		fmt.Println("[go version] übersprungen: kein 'go' im PATH")
	}

	// 2) Sicherheitsbeweis
	catBin := lookPath("cat")
	if catBin == "" {
		fmt.Fprintln(os.Stderr, "kein 'cat' im PATH")
		return 1
	}
	out, err := runInSandbox(ctx, d, catBin, d.secretPath)
	fmt.Printf("[cat secret] err=%v out=%q\n", err, out)

	if err == nil && contains(out, "SECRET") {
		fmt.Println("RESULT: ✘ SECRET LEAKED — Sandbox greift NICHT (Nicht-Linux-Passthrough?)")
		return 1
	}
	fmt.Println("RESULT: ✔ GESCHÜTZT — Secret ist aus der Sandbox nicht erreichbar")
	return 0
}

func contains(s, sub string) bool {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}

// setup legt das Demo-Secret im DataDir an und eine getrennte, harmlose
// Arbeitskopie, die der Sandbox als beschreibbares WorkDir dient.
func setup(cfg *application.Configurator) (*demo, error) {
	return setupDir(cfg.DataDir())
}

// setupDir ist der DataDir-unabhängige Teil von setup und wird auch vom
// Headless-Selbsttest genutzt.
func setupDir(dataDir string) (*demo, error) {
	secretPath := filepath.Join(dataDir, secretRelPath)

	if err := os.MkdirAll(filepath.Dir(secretPath), 0o700); err != nil {
		return nil, err
	}
	if err := os.WriteFile(secretPath, []byte("SUPER-SECRET-DB-PASSWORD\n"), 0o600); err != nil {
		return nil, err
	}

	workDir := filepath.Join(dataDir, "sandbox-work")
	if err := os.MkdirAll(workDir, 0o755); err != nil {
		return nil, err
	}

	return &demo{dataDir: dataDir, secretPath: secretPath, workDir: workDir}, nil
}

func page(wnd core.Window, d *demo) core.View {
	// Ausgabe-States für die beiden Läufe.
	allowedOut := core.AutoState[string](wnd)
	secretOut := core.AutoState[string](wnd)

	// Wir suchen ein erlaubtes Programm (go, sonst cat) für die harmlose Demo.
	goBin := lookPath("go")

	runAllowed := func() {
		if goBin == "" {
			allowedOut.Set("kein 'go' im PATH gefunden – Beispiel benötigt die Go-Toolchain")
			return
		}
		out, err := runInSandbox(wnd.Context(), d, goBin, "version")
		allowedOut.Set(formatRun("go version", out, err))
	}

	runSecretAttempt := func() {
		// Wir versuchen, das App-Secret aus dem DataDir zu lesen – aus der
		// Sandbox heraus. Das MUSS scheitern.
		catBin := lookPath("cat")
		if catBin == "" {
			secretOut.Set("kein 'cat' im PATH gefunden")
			return
		}
		out, err := runInSandbox(wnd.Context(), d, catBin, d.secretPath)
		leaked := out
		secretOut.Set(formatRun("cat "+d.secretPath, leaked, err) +
			"\n\n" + verdict(out, err))
	}

	return ui.VStack(
		ui.H1("sbox · untrusted Prozesse sicher ausführen"),
		ui.Text("Diese App hält ihr Secret unter DataDir()/"+secretRelPath+
			". Untrusted Prozesse werden über pkg/sbox in einer engen Sandbox "+
			"gestartet und dürfen das Secret niemals sehen.").Font(ui.BodyLarge),

		platformHint(),

		ui.Space(ui.L16),

		explainerCard(
			"1 · Erlaubter Prozess in der Sandbox",
			"„go version“ läuft vollständig isoliert: eigener User-/Mount-/PID-/"+
				"Netz-Namespace, read-only Systempfade, seccomp-Filter, landlock. "+
				"Nur die harmlose Arbeitskopie ist beschreibbar.",
			ui.VStack(
				ui.PrimaryButton(runAllowed).Title("go version ausführen"),
				outputBox(allowedOut.Get()),
			).Gap(ui.L8).Alignment(ui.Leading).FullWidth(),
		),

		ui.Space(ui.L8),

		explainerCard(
			"2 · Sicherheitsbeweis: Secret ist unerreichbar",
			"Derselbe Sandbox-Prozess versucht, das App-Secret aus dem DataDir "+
				"zu lesen. Der Pfad existiert im Mount-Namespace der Sandbox gar "+
				"nicht (er wurde nie hineingebunden), und landlock verweigert "+
				"zusätzlich jeden Zugriff. Der Leseversuch scheitert daher.",
			ui.VStack(
				ui.SecondaryButton(runSecretAttempt).
					Title("Secret aus der Sandbox lesen (muss scheitern)"),
				outputBox(secretOut.Get()),
			).Gap(ui.L8).Alignment(ui.Leading).FullWidth(),
		),
	).Gap(ui.L8).Alignment(ui.Leading).FullWidth().
		Frame(ui.Frame{MaxWidth: ui.L880}).
		Padding(ui.Padding{}.All(ui.L16))
}

// runInSandbox führt ein Programm über pkg/sbox aus und gibt dessen kombinierte
// stdout/stderr-Ausgabe zurück. Es verwendet ein hermetisches Profil, das nur
// die nötigen read-only Systempfade und eine beschreibbare Arbeitskopie
// exponiert – das DataDir mit den Secrets wird bewusst NICHT gebunden.
func runInSandbox(ctx context.Context, d *demo, path string, args ...string) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()

	// CapBuffer begrenzt die eingefangene Ausgabe eines (potenziell
	// fehlerhaften) untrusted Prozesses, damit dieser nicht beliebig viel
	// Speicher im Host belegen kann.
	buf := sbox.NewCapBuffer(64 * 1024)
	p := sbox.Profile{
		RootFS:   sbox.RootMinimal, // read-only /usr,/bin,/lib,... und CA-Zertifikate
		Binds:    []sbox.Bind{{Host: d.workDir, Writable: true}},
		Env:      []string{"PATH=/usr/bin:/bin:/usr/local/go/bin", "HOME=" + d.workDir},
		WorkDir:  d.workDir,
		Net:      sbox.NetHost, // go/git dürfen ins Netz; Secrets bleiben trotzdem unerreichbar
		Seccomp:  sbox.SeccompStrict,
		Landlock: true,
		Limits:   sbox.Limits{Wall: 30 * time.Second, NoFile: 4096},
	}

	res, err := sbox.Run(ctx, p, sbox.Cmd{
		Path:   path,
		Args:   args,
		Stdout: buf,
		Stderr: buf,
	})
	if err != nil {
		return buf.String(), err
	}
	if res.ExitCode != 0 {
		return buf.String(), fmt.Errorf("exit code %d", res.ExitCode)
	}
	return buf.String(), nil
}

// verdict formuliert das Ergebnis des Sicherheitsbeweises als Klartext.
func verdict(out string, err error) string {
	if err != nil {
		return "✔ GESCHÜTZT: Der Leseversuch ist fehlgeschlagen – das Secret ist " +
			"aus der Sandbox nicht erreichbar."
	}
	if out == "" {
		return "✔ GESCHÜTZT: kein Inhalt gelesen."
	}
	return "✘ ACHTUNG: Es wurde Inhalt gelesen. Läuft dies auf einem " +
		"Nicht-Linux-Dev-Host? Dort nutzt sbox einen UNSANDBOXED-Passthrough " +
		"ohne Isolation – niemals in Produktion verwenden."
}

// platformHint weist darauf hin, wenn wir nicht auf Linux laufen und sbox daher
// nur den unsicheren Passthrough bereitstellt.
func platformHint() core.View {
	if isLinux() {
		return ui.Text("Läuft auf Linux – echte Sandbox aktiv.").
			Font(ui.Small).Color(ui.ColorSemanticGood)
	}
	return ui.Text("Achtung: Nicht-Linux-Dev-Host erkannt. sbox nutzt hier einen " +
		"UNSANDBOXED-Passthrough OHNE jede Isolation (mit Log-Warnung). Der " +
		"Sicherheitsbeweis unten schlägt daher fehl – so ist es gewollt. In " +
		"Produktion (Linux) ist die Sandbox aktiv.").
		Font(ui.Small).Color(ui.ColorSemanticWarn)
}

// ---- kleine Helfer ----------------------------------------------------------

func outputBox(s string) core.View {
	if s == "" {
		return ui.Text("— noch keine Ausgabe —").Font(ui.Small).Color(ui.ColorText)
	}
	return ui.CodeEditor(s).Disabled(true).Frame(ui.Frame{}.FullWidth())
}

func explainerCard(title, text string, body core.View) core.View {
	return ui.VStack(
		ui.Text(title).Font(ui.Title),
		ui.Text(text),
		ui.Space(ui.L8),
		body,
	).Alignment(ui.Leading).
		BackgroundColor(ui.ColorCardBody).
		Padding(ui.Padding{}.All(ui.L16)).
		Frame(ui.Frame{}.FullWidth())
}

func formatRun(cmd, out string, err error) string {
	b := "$ " + cmd + "\n"
	if out != "" {
		b += out
		if out[len(out)-1] != '\n' {
			b += "\n"
		}
	}
	if err != nil {
		b += "[Fehler: " + err.Error() + "]\n"
	}
	return b
}

func lookPath(name string) string {
	if p, err := exec.LookPath(name); err == nil {
		return p
	}
	// gängige absolute Fallbacks
	for _, c := range []string{"/usr/bin/" + name, "/bin/" + name, "/usr/local/go/bin/" + name} {
		if _, err := os.Stat(c); err == nil {
			return c
		}
	}
	return ""
}

// isLinux erlaubt eine plattformabhängige Anzeige ohne separate Build-Dateien.
func isLinux() bool { return runtime.GOOS == "linux" }

View on GitHub ↗ · run it with go run go.wdy.de/nago/example/cmd/tutorial-104-sandbox-exec@latest