Two shared wasm-boundary fixes surfaced by the messenger port (WZ-013):
1. uuid[] DbValue variant. bnwasm had no uuid-array bind/scan, so every
ANY($1::uuid[]) query broke in the guest ("unsupported argument type
[]uuid.UUID") and messenger worked around it with a ::text[]::uuid[] cast.
Adds a dedicated DbValue.uuid_array_value (abiv1.UuidArray) — distinct from
text[] so the host binds a native uuid[] param (queries keep ::uuid[]) and
scans a uuid[] column straight into []uuid.UUID. Guest toDbValue marshals
[]uuid.UUID; naturalValue/assign parse the canonical strings back into
[]uuid.UUID (nil→NULL, empty stays empty). Pinned by the uuid_array entry in
the shared DbValueFixtures contract (round-trip + driver-value tests green).
2. Trusted identity headers (auth/trustedheaders.go). Context does not cross
the ABI, so guests cannot see the host's verified principal. The SECURE
contract: the host runs its RBAC guard against the signature-verified JWT,
strips any client-supplied copy of the X-Bn-Verified-* headers, and sets
them itself from auth.Get{Public,}UserFromContext; the guest reconstructs
context via auth.TrustedHeaderMiddleware and trusts ONLY those headers.
Guests MUST NOT decode a client cookie/Bearer token for identity — that is a
privilege-escalation bug (a verified public user forging an admin JWT the
guest would honour on a RolePublic method). Documented in docs/wasm-abi.md,
replacing the ambiguous "auth context reaches the guest via HttpRequest
headers" line that invited the insecure decode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
92 lines
3.5 KiB
Go
92 lines
3.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Trusted identity headers — the wasm plugin auth contract.
|
|
//
|
|
// Context values do NOT cross the wasm ABI, so a guest cannot see the auth
|
|
// context the host's middleware built. The SECURE way to convey the caller's
|
|
// identity to a guest is these headers, populated HOST-SIDE from the
|
|
// signature-verified principal after the host has run its RBAC guard
|
|
// (rbac.Authorize against the verified JWT). The host STRIPS any client-supplied
|
|
// copy of these headers before setting them, so a guest may trust them
|
|
// unconditionally.
|
|
//
|
|
// A guest MUST NOT decode a client-supplied cookie or Authorization/Bearer
|
|
// token to establish identity — those are attacker-controlled across the
|
|
// boundary (the host forwards the raw request), and trusting them is a
|
|
// privilege-escalation bug. Read identity ONLY from these headers (via
|
|
// TrustedHeaderMiddleware / ContextFromTrustedHeaders). See
|
|
// core/docs/wasm-abi.md ("Trusted identity headers").
|
|
//
|
|
// Values are the canonical MIME header form so a host map-set and a guest
|
|
// http.Header.Get agree without re-canonicalization surprises.
|
|
const (
|
|
// Admin principal (auth.Claims).
|
|
HeaderVerifiedUserID = "X-Bn-Verified-User-Id"
|
|
HeaderVerifiedRole = "X-Bn-Verified-Role"
|
|
HeaderVerifiedEmail = "X-Bn-Verified-Email"
|
|
|
|
// Public/community principal (auth.PublicClaims).
|
|
HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id"
|
|
HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username"
|
|
HeaderVerifiedPublicEmail = "X-Bn-Verified-Public-Email"
|
|
)
|
|
|
|
// AllTrustedHeaders lists every trusted identity header, canonical form. The
|
|
// host deletes each of these from the inbound request before injecting its own
|
|
// verified values, so a client cannot forge one.
|
|
func AllTrustedHeaders() []string {
|
|
return []string{
|
|
HeaderVerifiedUserID,
|
|
HeaderVerifiedRole,
|
|
HeaderVerifiedEmail,
|
|
HeaderVerifiedPublicUserID,
|
|
HeaderVerifiedPublicUsername,
|
|
HeaderVerifiedPublicEmail,
|
|
}
|
|
}
|
|
|
|
// ContextFromTrustedHeaders rebuilds the request's auth context from the host's
|
|
// verified identity headers. Trust model: see the const block above — these
|
|
// headers are host-controlled, so no token parsing or signature check happens
|
|
// here (the guest has no signing secret by design). A malformed user-id header
|
|
// is ignored rather than trusted.
|
|
func ContextFromTrustedHeaders(ctx context.Context, h http.Header) context.Context {
|
|
if s := h.Get(HeaderVerifiedUserID); s != "" {
|
|
if id, err := uuid.Parse(s); err == nil {
|
|
ctx = WithUser(ctx, &Claims{
|
|
UserID: id,
|
|
Email: h.Get(HeaderVerifiedEmail),
|
|
Role: h.Get(HeaderVerifiedRole),
|
|
})
|
|
}
|
|
}
|
|
if s := h.Get(HeaderVerifiedPublicUserID); s != "" {
|
|
if id, err := uuid.Parse(s); err == nil {
|
|
ctx = WithPublicUser(ctx, &PublicClaims{
|
|
UserID: id,
|
|
Email: h.Get(HeaderVerifiedPublicEmail),
|
|
Username: h.Get(HeaderVerifiedPublicUsername),
|
|
})
|
|
}
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
// TrustedHeaderMiddleware wraps a guest HTTP handler so downstream RPCs can read
|
|
// auth.GetUserFromContext / GetPublicUserFromContext. Plugins whose Connect RPCs
|
|
// resolve the caller from context MUST mount this around their handler (context
|
|
// does not cross the ABI otherwise). This is the ONLY sanctioned way for a guest
|
|
// to learn the caller's identity.
|
|
func TrustedHeaderMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
next.ServeHTTP(w, r.WithContext(ContextFromTrustedHeaders(r.Context(), r.Header)))
|
|
})
|
|
}
|