Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228package executor
import ( "bytes" "context" "errors" "fmt" "io" "time"
"github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" "github.com/tetratelabs/wazero/sys")
// WasmMagic is the magic number that every valid wasm binary starts with.var WasmMagic = []byte{0x00, 0x61, 0x73, 0x6d}
// Config controls the sandbox limits for wasm execution.type Config struct { // MaxExecTime is the wall-clock timeout for a single execution. MaxExecTime time.Duration
// MaxCompileTime is the wall-clock timeout for compiling a module. MaxCompileTime time.Duration
// MaxMemoryMB is the maximum memory in megabytes a module may use. MaxMemoryMB uint32
// MaxWasmSize is the maximum size in bytes of a wasm binary. MaxWasmSize int64
// MaxOutputBytes is the maximum bytes captured from stdout/stderr each. MaxOutputBytes int64}
// DefaultConfig returns sensible defaults for running untrusted wasm.func DefaultConfig() Config { return Config{ MaxExecTime: 5 * time.Second, MaxCompileTime: 10 * time.Second, MaxMemoryMB: 16, MaxWasmSize: 10 * 1024 * 1024, // 10 MB MaxOutputBytes: 10_000, }}
// Result holds the output of a wasm execution.type Result struct { Status string Stdout string Stderr string DurationMs int64 ExitCode uint32}
const ( StatusSuccess = "success" StatusError = "error" StatusTimeout = "timeout")
// Validate checks that the wasm binary looks valid and is within size limits.func Validate(wasm []byte, maxSize int64) error { if int64(len(wasm)) > maxSize { return fmt.Errorf("wasm binary too large: %d bytes (max %d)", len(wasm), maxSize) } if len(wasm) < 8 { return errors.New("wasm binary too small to be valid") } if !bytes.HasPrefix(wasm, WasmMagic) { return errors.New("invalid wasm magic number") } return nil}
// Execute runs a wasm binary in a sandboxed wazero runtime and returns the result.// The provided context can be used for cancellation independent of the execution timeout.//// Sandbox guarantees:// - No filesystem access (all path operations return ENOSYS)// - No network access (no host functions exposed)// - No environment variables// - No command-line arguments// - No real clock (walltime is fake, starting at 2022-01-01)// - No real sleep (nanosleep is a no-op)// - Deterministic random source (not crypto-secure)// - Stdin returns immediate EOF// - Stdout/stderr captured into bounded buffers// - Memory hard-capped at MaxMemoryMB// - Wall-clock execution timeout via context cancellation// - Separate compilation timeout// - Fresh runtime per execution (no state leaks between runs)func Execute(ctx context.Context, cfg Config, wasm []byte) (result *Result, err error) { // Recover from any panic in wazero to prevent crashing the daemon. defer func() { if r := recover(); r != nil { result = &Result{ Status: StatusError, Stderr: fmt.Sprintf("runtime panic: %v", r), } err = nil } }()
if err := Validate(wasm, cfg.MaxWasmSize); err != nil { return nil, err }
memPages := cfg.MaxMemoryMB * 16 // 1 MB = 16 pages (each page is 64 KB)
rtCfg := wazero.NewRuntimeConfig(). WithMemoryLimitPages(memPages). WithCloseOnContextDone(true). // Disable debug info to reduce compilation time and memory for // untrusted modules. We don't need DWARF stack traces. WithDebugInfoEnabled(false)
rt := wazero.NewRuntimeWithConfig(ctx, rtCfg) defer func() { _ = rt.Close(ctx) }()
if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil { return nil, fmt.Errorf("instantiating wasi: %w", err) }
// Compile with a separate timeout so a malicious module can't hang // the daemon during compilation. compileCtx, compileCancel := context.WithTimeout(ctx, cfg.MaxCompileTime) defer compileCancel()
compiled, err := rt.CompileModule(compileCtx, wasm) if err != nil { return nil, fmt.Errorf("compiling module: %w", err) }
stdout := &limitedWriter{max: cfg.MaxOutputBytes} stderr := &limitedWriter{max: cfg.MaxOutputBytes}
// Explicitly configure every capability to be denied. While wazero // defaults are already sandboxed, being explicit provides defense in // depth — if a future wazero version changes defaults, we're still safe. modCfg := wazero.NewModuleConfig(). WithName("guest"). // I/O: stdin is EOF, stdout/stderr captured into bounded buffers. WithStdin(nil). WithStdout(stdout). WithStderr(stderr). // No filesystem access. This is the default, but be explicit. WithFSConfig(wazero.NewFSConfig()). // No environment variables. // No command-line arguments. // (wazero defaults to empty for both, but calling WithArgs/WithEnv // with values would add them — we simply don't.) // // Clock: use wazero's fake deterministic clocks (default). // - Walltime starts at 2022-01-01T00:00:00Z, advances 1ms per read. // - Nanotime starts at 0, advances 1ms per read. // - Nanosleep is a no-op (returns immediately). // - Random source is deterministic (seed 42). // We do NOT call WithWalltime, WithNanotime, WithSysNanosleep, or // WithRandSource, which would install the real host implementations. // // Start function: call _start (WASI entry point). WithStartFunctions("_start")
execCtx, execCancel := context.WithTimeout(ctx, cfg.MaxExecTime) defer execCancel()
start := time.Now() _, err = rt.InstantiateModule(execCtx, compiled, modCfg) duration := time.Since(start)
result = &Result{ Stdout: stdout.String(), Stderr: stderr.String(), DurationMs: duration.Milliseconds(), }
if err != nil { var exitErr *sys.ExitError if errors.As(err, &exitErr) { result.ExitCode = exitErr.ExitCode() switch result.ExitCode { case 0: result.Status = StatusSuccess case sys.ExitCodeDeadlineExceeded: result.Status = StatusTimeout case sys.ExitCodeContextCanceled: result.Status = StatusTimeout default: result.Status = StatusError } return result, nil } return nil, fmt.Errorf("instantiating module: %w", err) }
result.Status = StatusSuccess return result, nil}
// limitedWriter captures output up to a maximum number of bytes.type limitedWriter struct { buf bytes.Buffer max int64}
func (w *limitedWriter) Write(p []byte) (int, error) { remaining := w.max - int64(w.buf.Len()) if remaining <= 0 { // Silently discard but report success to the guest so it doesn't error. return len(p), nil } if int64(len(p)) > remaining { p = p[:remaining] } return w.buf.Write(p)}
func (w *limitedWriter) String() string { return w.buf.String()}
// WriteTo implements io.WriterTo for efficient copying.func (w *limitedWriter) WriteTo(dst io.Writer) (int64, error) { return w.buf.WriteTo(dst)}