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

83 lines
2.5 KiB
Go

package hostfunctions
import (
"context"
"fmt"
"github.com/DeBrosOfficial/network/pkg/serverless"
)
// WSPubSubBridge wires a WS client to a PubSub topic in the function's
// own namespace. Returns an error if:
//
// - bridge is not configured on this gateway
// - the function has no namespace in its invocation context
// - the client's namespace (set at WS upgrade) doesn't match the function's
// - the bridge itself returns an error (e.g. per-client topic cap exceeded)
//
// Idempotent: re-bridging the same (client, topic) is a no-op.
func (h *HostFunctions) WSPubSubBridge(ctx context.Context, clientID, topic string) error {
if h.wsBridge == nil {
return &serverless.HostFunctionError{
Function: "ws_pubsub_bridge",
Cause: fmt.Errorf("bridge not configured on this gateway"),
}
}
fnNS := h.namespaceFromCtx(ctx)
if fnNS == "" {
return &serverless.HostFunctionError{
Function: "ws_pubsub_bridge",
Cause: fmt.Errorf("no namespace in invocation context"),
}
}
cliNS, ok := h.wsBridge.GetClientNamespace(clientID)
if !ok {
return &serverless.HostFunctionError{
Function: "ws_pubsub_bridge",
Cause: fmt.Errorf("unknown client_id %q", clientID),
}
}
if cliNS != fnNS {
return &serverless.HostFunctionError{
Function: "ws_pubsub_bridge",
Cause: fmt.Errorf("namespace mismatch: function=%q client=%q", fnNS, cliNS),
}
}
if err := h.wsBridge.Add(ctx, fnNS, clientID, topic); err != nil {
return &serverless.HostFunctionError{Function: "ws_pubsub_bridge", Cause: err}
}
return nil
}
// WSPubSubUnbridge removes a (client, topic) bridge. Idempotent.
func (h *HostFunctions) WSPubSubUnbridge(ctx context.Context, clientID, topic string) error {
if h.wsBridge == nil {
return &serverless.HostFunctionError{
Function: "ws_pubsub_unbridge",
Cause: fmt.Errorf("bridge not configured on this gateway"),
}
}
fnNS := h.namespaceFromCtx(ctx)
if fnNS == "" {
return &serverless.HostFunctionError{
Function: "ws_pubsub_unbridge",
Cause: fmt.Errorf("no namespace in invocation context"),
}
}
if err := h.wsBridge.Remove(ctx, fnNS, clientID, topic); err != nil {
return &serverless.HostFunctionError{Function: "ws_pubsub_unbridge", Cause: err}
}
return nil
}
// namespaceFromCtx returns the current invocation's namespace, or "" if
// no context is set. ctx-attached invCtx wins over the singleton (see
// invocation_context.go).
func (h *HostFunctions) namespaceFromCtx(ctx context.Context) string {
cur := h.currentInvocationContext(ctx)
if cur == nil {
return ""
}
return cur.Namespace
}