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

289 lines
9.6 KiB
Go

package hostfunctions
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"github.com/DeBrosOfficial/network/pkg/rqlite"
"github.com/DeBrosOfficial/network/pkg/serverless"
)
// DBQuery executes a SELECT query and returns JSON-encoded results.
func (h *HostFunctions) DBQuery(ctx context.Context, query string, args []interface{}) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{Function: "db_query", Cause: serverless.ErrDatabaseUnavailable}
}
var results []map[string]interface{}
if err := h.db.Query(ctx, &results, query, args...); err != nil {
return nil, &serverless.HostFunctionError{Function: "db_query", Cause: err}
}
data, err := json.Marshal(results)
if err != nil {
return nil, &serverless.HostFunctionError{Function: "db_query", Cause: fmt.Errorf("failed to marshal results: %w", err)}
}
return data, nil
}
// DBExecute executes an INSERT/UPDATE/DELETE query and returns affected rows.
//
// IMPORTANT: this returns 0 for BOTH "0 rows affected" AND "SQL error"
// — callers can't distinguish. That ambiguity caused bug #218 (AnChat's
// migrate function silently dropped statements). For new code, prefer
// DBExecuteV2 which returns a typed envelope.
func (h *HostFunctions) DBExecute(ctx context.Context, query string, args []interface{}) (int64, error) {
if h.db == nil {
return 0, &serverless.HostFunctionError{Function: "db_execute", Cause: serverless.ErrDatabaseUnavailable}
}
result, err := h.db.Exec(ctx, query, args...)
if err != nil {
return 0, &serverless.HostFunctionError{Function: "db_execute", Cause: err}
}
affected, _ := result.RowsAffected()
return affected, nil
}
// dbExecuteV2Result is the JSON wire shape returned by DBExecuteV2.
type dbExecuteV2Result struct {
RowsAffected int64 `json:"rows_affected"`
LastInsertID int64 `json:"last_insert_id,omitempty"`
Error string `json:"error,omitempty"`
}
// DBExecuteV2 is the typed equivalent of DBExecute. Returns the same shape
// regardless of success/failure so callers can distinguish "0 rows affected"
// from "SQL error" — fixing the contract gap that caused bug #218.
//
// Returns a Go error only for host-side setup failures (no DB). SQL errors
// are encoded in the JSON envelope's "error" field.
func (h *HostFunctions) DBExecuteV2(ctx context.Context, query string, args []interface{}) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{
Function: "db_execute_v2",
Cause: serverless.ErrDatabaseUnavailable,
}
}
out := dbExecuteV2Result{}
result, err := h.db.Exec(ctx, query, args...)
if err != nil {
out.Error = err.Error()
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "db_execute_v2", Cause: mErr}
}
return buf, nil
}
if result != nil {
out.RowsAffected, _ = result.RowsAffected()
out.LastInsertID, _ = result.LastInsertId()
}
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "db_execute_v2", Cause: mErr}
}
return buf, nil
}
// dbQueryV2Result is the JSON wire shape returned by DBQueryV2.
type dbQueryV2Result struct {
Rows []map[string]interface{} `json:"rows"`
Error string `json:"error,omitempty"`
}
// DBQueryV2 is the typed equivalent of DBQuery. Distinguishes "empty
// result set" from "query failed" via the "error" field.
func (h *HostFunctions) DBQueryV2(ctx context.Context, query string, args []interface{}) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{
Function: "db_query_v2",
Cause: serverless.ErrDatabaseUnavailable,
}
}
out := dbQueryV2Result{Rows: []map[string]interface{}{}}
if err := h.db.Query(ctx, &out.Rows, query, args...); err != nil {
out.Error = err.Error()
// Reset rows to non-nil empty on error so callers get a stable shape.
out.Rows = []map[string]interface{}{}
}
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "db_query_v2", Cause: mErr}
}
return buf, nil
}
// dbTransactionRequest is the WASM-side shape for db_transaction input.
type dbTransactionRequest struct {
Ops []rqlite.BatchOp `json:"ops"`
}
// DBTransaction executes ops as a single atomic batch.
// Input is JSON: {"ops": [{"kind":"exec"|"query","sql":"...","args":[...]}, ...]}
// Output is JSON: BatchResult — caller checks `committed` to know if writes landed.
//
// Returns an error only for setup/validation problems. A rolled-back batch is
// communicated via committed=false in the returned JSON; that's not a Go error.
func (h *HostFunctions) DBTransaction(ctx context.Context, opsJSON []byte) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{Function: "db_transaction", Cause: serverless.ErrDatabaseUnavailable}
}
var req dbTransactionRequest
if err := json.Unmarshal(opsJSON, &req); err != nil {
return nil, &serverless.HostFunctionError{
Function: "db_transaction",
Cause: fmt.Errorf("invalid json: %w", err),
}
}
if len(req.Ops) == 0 {
return nil, &serverless.HostFunctionError{
Function: "db_transaction",
Cause: fmt.Errorf("ops required"),
}
}
if len(req.Ops) > rqlite.MaxBatchOps {
return nil, &serverless.HostFunctionError{
Function: "db_transaction",
Cause: fmt.Errorf("too many ops: max %d", rqlite.MaxBatchOps),
}
}
res, err := h.db.Batch(ctx, req.Ops)
// Always return the structured result, even on rollback — caller wants the
// per-op detail to know which op failed.
if res == nil {
// Unrecoverable setup failure (no native conn). Surface as Go error.
return nil, &serverless.HostFunctionError{Function: "db_transaction", Cause: err}
}
out, mErr := json.Marshal(res)
if mErr != nil {
return nil, &serverless.HostFunctionError{
Function: "db_transaction",
Cause: fmt.Errorf("marshal result: %w", mErr),
}
}
// Rollback errors are encoded in the JSON; do NOT propagate as Go error.
// Only true setup/transport errors after the result was built warrant returning err.
_ = err // intentionally swallowed — committed=false carries the signal
return out, nil
}
// execAndPublishResult is the JSON wire shape returned to WASM callers.
type execAndPublishResult struct {
Results []rqlite.OpResult `json:"results"`
Committed bool `json:"committed"`
FailedIndex int `json:"failed_index,omitempty"`
Seq int64 `json:"seq,omitempty"`
Published bool `json:"published,omitempty"`
PublishError string `json:"publish_error,omitempty"`
}
// ExecAndPublish runs ops atomically (with a seq increment in the same batch)
// and, if committed, publishes data with `{{seq}}` substituted for the
// assigned per-namespace sequence number.
//
// Failure modes (each communicated in the JSON, not as Go error):
// - Rollback: committed=false, failed_index points to the failing user op
// - Publish failed but commit succeeded: committed=true, published=false,
// publish_error is set. Writes are durable; caller can retry the publish.
// - Both succeeded: committed=true, published=true.
//
// Returns a Go error only on setup failures (no DB, bad JSON, no namespace).
func (h *HostFunctions) ExecAndPublish(
ctx context.Context, opsJSON []byte, topic string, dataTemplate []byte,
) ([]byte, error) {
if h.db == nil {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: serverless.ErrDatabaseUnavailable,
}
}
if h.pubsub == nil {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("pubsub not available"),
}
}
if topic == "" {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("topic required"),
}
}
// Resolve namespace from invocation context — server-trusted.
// ctx-attached invCtx wins over singleton; see invocation_context.go.
ns := ""
if cur := h.currentInvocationContext(ctx); cur != nil {
ns = cur.Namespace
}
if ns == "" {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("no namespace in invocation context"),
}
}
var req dbTransactionRequest
if err := json.Unmarshal(opsJSON, &req); err != nil {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("invalid ops json: %w", err),
}
}
if len(req.Ops) > rqlite.MaxBatchOps {
return nil, &serverless.HostFunctionError{
Function: "exec_and_publish",
Cause: fmt.Errorf("too many ops: max %d", rqlite.MaxBatchOps),
}
}
batchRes, seq, batchErr := h.db.BatchWithSeq(ctx, ns, req.Ops)
out := execAndPublishResult{}
if batchRes != nil {
out.Results = batchRes.Results
out.Committed = batchRes.Committed
out.FailedIndex = batchRes.FailedIndex
}
// On rollback or pre-publish error, return without publishing.
if batchErr != nil || !out.Committed {
// On a true rollback batchErr may be non-nil; that's already encoded
// in the result. Don't surface as Go error — caller reads `committed`.
_ = batchErr
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "exec_and_publish", Cause: mErr}
}
return buf, nil
}
out.Seq = seq
// Substitute {{seq}} in the data template, then publish.
finalData := bytes.ReplaceAll(
dataTemplate,
[]byte("{{seq}}"),
[]byte(strconv.FormatInt(seq, 10)),
)
if err := h.pubsub.Publish(ctx, topic, finalData); err != nil {
out.PublishError = err.Error()
} else {
out.Published = true
}
buf, mErr := json.Marshal(out)
if mErr != nil {
return nil, &serverless.HostFunctionError{Function: "exec_and_publish", Cause: mErr}
}
return buf, nil
}