anonpenguin23 251630a5c7 fix(serverless): per-call invCtx propagation prevents cross-tenant identity leak in persistent WS
HostFunctions is a process-wide singleton (one per gateway engine).
Its `invCtx` field is shared across all WASM instances. For STATELESS
execution the executor sets/clears it per-call but the lock is
released before WASM runs — two concurrent invocations can race on
the field and one's host call can read the other's identity. Window
is microseconds.

For PERSISTENT WS the bug was much worse: invCtx used to be bound
ONCE at instantiation and reused for the connection's lifetime. Two
simultaneous persistent WS connections from different namespaces /
wallets overwrote each other's invCtx, and EVERY subsequent
function_invoke / GetCallerJWTSubject / GetCallerWallet / GetSecret
call from inside the WASM read whatever was bound LAST. Result:
silent identity leak across tenants for as long as the connections
overlapped.

Fix: per-call invCtx propagation through Go's context.Context.
wazero passes the ctx given to api.Function.Call through to host
function callbacks, so every WASM-host hop carries its own invCtx.

- pkg/serverless/invocation_context.go (new): WithInvocationContext +
  InvocationContextFromCtx helpers using an unexported invCtxKey.
- pkg/serverless/hostfunctions/invocation_context.go (new):
  currentInvocationContext(ctx) — ctx-attached invCtx wins over the
  singleton field.
- All host accessors (FunctionInvoke, GetEnv, GetSecret, GetRequestID,
  GetCallerWallet, GetWSClientID, GetCallerClaim, GetCallerJWTSubject)
  now route through currentInvocationContext(ctx).
- pkg/serverless/persistent/instance.go: every export call's ctx is
  wrapped with the per-instance invCtx before being passed to wazero.
- pkg/gateway/handlers/serverless/ws_persistent_handler.go: invCtx is
  built per-frame and attached to ctx, not stored on a shared field.
- pkg/serverless/engine.go: removed the SetInvocationContext call at
  InstantiatePersistent (no longer needed; ctx carries it).

Stateless still uses the singleton field — its race is latent since
the host-functions split and migrating it is a separate scoped
change.

Tests:
- hostfunctions/invocation_context_test.go covers ctx-wins-over-singleton.
- gateway/handlers/serverless/ws_persistent_handler_test.go covers the
  per-frame ctx wiring.
- cli/functions/build_test.go is new coverage for the build path
  touched in this change.

VERSION bumped to 0.122.24.
2026-05-15 13:36:35 +03:00

124 lines
4.1 KiB
Go

package functions
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
// tinygoBuildArgs returns the argv (without the leading `tinygo`) used
// to compile a function. Pure function — extracted from buildFunction
// so the WS-persistent → `-buildmode=c-shared` policy can be unit
// tested without invoking TinyGo.
//
// Persistent WS functions need the WASI-reactor variant (exports
// `_initialize`, no `_start`) — see the comment on cfg loading in
// buildFunction for the full rationale. Stateless (default) functions
// stay on command mode for back-compat.
func tinygoBuildArgs(outputPath string, wsPersistent bool) []string {
args := []string{"build", "-o", outputPath, "-target", "wasi"}
if wsPersistent {
args = append(args, "-buildmode=c-shared")
}
args = append(args, ".")
return args
}
// BuildCmd compiles a function to WASM using TinyGo.
var BuildCmd = &cobra.Command{
Use: "build [directory]",
Short: "Build a function to WASM using TinyGo",
Long: `Compiles function.go in the given directory (or current directory) to a WASM binary.
Requires TinyGo to be installed (https://tinygo.org/getting-started/install/).`,
Args: cobra.MaximumNArgs(1),
RunE: runBuild,
}
func runBuild(cmd *cobra.Command, args []string) error {
dir := ""
if len(args) > 0 {
dir = args[0]
}
_, err := buildFunction(dir)
return err
}
// buildFunction compiles the function in dir and returns the path to the WASM output.
func buildFunction(dir string) (string, error) {
absDir, err := ResolveFunctionDir(dir)
if err != nil {
return "", err
}
// Verify function.go exists
goFile := filepath.Join(absDir, "function.go")
if _, err := os.Stat(goFile); os.IsNotExist(err) {
return "", fmt.Errorf("function.go not found in %s", absDir)
}
// Verify function.yaml exists
if _, err := os.Stat(filepath.Join(absDir, "function.yaml")); os.IsNotExist(err) {
return "", fmt.Errorf("function.yaml not found in %s", absDir)
}
// Load config so we can pick the right TinyGo build mode based on
// ws_persistent. Persistent functions need WASI-reactor semantics
// (`_initialize` export, no `_start`); command-mode functions stay
// on the default. See bug #240/#249 follow-up #6 for the full
// rationale — TL;DR: TinyGo command-mode `_start` doesn't set the
// runtime guard `wasmExportCheckRun` checks, so any export call
// from the host (e.g. orama_alloc → ws_open payload) traps with
// "wasm error: unreachable" inside the runtime hashmap path.
//
// `-buildmode=c-shared` flips TinyGo to reactor mode: the wasm
// exports `_initialize` instead of `_start`. The gateway's
// persistent-instance bootstrap (pkg/serverless/engine.go) calls
// `_initialize` first if exported, which sets the guard cleanly,
// and the function's exports become callable from the host loop.
cfg, cfgErr := LoadConfig(absDir)
if cfgErr != nil {
return "", fmt.Errorf("failed to load function.yaml: %w", cfgErr)
}
// Check TinyGo is installed
tinygoPath, err := exec.LookPath("tinygo")
if err != nil {
return "", fmt.Errorf("tinygo not found in PATH. Install it: https://tinygo.org/getting-started/install/")
}
outputPath := filepath.Join(absDir, "function.wasm")
fmt.Printf("Building %s...\n", absDir)
// Build args. Default = command mode. Persistent WS functions get
// reactor mode via `-buildmode=c-shared` so TinyGo emits
// `_initialize` and the runtime guard activates.
tinygoArgs := tinygoBuildArgs(outputPath, cfg.WSPersistent)
if cfg.WSPersistent {
fmt.Printf(" (ws_persistent=true → using -buildmode=c-shared for WASI-reactor semantics)\n")
}
buildCmd := exec.Command(tinygoPath, tinygoArgs...)
buildCmd.Dir = absDir
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
return "", fmt.Errorf("tinygo build failed: %w", err)
}
// Validate output
if err := ValidateWASMFile(outputPath); err != nil {
os.Remove(outputPath)
return "", fmt.Errorf("build produced invalid WASM: %w", err)
}
info, _ := os.Stat(outputPath)
fmt.Printf("Built %s (%d bytes)\n", outputPath, info.Size())
return outputPath, nil
}