mirror of
https://github.com/luxfi/zap.git
synced 2026-07-26 22:53:31 +00:00
fix(forward+node): canonical HTTP-over-ZAP security hardening (v0.8.8)
V4 CRITICAL: forward.handle no longer panics on hostile method/path (validForwardLine→400 + http.NewRequestWithContext); node.safeHandle adds a recover() boundary across all 5 dispatch sites (TCP dispatchLoop x2, getOrConnect, QUIC handleCallStream + invokeHandlerOneWay) — a handler panic drops one conn, never crashes the node. Proven on TCP+QUIC. V2 HIGH: forward/identity.go single-source strip set (superset of base's StripIdentityHeaders + Cookie drop + X-Gateway-* sweep), applied on BOTH legs (serve.go backend + client.go gateway); Authorization preserved. V6: SSE responses → 501 (buffered-stream guard). + consolidation: zap_crosswire_test.go proves byte-identical wire vs zap-proto/go; MIGRATION.md roadmap. Red-reviewed SHIP (0 crit/high/med). Zero exported-API change — source-compatible.
This commit is contained in:
@@ -5,6 +5,44 @@ The canonical KV + transport for Lux is `luxfi/zap` (this repo) backed by
|
||||
**and** cold ancient stores. PebbleDB / leveldb remain only as legacy
|
||||
in-place upgrade paths.
|
||||
|
||||
## ZAP runtime consolidation (serialization core)
|
||||
|
||||
`github.com/zap-proto/go` is the canonical pure-stdlib runtime for the ZAP
|
||||
wire format (`Parse`/`Message`/`Object`/`List` + `Builder`/`ObjectBuilder`/
|
||||
`ListBuilder` + `Schema`). This repo (`luxfi/zap`) historically duplicated
|
||||
that core in its own `zap.go`/`builder.go`/`schema.go` while adding the
|
||||
network layer (Node mesh, QUIC/TCP, mDNS, PQ-TLS handshake, EVM types, the
|
||||
`forward` HTTP-over-ZAP contract). The duplication is being collapsed so
|
||||
there is ONE serialization runtime; `zap-proto/go` is the source of truth and
|
||||
this repo will ride it.
|
||||
|
||||
**Status (first safe step landed):** the two cores are now proven to emit a
|
||||
byte-identical data segment, and `zap-proto/go` has absorbed the two deltas
|
||||
that previously diverged it from this repo's hardened core, so the readers now
|
||||
agree on accept/reject for every input.
|
||||
|
||||
- **Wire proof.** `zap_crosswire_test.go` (here) and the file of the same
|
||||
name in `zap-proto/go` pin the SAME `goldenV1Hex` constant. This repo's
|
||||
`NewBuilderV1` emits it byte-for-byte; `zap-proto/go`'s default `NewBuilder`
|
||||
emits it too. The ONLY header difference between this repo's default
|
||||
(Version2) output and the v1 golden vector is byte 4 (the version). A live
|
||||
cross-encode in a throwaway `go.work` joining both modules confirmed every
|
||||
field round-trips both directions.
|
||||
- **Deltas reconciled in `zap-proto/go`** (additive — no honest wire changed):
|
||||
(1) it now accepts `Version1` AND `Version2` on read (was v1-only → it would
|
||||
reject this repo's default v2 buffers); (2) its reader now applies the same
|
||||
hardening this repo already had — unsigned-forward `Bytes` with
|
||||
`absPos < HeaderSize` rejection, `Object`/`List` `absOffset < HeaderSize`
|
||||
rejection, and `List` `length ≤ msgsize` clamp.
|
||||
|
||||
**Remaining steps** (see `zap-proto/go`'s LLM.md for the canonical list):
|
||||
`schema.go` is already byte-identical and is the first alias target; the
|
||||
reader/builder alias follows once `cmd/zapgen` lands (the read-buffer pool —
|
||||
`Message.refs`/`Release`/`Retain` — is network-local and stays here); the
|
||||
`StartObject` pre-reserve discipline is a documented, test-covered delta to
|
||||
unify when the builder is aliased (adopt `zap-proto/go`'s pre-reserve — it is
|
||||
strictly more reserving and byte-identical for every honest layout).
|
||||
|
||||
This doc enumerates every storage surface in luxd and the migration path
|
||||
per backend.
|
||||
|
||||
|
||||
+10
-4
@@ -73,6 +73,11 @@ func requestToForward(req *http.Request) (Forward, error) {
|
||||
if h == nil {
|
||||
h = http.Header{}
|
||||
}
|
||||
// Lift the edge-validated identity out of the X-* headers into the typed
|
||||
// fields. These four are only honoured here because by the time a request
|
||||
// reaches the gateway-side Forwarder the gateway has ALREADY validated the
|
||||
// JWT and re-set them; a request that bypassed the gateway carries no
|
||||
// trustworthy values and the full strip below scrubs everything regardless.
|
||||
isAdmin, _ := strconv.ParseBool(h.Get(HeaderUserIsAdmin))
|
||||
perms, _ := strconv.ParseInt(h.Get(HeaderUserPerms), 10, 64)
|
||||
fwd := Forward{
|
||||
@@ -84,10 +89,11 @@ func requestToForward(req *http.Request) (Forward, error) {
|
||||
Path: path,
|
||||
Body: body,
|
||||
}
|
||||
h.Del(HeaderOrgID)
|
||||
h.Del(HeaderUserID)
|
||||
h.Del(HeaderUserIsAdmin)
|
||||
h.Del(HeaderUserPerms)
|
||||
// Strip the FULL canonical identity set (not just the 4 promoted ones) so
|
||||
// no forged identity header — X-Roles, X-User-Email, X-IAM-*, X-Hanzo-*,
|
||||
// X-Gateway-*, identity cookies — is ever serialized into the envelope.
|
||||
// Authorization (the Bearer JWT base validates) is preserved.
|
||||
stripClientIdentity(h)
|
||||
|
||||
if len(h) > 0 {
|
||||
hdrJSON, err := json.Marshal(map[string][]string(h))
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package forward
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// identity.go is the single source of truth for which inbound headers may
|
||||
// carry an authenticated principal across the HTTP-over-ZAP boundary.
|
||||
//
|
||||
// Only the EDGE may assert identity. The gateway validates the IAM JWT and
|
||||
// promotes the result into the typed Forward identity fields (TenantID,
|
||||
// UserID, IsAdmin, Permissions); the backend re-injects those as the
|
||||
// canonical X-* headers AFTER stripping every client-supplied copy. A
|
||||
// client that sets any identity header directly must never have it survive
|
||||
// into a backend handler.
|
||||
//
|
||||
// This list mirrors the authoritative set in
|
||||
// github.com/hanzoai/base/tools/claims.StripIdentityHeaders. The two MUST
|
||||
// stay in lock-step: base strips on its own ingress as defence-in-depth,
|
||||
// and this contract strips on BOTH legs (gateway-side in client.go before
|
||||
// the envelope is built, backend-side in serve.go before identity is
|
||||
// injected) so a forged identity header never enters the envelope and never
|
||||
// reaches a handler even if the envelope is crafted directly.
|
||||
var clientIdentityHeaders = []string{
|
||||
// Canonical edge-injected identity (this contract's own 4).
|
||||
HeaderOrgID, // X-Org-Id
|
||||
HeaderUserID, // X-User-Id
|
||||
HeaderUserIsAdmin, // X-User-IsAdmin
|
||||
HeaderUserPerms, // X-User-Permissions
|
||||
|
||||
// Base's canonical roles header + gateway auxiliaries (JWT derivatives).
|
||||
"X-Roles",
|
||||
"X-User-Email",
|
||||
"X-Phone-Number",
|
||||
|
||||
// Non-canonical legacy identity headers shipped across the ecosystem.
|
||||
"X-User-Role", // singular
|
||||
"X-User-Roles", // plural — renamed to X-Roles
|
||||
"X-User-Name",
|
||||
"X-Tenant-Id",
|
||||
"X-Org",
|
||||
"X-Is-Admin",
|
||||
|
||||
// Pre-validation hints from older gateway flows. The X-Gateway-* prefix
|
||||
// is also caught by the prefix sweep below; listed here for parity with
|
||||
// base's authoritative set.
|
||||
"X-Gateway-Validated",
|
||||
"X-Gateway-User-Id",
|
||||
"X-Gateway-Org-Id",
|
||||
"X-Gateway-User-Email",
|
||||
}
|
||||
|
||||
// stripClientIdentity removes every client-supplied identity-bearing header
|
||||
// from h. It deletes the explicit list above (case-insensitive via
|
||||
// http.Header's canonicalization) and, unconditionally, every header whose
|
||||
// name starts with "X-IAM-", "X-Hanzo-", or "X-Gateway-" — closing the
|
||||
// "clever-new-prefix" smuggling vector. It also drops the Cookie header
|
||||
// entirely: cookies can carry session/identity material the edge has
|
||||
// already lifted into the typed fields, and the backend's auth path is the
|
||||
// forwarded Authorization Bearer JWT, not cookies, so a forwarded Cookie is
|
||||
// only a smuggling surface here.
|
||||
//
|
||||
// Authorization is deliberately PRESERVED: base validates the forwarded
|
||||
// Bearer JWT — that is the real authentication path and must survive.
|
||||
func stripClientIdentity(h http.Header) {
|
||||
for _, name := range clientIdentityHeaders {
|
||||
h.Del(name)
|
||||
}
|
||||
for key := range h {
|
||||
upper := strings.ToUpper(key)
|
||||
if strings.HasPrefix(upper, "X-IAM-") ||
|
||||
strings.HasPrefix(upper, "X-HANZO-") ||
|
||||
strings.HasPrefix(upper, "X-GATEWAY-") {
|
||||
h.Del(key)
|
||||
}
|
||||
}
|
||||
// Cookies are an identity-bearing surface the edge does not promote;
|
||||
// drop them so a forwarded Cookie cannot smuggle a session. Authorization
|
||||
// (the Bearer JWT base validates) is intentionally left intact.
|
||||
h.Del("Cookie")
|
||||
}
|
||||
+186
-22
@@ -9,7 +9,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
zaplib "github.com/luxfi/zap"
|
||||
)
|
||||
@@ -30,44 +32,130 @@ func Serve(node *zaplib.Node, h http.Handler) {
|
||||
})
|
||||
}
|
||||
|
||||
// handle turns one Forward into one Response by invoking h. Exported logic
|
||||
// lives here (not in the closure) so it is unit-testable without two live
|
||||
// nodes — feed a Forward, assert the Response.
|
||||
func handle(ctx context.Context, h http.Handler, f Forward) Response {
|
||||
method := f.Method
|
||||
// errorResponse renders a fail-closed JSON error Response. The body is a
|
||||
// fixed JSON shape so callers (and the bridged *http.Response) get a
|
||||
// well-formed payload, never leaked internal detail. status is the HTTP
|
||||
// code to surface to the original client.
|
||||
func errorResponse(status int, reason string) Response {
|
||||
body, _ := json.Marshal(map[string]string{"error": reason})
|
||||
hdr, _ := json.Marshal(map[string][]string{"Content-Type": {"application/json"}})
|
||||
return Response{Status: status, Headers: hdr, Body: body}
|
||||
}
|
||||
|
||||
// validForwardLine reports whether method+path can be safely turned into an
|
||||
// *http.Request. It rejects:
|
||||
// - methods that are not a single HTTP token (RFC 7230 §3.2.6) — this
|
||||
// excludes spaces, CR, LF, NUL, and all separators/control bytes;
|
||||
// - paths carrying any control byte (C0 incl. CR/LF/NUL, or DEL), and
|
||||
// paths that url.ParseRequestURI cannot parse (e.g. a non-absolute
|
||||
// target).
|
||||
//
|
||||
// A space in the path is NOT rejected: url.ParseRequestURI percent-encodes
|
||||
// it to %20 — a space cannot split a header/request line the way the
|
||||
// control bytes can, so encoding it is the correct, non-lossy outcome.
|
||||
//
|
||||
// On success it returns the cleaned method and the parsed *url.URL so the
|
||||
// caller builds the request from already-validated values. Empty method
|
||||
// defaults to GET and empty path to "/" (the legacy lenient behaviour),
|
||||
// but a NON-empty malformed value is rejected, never defaulted.
|
||||
func validForwardLine(method, path string) (cleanMethod string, u *url.URL, reason string, ok bool) {
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
} else if !isHTTPToken(method) {
|
||||
// An HTTP method is a single token (RFC 7230 §3.2.6: 1*tchar). A
|
||||
// method with a space ("GE T"), CR, LF, NUL, or any separator/
|
||||
// control byte is not a token and is rejected here — this is the
|
||||
// exact grammar net/http's own (unexported) validMethod enforces.
|
||||
return "", nil, "invalid method", false
|
||||
}
|
||||
path := f.Path
|
||||
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
// Reject control bytes (C0 + DEL) outright before parsing. These are the
|
||||
// bytes that, left in a path, can ride into a request line / log line and
|
||||
// split it; url.Parse would silently encode some and tolerate others, so
|
||||
// we refuse them up front. The 3-header identity contract in base
|
||||
// (claims.hasControlByte) rejects the same byte class for symmetry.
|
||||
if hasControlByte(path) {
|
||||
return "", nil, "invalid path", false
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(path)
|
||||
if err != nil {
|
||||
return "", nil, "invalid path", false
|
||||
}
|
||||
return method, parsed, "", true
|
||||
}
|
||||
|
||||
// httptest.NewRequest parses Path (incl. raw query) into URL.Path +
|
||||
// URL.RawQuery. The body is the verbatim client bytes.
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(f.Body))
|
||||
req = req.WithContext(ctx)
|
||||
// hasControlByte reports whether s contains any C0 control byte (< 0x20,
|
||||
// includes NUL/TAB/CR/LF) or DEL (0x7f). These are the universal
|
||||
// primitives for request-line / header / log injection and never appear in
|
||||
// a legitimate method or request-target. Mirrors base's claims parser.
|
||||
func hasControlByte(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if b := s[i]; b < 0x20 || b == 0x7f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handle turns one Forward into one Response by invoking h. Exported logic
|
||||
// lives here (not in the closure) so it is unit-testable without two live
|
||||
// nodes — feed a Forward, assert the Response.
|
||||
//
|
||||
// Hostile input is rejected BEFORE any *http.Request is constructed: a
|
||||
// malformed method or path yields a 400 Response, never a panic. (The prior
|
||||
// implementation called httptest.NewRequest directly, which panics on a
|
||||
// method containing a space or a path containing a control byte — an
|
||||
// unrecovered panic in the dispatch goroutine. node.go's dispatch now also
|
||||
// recovers as defence in depth, but the contract refuses to generate the
|
||||
// panic in the first place.)
|
||||
func handle(ctx context.Context, h http.Handler, f Forward) Response {
|
||||
method, u, reason, ok := validForwardLine(f.Method, f.Path)
|
||||
if !ok {
|
||||
return errorResponse(http.StatusBadRequest, reason)
|
||||
}
|
||||
|
||||
// Build the request from already-validated values via the non-panicking
|
||||
// constructor. http.NewRequestWithContext re-checks the method and URL
|
||||
// and returns an error (never panics) on anything we missed.
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(f.Body))
|
||||
if err != nil {
|
||||
return errorResponse(http.StatusBadRequest, "invalid request")
|
||||
}
|
||||
req.RequestURI = "" // a server-side request must not carry RequestURI
|
||||
|
||||
// Decode the client headers JSON onto the request.
|
||||
// Decode the client headers JSON onto the request. Reject header NAMES
|
||||
// that are not valid HTTP tokens and VALUES that carry CR/LF/NUL so a
|
||||
// crafted Headers blob cannot smuggle a header or split a downstream
|
||||
// log/response. (req.Header.Add would also reject these at write time in
|
||||
// recent Go, but we filter explicitly so behaviour is deterministic and
|
||||
// independent of stdlib internals.)
|
||||
if len(f.Headers) > 0 {
|
||||
var hdrs map[string][]string
|
||||
if err := json.Unmarshal(f.Headers, &hdrs); err == nil {
|
||||
for k, vs := range hdrs {
|
||||
if !isHTTPToken(k) {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
if !validHeaderValue(v) {
|
||||
continue
|
||||
}
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inject the pre-validated identity. Strip any client-supplied copies
|
||||
// first — only the edge may assert identity (gateway already strips
|
||||
// these inbound, this is defence-in-depth at the trust boundary).
|
||||
req.Header.Del(HeaderOrgID)
|
||||
req.Header.Del(HeaderUserID)
|
||||
req.Header.Del(HeaderUserIsAdmin)
|
||||
req.Header.Del(HeaderUserPerms)
|
||||
// Strip the FULL canonical identity set the client may have smuggled in
|
||||
// the Headers blob, THEN inject the edge-validated identity. Only the
|
||||
// edge may assert identity; the gateway strips inbound, this is
|
||||
// defence-in-depth at the ZAP trust boundary. Mirrors base's
|
||||
// claims.StripIdentityHeaders so the same forged-identity vectors are
|
||||
// closed on both legs of the contract.
|
||||
stripClientIdentity(req.Header)
|
||||
if f.TenantID != "" {
|
||||
req.Header.Set(HeaderOrgID, f.TenantID)
|
||||
}
|
||||
@@ -77,13 +165,22 @@ func handle(ctx context.Context, h http.Handler, f Forward) Response {
|
||||
req.Header.Set(HeaderUserIsAdmin, strconv.FormatBool(f.IsAdmin))
|
||||
req.Header.Set(HeaderUserPerms, strconv.FormatInt(f.Permissions, 10))
|
||||
|
||||
// TODO(stream): when h's handler flushes (http.Flusher), a flush-aware
|
||||
// ResponseWriter should emit Push frames (BuildPush, Encoding=EncSSE/
|
||||
// EncWS*) back to node for ConnID=f.ConnID instead of buffering. For now
|
||||
// we buffer the full response — correct, just not incremental.
|
||||
// Streaming guard: a handler that declares an SSE/streaming response
|
||||
// would otherwise be buffered until idle-timeout (no Push path yet —
|
||||
// see TODO(stream) below). Detect it post-hoc via the recorder's
|
||||
// committed headers and surface a clear 501 instead of hanging.
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if isStreamingResponse(rec.Header()) {
|
||||
// TODO(stream): when h flushes (http.Flusher) a flush-aware
|
||||
// ResponseWriter should emit Push frames (BuildPush, Encoding=
|
||||
// EncSSE/EncWS*) back to node for ConnID=f.ConnID instead of
|
||||
// buffering. Until then a streaming content-type cannot be served
|
||||
// over a single buffered Response — fail loud, don't hang.
|
||||
return errorResponse(http.StatusNotImplemented, "streaming not supported over Forward yet")
|
||||
}
|
||||
|
||||
hdrJSON, _ := json.Marshal(map[string][]string(rec.Header()))
|
||||
return Response{
|
||||
Status: rec.Code,
|
||||
@@ -91,3 +188,70 @@ func handle(ctx context.Context, h http.Handler, f Forward) Response {
|
||||
Body: rec.Body.Bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
// isStreamingResponse reports whether the handler committed a streaming
|
||||
// content type that the buffered Response path cannot carry. SSE is the
|
||||
// canonical case; chunked text/event-stream is detected by the media type
|
||||
// regardless of any trailing "; charset=" parameter.
|
||||
func isStreamingResponse(h http.Header) bool {
|
||||
ct := h.Get("Content-Type")
|
||||
if ct == "" {
|
||||
return false
|
||||
}
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(ct), "text/event-stream")
|
||||
}
|
||||
|
||||
// tokenTable marks the bytes that are valid in an HTTP token (RFC 7230
|
||||
// §3.2.6: tchar). A token excludes all CTLs (incl. NUL/CR/LF), SP, and the
|
||||
// separator set ()<>@,;:\"/[]?={}. This is the byte-for-byte table net/http
|
||||
// uses internally (its private isTokenTable). Kept local so this core
|
||||
// module needs no golang.org/x/net (which would drag golang.org/x/text via
|
||||
// idna) for two table lookups.
|
||||
var tokenTable = [256]bool{
|
||||
'!': true, '#': true, '$': true, '%': true, '&': true, '\'': true,
|
||||
'*': true, '+': true, '-': true, '.': true, '^': true, '_': true,
|
||||
'`': true, '|': true, '~': true,
|
||||
'0': true, '1': true, '2': true, '3': true, '4': true, '5': true,
|
||||
'6': true, '7': true, '8': true, '9': true,
|
||||
'A': true, 'B': true, 'C': true, 'D': true, 'E': true, 'F': true,
|
||||
'G': true, 'H': true, 'I': true, 'J': true, 'K': true, 'L': true,
|
||||
'M': true, 'N': true, 'O': true, 'P': true, 'Q': true, 'R': true,
|
||||
'S': true, 'T': true, 'U': true, 'V': true, 'W': true, 'X': true,
|
||||
'Y': true, 'Z': true,
|
||||
'a': true, 'b': true, 'c': true, 'd': true, 'e': true, 'f': true,
|
||||
'g': true, 'h': true, 'i': true, 'j': true, 'k': true, 'l': true,
|
||||
'm': true, 'n': true, 'o': true, 'p': true, 'q': true, 'r': true,
|
||||
's': true, 't': true, 'u': true, 'v': true, 'w': true, 'x': true,
|
||||
'y': true, 'z': true,
|
||||
}
|
||||
|
||||
// isHTTPToken reports whether s is a non-empty HTTP token (1*tchar). Used
|
||||
// to validate the request method and every client-supplied header NAME.
|
||||
func isHTTPToken(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if !tokenTable[s[i]] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether s is a safe HTTP header field-value: no
|
||||
// CR, LF, or NUL (the bytes that split a header / smuggle a response).
|
||||
// Matches httpguts.ValidHeaderFieldValue's rejection set; TAB and other
|
||||
// VCHAR/obs-text bytes are permitted as the RFC allows.
|
||||
func validHeaderValue(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '\r', '\n', 0x00:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package forward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// neverCalled is a handler that fails the test if it is ever invoked. Used
|
||||
// to prove hostile input is rejected BEFORE reaching the application.
|
||||
func neverCalled(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("handler must not run for hostile input; ran for %q %q", r.Method, r.URL)
|
||||
})
|
||||
}
|
||||
|
||||
// --- V4: hostile method / path → 400, NEVER a panic ---------------------
|
||||
|
||||
func TestHandleHostileMethodPathReturns400NotPanic(t *testing.T) {
|
||||
// Every one of these tripped httptest.NewRequest's panic in the old
|
||||
// implementation (method/path injected verbatim into a raw HTTP request
|
||||
// line fed to http.ReadRequest — a space split the line, CR/LF smuggled
|
||||
// a second line, NUL broke the parser). They must now yield a clean 400
|
||||
// with the handler NEVER invoked: these are the injection bytes that can
|
||||
// split a request/header/log, so the only safe action is to refuse.
|
||||
cases := []struct {
|
||||
name string
|
||||
method, path string
|
||||
}{
|
||||
{"method with space", "GE T", "/v1/x"},
|
||||
{"method with CRLF", "GET\r\nX-Evil: 1", "/v1/x"},
|
||||
{"method with NUL", "GET\x00", "/v1/x"},
|
||||
{"method with tab", "GET\t", "/v1/x"},
|
||||
{"method with separator comma", "GE,T", "/v1/x"},
|
||||
{"method with separator paren", "GET()", "/v1/x"},
|
||||
{"path with NUL", http.MethodGet, "/v1/x\x00"},
|
||||
{"path with CR", http.MethodGet, "/v1/x\r/y"},
|
||||
{"path with LF", http.MethodGet, "/v1/x\n/y"},
|
||||
{"path with CRLF header injection", http.MethodGet, "/v1/x\r\nX-Evil: 1"},
|
||||
{"path with control byte 0x1f", http.MethodGet, "/v1/\x1f"},
|
||||
{"path with DEL 0x7f", http.MethodGet, "/v1/\x7f"},
|
||||
{"path not absolute", http.MethodGet, "v1/x"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// If handle panicked, the test binary would crash; reaching the
|
||||
// assertion at all is half the proof. recover() here would only
|
||||
// fire if handle itself re-introduced a panic — it must not.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("handle panicked on hostile input (%q %q): %v", tc.method, tc.path, r)
|
||||
}
|
||||
}()
|
||||
resp := handle(context.Background(), neverCalled(t), Forward{
|
||||
Method: tc.method,
|
||||
Path: tc.path,
|
||||
})
|
||||
if resp.Status < 400 || resp.Status >= 500 {
|
||||
t.Fatalf("hostile input got status %d, want 4xx", resp.Status)
|
||||
}
|
||||
// Error body is well-formed JSON with an "error" key, never a leak.
|
||||
var e map[string]string
|
||||
if err := json.Unmarshal(resp.Body, &e); err != nil {
|
||||
t.Fatalf("error body not JSON: %q (%v)", resp.Body, err)
|
||||
}
|
||||
if e["error"] == "" {
|
||||
t.Errorf("error body missing reason: %q", resp.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSpaceInPathIsSafelyEncodedNotPanic documents that a SPACE in
|
||||
// the path — which crashed the old httptest.NewRequest path — is now safely
|
||||
// percent-encoded by url.ParseRequestURI rather than rejected. The space is
|
||||
// not an injection byte (it cannot split a header/log the way CR/LF/NUL
|
||||
// can); encoding it to %20 is the correct, non-lossy, non-panicking
|
||||
// outcome. The contract is "no panic, no smuggling", which encoding
|
||||
// satisfies.
|
||||
func TestHandleSpaceInPathIsSafelyEncodedNotPanic(t *testing.T) {
|
||||
var gotPath string
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path // decoded form
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("space in path panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
resp := handle(context.Background(), h, Forward{Method: http.MethodGet, Path: "/v1/x y"})
|
||||
if resp.Status != http.StatusOK {
|
||||
t.Fatalf("space-in-path got %d, want 200 (safely encoded)", resp.Status)
|
||||
}
|
||||
// The decoded path is intact; no second request line was smuggled.
|
||||
if gotPath != "/v1/x y" {
|
||||
t.Errorf("decoded path = %q, want %q", gotPath, "/v1/x y")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleEmptyMethodPathStillDefaults(t *testing.T) {
|
||||
// Backwards-compatible leniency: EMPTY method/path default to GET / "/".
|
||||
// (Only NON-empty malformed values are rejected.)
|
||||
var gotMethod, gotPath string
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod, gotPath = r.Method, r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
resp := handle(context.Background(), h, Forward{}) // all zero
|
||||
if resp.Status != http.StatusNoContent {
|
||||
t.Fatalf("status: got %d want 204", resp.Status)
|
||||
}
|
||||
if gotMethod != http.MethodGet || gotPath != "/" {
|
||||
t.Errorf("defaults wrong: method=%q path=%q want GET /", gotMethod, gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleValidMethodsPass(t *testing.T) {
|
||||
for _, m := range []string{
|
||||
http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
|
||||
http.MethodDelete, http.MethodHead, http.MethodOptions, "PROPFIND", "MKCOL",
|
||||
} {
|
||||
ran := false
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ran = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
resp := handle(context.Background(), h, Forward{Method: m, Path: "/v1/x"})
|
||||
if !ran {
|
||||
t.Errorf("method %q was rejected but is a valid token", m)
|
||||
}
|
||||
if resp.Status != http.StatusOK {
|
||||
t.Errorf("method %q got status %d want 200", m, resp.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- V2: FULL identity strip — smuggled identity must not survive --------
|
||||
|
||||
func TestHandleStripsFullIdentitySet(t *testing.T) {
|
||||
// A hostile client packs every identity-bearing header into the Headers
|
||||
// blob. None may reach the handler. Edge identity (here: only TenantID)
|
||||
// is the sole survivor for the canonical 4, injected AFTER the strip.
|
||||
smuggled := map[string][]string{
|
||||
HeaderOrgID: {"evil-org"},
|
||||
HeaderUserID: {"evil-user"},
|
||||
HeaderUserIsAdmin: {"true"},
|
||||
HeaderUserPerms: {"9223372036854775807"},
|
||||
"X-Roles": {"admin,root"},
|
||||
"X-User-Email": {"attacker@evil.test"},
|
||||
"X-User-Role": {"superuser"},
|
||||
"X-User-Roles": {"admin"},
|
||||
"X-User-Name": {"mallory"},
|
||||
"X-Tenant-Id": {"evil-tenant"},
|
||||
"X-Tenant-ID": {"evil-tenant-2"},
|
||||
"X-Org": {"evil"},
|
||||
"X-Is-Admin": {"1"},
|
||||
"X-Phone-Number": {"+10000000000"},
|
||||
"X-Gateway-Validated": {"true"},
|
||||
"X-Gateway-User-Id": {"evil"},
|
||||
"X-Gateway-Org-Id": {"evil"},
|
||||
"X-Gateway-User-Email": {"evil@evil.test"},
|
||||
"X-IAM-User": {"evil"},
|
||||
"X-IAM-Roles": {"admin"},
|
||||
"X-Hanzo-Org": {"evil"},
|
||||
"X-Hanzo-User-Id": {"evil"},
|
||||
"Cookie": {"session=hijacked; iam_token=forged"},
|
||||
// A benign header that MUST survive the strip.
|
||||
"Content-Type": {"application/json"},
|
||||
// Authorization MUST survive (the real auth path).
|
||||
"Authorization": {"Bearer real.jwt.token"},
|
||||
}
|
||||
blob, _ := json.Marshal(smuggled)
|
||||
|
||||
var seen http.Header
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handle(context.Background(), h, Forward{
|
||||
TenantID: "real-org", // the only edge-asserted identity
|
||||
Method: http.MethodGet,
|
||||
Path: "/v1/whoami",
|
||||
Headers: blob,
|
||||
})
|
||||
|
||||
// 1. Every smuggled identity header is gone or overwritten by the edge.
|
||||
if got := seen.Get(HeaderOrgID); got != "real-org" {
|
||||
t.Errorf("X-Org-Id: got %q, smuggled value must be replaced by edge 'real-org'", got)
|
||||
}
|
||||
if got := seen.Get(HeaderUserID); got != "" {
|
||||
t.Errorf("X-User-Id: smuggled %q survived (edge asserted none)", got)
|
||||
}
|
||||
// IsAdmin/Perms are re-injected from the (zero) Forward, overwriting the
|
||||
// smuggled "true"/max-int.
|
||||
if got := seen.Get(HeaderUserIsAdmin); got != "false" {
|
||||
t.Errorf("X-User-IsAdmin: got %q want false (smuggled true must be overridden)", got)
|
||||
}
|
||||
if got := seen.Get(HeaderUserPerms); got != "0" {
|
||||
t.Errorf("X-User-Permissions: got %q want 0 (smuggled max-int must be overridden)", got)
|
||||
}
|
||||
|
||||
// 2. Every NON-injected identity header is fully absent.
|
||||
for _, h := range []string{
|
||||
"X-Roles", "X-User-Email", "X-User-Role", "X-User-Roles", "X-User-Name",
|
||||
"X-Tenant-Id", "X-Tenant-ID", "X-Org", "X-Is-Admin", "X-Phone-Number",
|
||||
"X-Gateway-Validated", "X-Gateway-User-Id", "X-Gateway-Org-Id", "X-Gateway-User-Email",
|
||||
"X-IAM-User", "X-IAM-Roles", "X-Hanzo-Org", "X-Hanzo-User-Id",
|
||||
"Cookie",
|
||||
} {
|
||||
if v := seen.Get(h); v != "" {
|
||||
t.Errorf("identity header %q leaked through with value %q", h, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Authorization is PRESERVED — base validates the forwarded Bearer JWT.
|
||||
if got := seen.Get("Authorization"); got != "Bearer real.jwt.token" {
|
||||
t.Errorf("Authorization was stripped (%q); base needs it to validate the JWT", got)
|
||||
}
|
||||
// 4. Benign non-identity header survives.
|
||||
if got := seen.Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("benign Content-Type was dropped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestToForwardStripsFullIdentityFromEnvelope(t *testing.T) {
|
||||
// V2 gateway/ingress leg: a forged identity header must never even enter
|
||||
// the Forward envelope. requestToForward promotes the edge-validated 4
|
||||
// into typed fields and strips the FULL set from the carried Headers.
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://backend/v1/x", strings.NewReader("body"))
|
||||
req.Header.Set(HeaderOrgID, "real-org")
|
||||
req.Header.Set(HeaderUserID, "real-user")
|
||||
req.Header.Set("X-Roles", "admin")
|
||||
req.Header.Set("X-User-Email", "x@y.z")
|
||||
req.Header.Set("X-IAM-Token", "forged")
|
||||
req.Header.Set("X-Hanzo-Org", "evil")
|
||||
req.Header.Set("X-Gateway-Validated", "true")
|
||||
req.Header.Set("Cookie", "session=hijacked")
|
||||
req.Header.Set("Authorization", "Bearer real.jwt")
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
fwd, err := requestToForward(req)
|
||||
if err != nil {
|
||||
t.Fatalf("requestToForward: %v", err)
|
||||
}
|
||||
// The 4 are promoted to typed fields.
|
||||
if fwd.TenantID != "real-org" || fwd.UserID != "real-user" {
|
||||
t.Errorf("identity not promoted: org=%q user=%q", fwd.TenantID, fwd.UserID)
|
||||
}
|
||||
// The carried Headers blob contains NO identity header, but keeps
|
||||
// Authorization + Content-Type.
|
||||
var carried map[string][]string
|
||||
if len(fwd.Headers) > 0 {
|
||||
_ = json.Unmarshal(fwd.Headers, &carried)
|
||||
}
|
||||
get := func(k string) string {
|
||||
// http.Header canonicalization is applied by Clone()+Del; the JSON
|
||||
// keys are canonical. Look up case-insensitively to be safe.
|
||||
for kk, vv := range carried {
|
||||
if strings.EqualFold(kk, k) && len(vv) > 0 {
|
||||
return vv[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, leak := range []string{
|
||||
HeaderOrgID, HeaderUserID, "X-Roles", "X-User-Email",
|
||||
"X-IAM-Token", "X-Hanzo-Org", "X-Gateway-Validated", "Cookie",
|
||||
} {
|
||||
if v := get(leak); v != "" {
|
||||
t.Errorf("envelope carried forged identity header %q=%q", leak, v)
|
||||
}
|
||||
}
|
||||
if get("Authorization") != "Bearer real.jwt" {
|
||||
t.Errorf("Authorization missing from envelope; got %q", get("Authorization"))
|
||||
}
|
||||
if get("Content-Type") != "text/plain" {
|
||||
t.Errorf("benign Content-Type missing from envelope; got %q", get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- V4: hostile header name/value in the Headers blob is filtered -------
|
||||
|
||||
func TestHandleFiltersHostileHeaderNamesAndValues(t *testing.T) {
|
||||
// A crafted Headers blob with an invalid header name and a CRLF-bearing
|
||||
// value must not inject headers into the served request.
|
||||
blob, _ := json.Marshal(map[string][]string{
|
||||
"X-Good": {"fine"},
|
||||
"X-Bad Name": {"has space in name"}, // invalid token name
|
||||
"X-Inject": {"ok\r\nX-Smuggled: evil"}, // CRLF in value
|
||||
"X-NulVal": {"ok\x00evil"}, // NUL in value
|
||||
"X-Mixed": {"first-ok", "bad\r\nsecond"}, // one good, one bad value
|
||||
})
|
||||
var seen http.Header
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
resp := handle(context.Background(), h, Forward{Method: http.MethodGet, Path: "/v1/x", Headers: blob})
|
||||
if resp.Status != http.StatusOK {
|
||||
t.Fatalf("status: got %d want 200", resp.Status)
|
||||
}
|
||||
if seen.Get("X-Good") != "fine" {
|
||||
t.Errorf("valid header dropped: X-Good=%q", seen.Get("X-Good"))
|
||||
}
|
||||
if got := seen.Get("X-Bad Name"); got != "" {
|
||||
t.Errorf("invalid header name was added: %q", got)
|
||||
}
|
||||
if got := seen.Get("X-Smuggled"); got != "" {
|
||||
t.Errorf("CRLF value smuggled a second header X-Smuggled=%q", got)
|
||||
}
|
||||
if got := seen.Get("X-Inject"); got != "" {
|
||||
t.Errorf("CRLF-bearing value was added: %q", got)
|
||||
}
|
||||
if got := seen.Get("X-NulVal"); got != "" {
|
||||
t.Errorf("NUL-bearing value was added: %q", got)
|
||||
}
|
||||
// X-Mixed: only the good value survives.
|
||||
if vals := seen.Values("X-Mixed"); len(vals) != 1 || vals[0] != "first-ok" {
|
||||
t.Errorf("X-Mixed values: got %v want [first-ok]", vals)
|
||||
}
|
||||
}
|
||||
|
||||
// --- V6: streaming response over Forward → 501, not a hang ---------------
|
||||
|
||||
func TestHandleStreamingResponseReturns501(t *testing.T) {
|
||||
for _, ct := range []string{
|
||||
"text/event-stream",
|
||||
"text/event-stream; charset=utf-8",
|
||||
"Text/Event-Stream",
|
||||
} {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("data: hi\n\n"))
|
||||
})
|
||||
resp := handle(context.Background(), h, Forward{Method: http.MethodGet, Path: "/v1/stream"})
|
||||
if resp.Status != http.StatusNotImplemented {
|
||||
t.Errorf("ct=%q: got status %d want 501", ct, resp.Status)
|
||||
}
|
||||
if !strings.Contains(string(resp.Body), "streaming not supported") {
|
||||
t.Errorf("ct=%q: body %q lacks streaming-not-supported reason", ct, resp.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNonStreamingResponsePassesThrough(t *testing.T) {
|
||||
// A normal JSON response is NOT mistaken for streaming.
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
resp := handle(context.Background(), h, Forward{Method: http.MethodGet, Path: "/v1/x"})
|
||||
if resp.Status != http.StatusOK {
|
||||
t.Fatalf("non-streaming response got %d want 200", resp.Status)
|
||||
}
|
||||
if string(resp.Body) != `{"ok":true}` {
|
||||
t.Errorf("body: %q", resp.Body)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -494,7 +495,7 @@ func (n *Node) dispatchLoop(netConn net.Conn, conn *Conn, peerID string) {
|
||||
ref.release()
|
||||
continue
|
||||
}
|
||||
resp, herr := handler(n.ctx, peerID, msg)
|
||||
resp, herr := n.safeHandle(handler, peerID, msgType, msg)
|
||||
if herr != nil {
|
||||
n.logger.Error("Handler error", "peerID", peerID, "msgType", msgType, "error", herr)
|
||||
ref.release()
|
||||
@@ -531,7 +532,7 @@ func (n *Node) dispatchLoop(netConn net.Conn, conn *Conn, peerID string) {
|
||||
ref.release()
|
||||
continue
|
||||
}
|
||||
resp, herr := handler(n.ctx, peerID, msg)
|
||||
resp, herr := n.safeHandle(handler, peerID, msgType, msg)
|
||||
if herr != nil {
|
||||
n.logger.Error("Handler error", "peerID", peerID, "msgType", msgType, "error", herr)
|
||||
ref.release()
|
||||
@@ -728,7 +729,11 @@ func (n *Node) getOrConnect(peerID string) (*Conn, error) {
|
||||
n.handlersMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
handler(n.ctx, peerID, msg)
|
||||
// Guarded: a handler panic here must not kill this receive
|
||||
// goroutine / the node. The error return is intentionally
|
||||
// dropped — this is the fire-and-forget receive path for an
|
||||
// outbound-dialed peer; safeHandle has already logged.
|
||||
_, _ = n.safeHandle(handler, peerID, msgType, msg)
|
||||
}
|
||||
ref.release()
|
||||
}
|
||||
@@ -958,3 +963,28 @@ func attachToMessage(m *Message, ref *bufRef) {
|
||||
m.refs = ref
|
||||
}
|
||||
}
|
||||
|
||||
// safeHandle invokes a registered handler with a recover() guard. A handler
|
||||
// is application code (e.g. forward.Serve's HTTP bridge) reachable directly
|
||||
// from attacker-controlled bytes on the wire; a panic inside it — out-of-
|
||||
// range index on a malformed envelope, a nil deref, a third-party library
|
||||
// fault — must NEVER unwind into the per-connection dispatch goroutine and
|
||||
// crash the whole node/process. On panic we log with the peer and message
|
||||
// type and return an error so the dispatch loop drops that one connection;
|
||||
// every other connection and the node itself survive.
|
||||
//
|
||||
// This is the single recover boundary for ALL handler dispatch in this
|
||||
// node (correlated Call requests and uncorrelated messages alike). It is
|
||||
// the one-and-only place a handler panic is contained.
|
||||
func (n *Node) safeHandle(handler Handler, peerID string, msgType uint16, msg *Message) (resp *Message, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
n.logger.Error("ZAP handler panic recovered",
|
||||
"peerID", peerID, "msgType", msgType, "panic", r,
|
||||
"stack", string(debug.Stack()))
|
||||
resp = nil
|
||||
err = fmt.Errorf("handler panic (msgType=%d): %v", msgType, r)
|
||||
}
|
||||
}()
|
||||
return handler(n.ctx, peerID, msg)
|
||||
}
|
||||
|
||||
+11
-7
@@ -204,7 +204,10 @@ func (n *Node) handleCallStream(peerID string, stream TransportStream) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, herr := handler(n.ctx, peerID, msg)
|
||||
// Guarded: a handler panic on the QUIC per-Call stream must not crash
|
||||
// the node any more than on the TCP path. safeHandle recovers, logs,
|
||||
// and converts the panic to an error; the stream is then closed.
|
||||
resp, herr := n.safeHandle(handler, peerID, msgType, msg)
|
||||
if herr != nil {
|
||||
n.logger.Error("Handler error (stream)", "peerID", peerID, "msgType", msgType, "error", herr)
|
||||
return
|
||||
@@ -227,7 +230,9 @@ func (n *Node) invokeHandlerOneWay(peerID string, msg *Message) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, _ = handler(n.ctx, peerID, msg)
|
||||
// Guarded: same recover boundary as every other dispatch path so a
|
||||
// panicking one-way handler cannot kill the node.
|
||||
_, _ = n.safeHandle(handler, peerID, msgType, msg)
|
||||
}
|
||||
|
||||
// quicCall is the QUIC path for Node.Call.
|
||||
@@ -255,10 +260,10 @@ func (n *Node) quicCall(ctx context.Context, peerID string, msg *Message) (*Mess
|
||||
// quicCallStream runs one Call on a fresh per-Call QUIC stream.
|
||||
//
|
||||
// The stream lifecycle is:
|
||||
// 1. OpenCallStream — server peer's AcceptCallStream wakes.
|
||||
// 2. WriteFrame(req) — single length-prefixed ZAP frame.
|
||||
// 3. ReadFrame() — single length-prefixed ZAP frame (response).
|
||||
// 4. Close — stream ID returns to the QUIC pool.
|
||||
// 1. OpenCallStream — server peer's AcceptCallStream wakes.
|
||||
// 2. WriteFrame(req) — single length-prefixed ZAP frame.
|
||||
// 3. ReadFrame() — single length-prefixed ZAP frame (response).
|
||||
// 4. Close — stream ID returns to the QUIC pool.
|
||||
//
|
||||
// No correlation header is needed on the wire: each stream carries
|
||||
// exactly one request + one response. The response payload is the raw
|
||||
@@ -379,4 +384,3 @@ func (n *Node) getOrConnectQUIC(ctx context.Context, peerID string) (TransportCo
|
||||
go n.serveTransportConn(peerID, newTC)
|
||||
return newTC, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
zap "github.com/luxfi/zap"
|
||||
)
|
||||
|
||||
// buildTypedMsg builds a one-field message tagged msgType so the dispatcher
|
||||
// routes it to that handler. Mirrors buildEchoMessage but with a caller-
|
||||
// chosen type.
|
||||
func buildTypedMsg(t *testing.T, msgType uint16, v uint64) *zap.Message {
|
||||
t.Helper()
|
||||
b := zap.NewBuilder(64)
|
||||
o := b.StartObject(16)
|
||||
o.SetUint64(0, v)
|
||||
o.FinishAsRoot()
|
||||
msg, err := zap.Parse(b.FinishWithFlags(msgType << 8))
|
||||
if err != nil {
|
||||
t.Fatalf("build typed msg: %v", err)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestQUICHandlerPanicDoesNotCrashNode proves the recover() guard also
|
||||
// covers the QUIC transport's per-Call stream dispatch (handleCallStream).
|
||||
// A handler that panics over QUIC must not unwind into the stream goroutine
|
||||
// and crash the node: safeHandle recovers, the panicking Call fails/closes,
|
||||
// and a subsequent valid Call to a different handler on the SAME server node
|
||||
// still succeeds.
|
||||
func TestQUICHandlerPanicDoesNotCrashNode(t *testing.T) {
|
||||
const (
|
||||
qmtPanic uint16 = 0x93
|
||||
qmtEcho uint16 = 0x94
|
||||
)
|
||||
srvAddr, srv, cli := newQUICTestPair(t, "qsrv-panic", "qcli-panic")
|
||||
defer srv.Stop()
|
||||
defer cli.Stop()
|
||||
|
||||
srv.Handle(qmtPanic, func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
||||
panic("quic handler blew up")
|
||||
})
|
||||
srv.Handle(qmtEcho, func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
||||
return buildTypedMsg(t, qmtEcho, msg.Root().Uint64(0)+1), nil
|
||||
})
|
||||
|
||||
if err := cli.ConnectDirect(srvAddr); err != nil {
|
||||
t.Fatalf("ConnectDirect: %v", err)
|
||||
}
|
||||
waitForPeer(t, srv, "qcli-panic", 2*time.Second)
|
||||
waitForPeer(t, cli, "qsrv-panic", 2*time.Second)
|
||||
|
||||
// 1. Fire the panic-inducing Call over QUIC. The stream is torn down on
|
||||
// recover; the Call errors or times out. Process MUST stay up.
|
||||
ctx1, cancel1 := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_, err := cli.Call(ctx1, "qsrv-panic", buildTypedMsg(t, qmtPanic, 1))
|
||||
cancel1()
|
||||
if err == nil {
|
||||
t.Log("QUIC panic Call returned nil error; node-survival is the real assertion")
|
||||
} else {
|
||||
t.Logf("QUIC panic Call errored as expected: %v", err)
|
||||
}
|
||||
|
||||
// 2. THE PROOF: the server node survived. A valid echo Call on the same
|
||||
// node over a fresh QUIC stream still works.
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel2()
|
||||
resp, err := cli.Call(ctx2, "qsrv-panic", buildTypedMsg(t, qmtEcho, 41))
|
||||
if err != nil {
|
||||
t.Fatalf("echo Call after QUIC handler panic failed — node did not survive: %v", err)
|
||||
}
|
||||
if got := resp.Root().Uint64(0); got != 42 {
|
||||
t.Errorf("echo returned %d want 42 (node alive but handler misbehaved)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pickFreePortNode returns an OS-assigned free TCP port.
|
||||
func pickFreePortNode(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
return port
|
||||
}
|
||||
|
||||
func waitPeersNode(t *testing.T, n *Node) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(n.Peers()) > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("no peers after 2s")
|
||||
}
|
||||
|
||||
const (
|
||||
mtPanic uint16 = 0x90 // handler that panics
|
||||
mtEcho uint16 = 0x91 // handler that echoes — proves the node survived
|
||||
)
|
||||
|
||||
// buildTyped makes a minimal typed message carrying a single uint32 so the
|
||||
// dispatcher routes it by msgType (Flags()>>8) and the echo handler has
|
||||
// something to read back.
|
||||
func buildTyped(t *testing.T, msgType uint16, payload uint32) *Message {
|
||||
t.Helper()
|
||||
b := NewBuilder(64)
|
||||
ob := b.StartObject(8)
|
||||
ob.SetUint32(0, payload)
|
||||
ob.FinishAsRoot()
|
||||
msg, err := Parse(b.FinishWithFlags(msgType << 8))
|
||||
if err != nil {
|
||||
t.Fatalf("build typed msg: %v", err)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestHandlerPanicDoesNotCrashNode proves the V4 dispatch-layer fix: a
|
||||
// handler that panics on a Call request must NOT unwind into the node's
|
||||
// dispatch goroutine and crash the process. The recover() in safeHandle
|
||||
// must fire, the offending Call returns an error to the caller (the
|
||||
// connection is dropped), and the SERVER NODE keeps running — a fresh
|
||||
// connection + a valid Call to a different handler still succeeds.
|
||||
func TestHandlerPanicDoesNotCrashNode(t *testing.T) {
|
||||
srvPort := pickFreePortNode(t)
|
||||
cliPort := pickFreePortNode(t)
|
||||
|
||||
srv := NewNode(NodeConfig{NodeID: "server", Port: srvPort, NoDiscovery: true})
|
||||
cli := NewNode(NodeConfig{NodeID: "client", Port: cliPort, NoDiscovery: true})
|
||||
|
||||
// A handler that always panics — simulates an out-of-range index on a
|
||||
// malformed envelope, a nil deref, or a third-party fault.
|
||||
srv.Handle(mtPanic, func(ctx context.Context, from string, msg *Message) (*Message, error) {
|
||||
panic("hostile input blew up the handler")
|
||||
})
|
||||
// A well-behaved echo handler on a different type.
|
||||
srv.Handle(mtEcho, func(ctx context.Context, from string, msg *Message) (*Message, error) {
|
||||
v := msg.Root().Uint32(0)
|
||||
return buildTyped(t, mtEcho, v+1), nil
|
||||
})
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatalf("srv.Start: %v", err)
|
||||
}
|
||||
defer srv.Stop()
|
||||
if err := cli.Start(); err != nil {
|
||||
t.Fatalf("cli.Start: %v", err)
|
||||
}
|
||||
defer cli.Stop()
|
||||
|
||||
if err := cli.ConnectDirect("127.0.0.1:" + itoa(srvPort)); err != nil {
|
||||
t.Fatalf("ConnectDirect: %v", err)
|
||||
}
|
||||
waitPeersNode(t, cli)
|
||||
|
||||
// 1. Fire the panic-inducing Call. safeHandle recovers; the handler
|
||||
// returns an error, so the dispatch loop logs + drops that connection.
|
||||
// From the caller's view the Call either errors or times out — either
|
||||
// way it does NOT bring the process down.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_, err := cli.Call(ctx, "server", buildTyped(t, mtPanic, 1))
|
||||
cancel()
|
||||
// We don't assert the exact error: the contract is "node survives", not
|
||||
// "specific error shape". A nil error would be wrong (no valid response
|
||||
// exists), but the load-bearing assertion is step 2 below.
|
||||
if err == nil {
|
||||
t.Log("panic Call returned nil error (response channel closed); node-survival is the real assertion")
|
||||
} else {
|
||||
t.Logf("panic Call errored as expected: %v", err)
|
||||
}
|
||||
|
||||
// 2. THE PROOF: the server node is still alive. Reconnect (the prior
|
||||
// conn may have been dropped) and issue a valid Call to the echo
|
||||
// handler. If the node had crashed, this fails.
|
||||
cli2 := NewNode(NodeConfig{NodeID: "client2", Port: pickFreePortNode(t), NoDiscovery: true})
|
||||
if err := cli2.Start(); err != nil {
|
||||
t.Fatalf("cli2.Start: %v", err)
|
||||
}
|
||||
defer cli2.Stop()
|
||||
if err := cli2.ConnectDirect("127.0.0.1:" + itoa(srvPort)); err != nil {
|
||||
t.Fatalf("server node died after handler panic — reconnect failed: %v", err)
|
||||
}
|
||||
waitPeersNode(t, cli2)
|
||||
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel2()
|
||||
resp, err := cli2.Call(ctx2, "server", buildTyped(t, mtEcho, 41))
|
||||
if err != nil {
|
||||
t.Fatalf("echo Call after panic failed — node did not survive: %v", err)
|
||||
}
|
||||
if got := resp.Root().Uint32(0); got != 42 {
|
||||
t.Errorf("echo returned %d want 42 (node alive but handler misbehaved)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// itoa is a tiny strconv.Itoa to keep this test file's imports minimal.
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
pos := len(b)
|
||||
for i > 0 {
|
||||
pos--
|
||||
b[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
return string(b[pos:])
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cross-wire conformance: this runtime (github.com/luxfi/zap) and the
|
||||
// pure-stdlib baseline runtime (github.com/zap-proto/go) implement the SAME
|
||||
// wire format. zap-proto/go is the canonical runtime this package is
|
||||
// converging onto (see MIGRATION.md "ZAP runtime consolidation"). This test
|
||||
// pins the contract from THIS side WITHOUT importing zap-proto/go, so neither
|
||||
// repo takes a build dependency on the other yet.
|
||||
//
|
||||
// The proof meets the zap-proto/go side at a shared golden vector: both repos
|
||||
// pin the byte-for-byte identical goldenV1Hex constant and assert their own
|
||||
// Builder emits it / their own reader decodes it. Changing the wire on one
|
||||
// side without the other is a wire break and MUST fail CI in both repos.
|
||||
//
|
||||
// field @0 uint32 = 0xDEADBEEF
|
||||
// field @8 text = "zap"
|
||||
// field @16 bytes = 01 02 03 04
|
||||
// dataSize = 24
|
||||
//
|
||||
// luxfi/zap's default NewBuilder tags Version2 (the v3 platformvm TxKind
|
||||
// discriminator schema). The shared golden vector is a Version1 header, so the
|
||||
// luxfi encode side builds via NewBuilderV1 to meet zap-proto's default
|
||||
// (Version1) byte-for-byte. The v2 default is asserted to differ ONLY at the
|
||||
// version byte — the single documented header delta between the runtimes.
|
||||
const (
|
||||
xwU32 = 0
|
||||
xwText = 8
|
||||
xwBytes = 16
|
||||
xwSize = 24
|
||||
|
||||
// goldenV1Hex — SHARED VERBATIM with zap-proto/go's zap_crosswire_test.go.
|
||||
// 47-byte canonical ZAP buffer (Version1) for the layout above.
|
||||
goldenV1Hex = "5a41500001000000100000002f000000efbeadde0000000010000000030000000b000000040000007a617001020304"
|
||||
)
|
||||
|
||||
func buildCanonicalV1(tb testing.TB) []byte {
|
||||
tb.Helper()
|
||||
b := NewBuilderV1(64)
|
||||
ob := b.StartObject(xwSize)
|
||||
ob.SetUint32(xwU32, 0xDEADBEEF)
|
||||
ob.SetText(xwText, "zap")
|
||||
ob.SetBytes(xwBytes, []byte{0x01, 0x02, 0x03, 0x04})
|
||||
ob.FinishAsRoot()
|
||||
return b.Finish()
|
||||
}
|
||||
|
||||
func buildCanonicalV2(tb testing.TB) []byte {
|
||||
tb.Helper()
|
||||
b := NewBuilder(64) // default = Version2
|
||||
ob := b.StartObject(xwSize)
|
||||
ob.SetUint32(xwU32, 0xDEADBEEF)
|
||||
ob.SetText(xwText, "zap")
|
||||
ob.SetBytes(xwBytes, []byte{0x01, 0x02, 0x03, 0x04})
|
||||
ob.FinishAsRoot()
|
||||
return b.Finish()
|
||||
}
|
||||
|
||||
// TestCrossWireGoldenEncodeV1 proves luxfi's Builder (v1 mode) emits exactly
|
||||
// the shared golden vector — byte-for-byte identical to zap-proto/go's
|
||||
// default NewBuilder output. This is the load-bearing "the two runtimes
|
||||
// produce the same wire" assertion.
|
||||
func TestCrossWireGoldenEncodeV1(t *testing.T) {
|
||||
got := buildCanonicalV1(t)
|
||||
want, err := hex.DecodeString(goldenV1Hex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode goldenV1Hex: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("luxfi v1 encode != shared golden:\n got %s\n want %s", hex.EncodeToString(got), goldenV1Hex)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossWireGoldenDecode proves luxfi's reader decodes the shared golden
|
||||
// vector field-by-field — the "decode with the other" leg (the bytes are the
|
||||
// canonical wire, identical to zap-proto's NewBuilder output).
|
||||
func TestCrossWireGoldenDecode(t *testing.T) {
|
||||
buf, err := hex.DecodeString(goldenV1Hex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode goldenV1Hex: %v", err)
|
||||
}
|
||||
msg, err := Parse(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(golden) failed: %v", err)
|
||||
}
|
||||
if msg.Version() != Version1 {
|
||||
t.Fatalf("golden Version = %d, want %d", msg.Version(), Version1)
|
||||
}
|
||||
r := msg.Root()
|
||||
if got := r.Uint32(xwU32); got != 0xDEADBEEF {
|
||||
t.Errorf("Uint32 = %#x, want 0xDEADBEEF", got)
|
||||
}
|
||||
if got := r.Text(xwText); got != "zap" {
|
||||
t.Errorf("Text = %q, want %q", got, "zap")
|
||||
}
|
||||
if got := r.Bytes(xwBytes); !bytes.Equal(got, []byte{0x01, 0x02, 0x03, 0x04}) {
|
||||
t.Errorf("Bytes = %v, want [1 2 3 4]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossWireV2DiffersOnlyAtVersionByte proves the single documented header
|
||||
// delta: luxfi's default NewBuilder (Version2) output equals the golden v1
|
||||
// vector with ONLY byte 4 changed from 1 to 2. The data segment past the
|
||||
// magic+version prefix is byte-identical. This is exactly the buffer zap-proto
|
||||
// must (now) accept on read — verified on the zap-proto side by
|
||||
// TestCrossWireAcceptsV2.
|
||||
func TestCrossWireV2DiffersOnlyAtVersionByte(t *testing.T) {
|
||||
v1, err := hex.DecodeString(goldenV1Hex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode goldenV1Hex: %v", err)
|
||||
}
|
||||
v2 := buildCanonicalV2(t)
|
||||
|
||||
if len(v2) != len(v1) {
|
||||
t.Fatalf("v2 len %d != v1 len %d", len(v2), len(v1))
|
||||
}
|
||||
if v2[4] != byte(Version2) {
|
||||
t.Fatalf("v2 version byte = %d, want %d", v2[4], Version2)
|
||||
}
|
||||
// Reconstruct the expected v2 buffer: v1 with byte 4 := 2.
|
||||
wantV2 := append([]byte(nil), v1...)
|
||||
wantV2[4] = byte(Version2)
|
||||
if !bytes.Equal(v2, wantV2) {
|
||||
t.Fatalf("luxfi v2 encode differs from v1 in more than the version byte:\n v2 %s\n v1 %s", hex.EncodeToString(v2), goldenV1Hex)
|
||||
}
|
||||
// Data segment identity (past magic+version).
|
||||
if !bytes.Equal(v1[6:], v2[6:]) {
|
||||
t.Fatal("v1 and v2 data segments diverge past the version byte")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user