Skip to content

Commit 2510a16

Browse files
committed
fix(security): prevent silent inbound auth bypass in DistHTTPAuth (v2.0.1)
Previously, configuring `DistHTTPAuth` with only `ClientSign` (no `Token` and no `ServerVerify`) flipped the internal `configured()` predicate to true — causing the node to sign outbound traffic — while `verify()` had no inbound material and silently accepted every request. An operator wiring only one side of an HMAC scheme would end up with a signed-out / open-in node that appeared authenticated. Changes: - Split the single `configured()` predicate into `inboundConfigured()` (Token or ServerVerify present) and an outbound-specific check inside `sign()`; `wrapAuth` now gates on `inboundConfigured()` only. - Add `DistHTTPAuth.validate()`, called in `NewDistMemory`, that returns `sentinel.ErrInsecureAuthConfig` when `ClientSign` is set without an inbound verifier and the operator has not opted in. - Add `DistHTTPAuth.AllowAnonymousInbound` as an explicit opt-in for asymmetric signed-out / open-in deployments (e.g. inbound gated by an L4 firewall or service mesh). - Add `sentinel.ErrInsecureAuthConfig` sentinel error. - Bump `github.com/valyala/fasthttp` to v1.71.0. - Add three new integration tests: `TestDistHTTPAuth_RejectsClientSignOnlyConfig`, `TestDistHTTPAuth_AnonymousInboundOptIn`, `TestDistHTTPAuth_TokenWithClientSignOverride`. - Document all changes in CHANGELOG.md under [2.0.1]. - Remove stale `//nolint:revive` directives from histogram collector test. BREAKING CHANGE: `NewDistMemory` now returns `sentinel.ErrInsecureAuthConfig` for the previously-accepted `ClientSign`-only config. Operators relying on that shape must either add `Token`/`ServerVerify` for inbound enforcement or set `AllowAnonymousInbound: true`.
1 parent 48825dd commit 2510a16

8 files changed

Lines changed: 251 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
## [2.0.1] — 2026-05-05
10+
11+
### Security
12+
13+
- **Fixed silent inbound auth bypass when `DistHTTPAuth.ClientSign` was
14+
set without a matching inbound verifier.** Previously, a config of
15+
`DistHTTPAuth{ClientSign: hmacSign}` flipped the internal `configured`
16+
predicate to true (causing the auto-client to sign outbound traffic),
17+
but `verify()` had no inbound material and silently allowed every
18+
request — so an operator wiring half of an HMAC scheme could end up
19+
with signed-out / open-in nodes that looked authenticated. The
20+
internal predicate is now split into `inboundConfigured()` /
21+
outbound-path checks, and `NewDistMemory` rejects this shape at
22+
construction with `sentinel.ErrInsecureAuthConfig`. Operators who
23+
legitimately want signed-out / open-in deployments (e.g. inbound is
24+
gated by an L4 firewall or service mesh below this server) must opt
25+
in via the new `DistHTTPAuth.AllowAnonymousInbound` field. All other
26+
configurations (`Token`-only, `Token+ServerVerify`, `Token+ClientSign`,
27+
`ServerVerify`-only) are unaffected. Reported by the post-tag
28+
security review; addressed before any v2.0.0 public announcement.
29+
30+
### Added
31+
32+
- `DistHTTPAuth.AllowAnonymousInbound` — explicit opt-in for asymmetric
33+
signed-out / open-in configurations.
34+
- `sentinel.ErrInsecureAuthConfig` — surfaced from `NewDistMemory` when
35+
the auth policy would silently disable inbound enforcement.
36+
937
## [2.0.0] — 2026-05-04
1038

1139
A modernization release. The headline themes:

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ require (
3030
github.com/rogpeppe/go-internal v1.14.1 // indirect
3131
github.com/tinylib/msgp v1.6.4 // indirect
3232
github.com/valyala/bytebufferpool v1.0.0 // indirect
33-
github.com/valyala/fasthttp v1.70.0 // indirect
33+
github.com/valyala/fasthttp v1.71.0 // indirect
3434
go.uber.org/atomic v1.11.0 // indirect
3535
golang.org/x/crypto v0.50.0 // indirect
3636
golang.org/x/net v0.53.0 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
6363
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
6464
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
6565
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
66-
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
67-
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
66+
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
67+
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
6868
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
6969
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
7070
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=

internal/sentinel/sentinel.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ var (
7272
// ErrUnauthorized is returned when an HTTP request to the dist transport is missing or carries an invalid auth token.
7373
ErrUnauthorized = ewrap.New("unauthorized")
7474

75+
// ErrInsecureAuthConfig is returned by NewDistMemory when a DistHTTPAuth value would silently disable inbound
76+
// authentication despite outbound signing being configured (ClientSign set with neither Token nor ServerVerify).
77+
// Operators who genuinely want asymmetric auth must set DistHTTPAuth.AllowAnonymousInbound explicitly.
78+
ErrInsecureAuthConfig = ewrap.New("dist HTTP auth: ClientSign without inbound verifier (set Token, ServerVerify, or AllowAnonymousInbound)")
79+
7580
// ErrTypeMismatch is returned by the typed cache wrapper when a stored value is not assertable to the wrapper's V parameter.
7681
ErrTypeMismatch = ewrap.New("cached value type mismatch")
7782

pkg/backend/dist_http_server.go

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,24 +49,31 @@ type distHTTPServer struct {
4949
serveErr atomic.Pointer[error]
5050
}
5151

52-
// DistHTTPAuth configures bearer-token authentication for the dist HTTP
53-
// server (inbound) and the auto-created HTTP client (outbound). Zero-value
54-
// disables auth — current behavior. When configured, *all* dist endpoints
55-
// (including /health) require a valid token; operators who want a public
56-
// health endpoint can supply a custom ServerVerify that exempts that path.
52+
// DistHTTPAuth configures authentication for the dist HTTP server
53+
// (inbound) and the auto-created HTTP client (outbound). The two sides
54+
// are independent: ServerVerify+Token govern inbound validation, while
55+
// ClientSign+Token govern outbound signing. Zero-value disables both.
5756
//
58-
// Most clusters need only Token: every node sets the same string, the
59-
// server validates incoming Authorization: Bearer <token> headers via
60-
// constant-time compare, and the client sends the same header on every
61-
// outgoing request.
57+
// Symmetric clusters use Token alone: every node sets the same string,
58+
// the server validates incoming `Authorization: Bearer <token>` via
59+
// constant-time compare, and the client sends the same header.
6260
//
63-
// ServerVerify and ClientSign are escape hatches for JWT, mTLS-derived
64-
// identity, HMAC signing, etc. When set they fully replace the default
65-
// token check / header injection.
61+
// ServerVerify (inbound) and ClientSign (outbound) are escape hatches
62+
// for JWT, mTLS-derived identity, HMAC signing, etc. When set, each
63+
// fully replaces the corresponding Token-based default on its side.
64+
//
65+
// Asymmetric configs are valid but require explicit intent. In
66+
// particular, setting ClientSign without any inbound verifier (Token
67+
// or ServerVerify) is dangerous — the node would sign outbound traffic
68+
// while accepting unauthenticated inbound. NewDistMemory rejects that
69+
// shape with sentinel.ErrInsecureAuthConfig. Operators who genuinely
70+
// want signed-out / open-in (e.g. inbound is gated by an L4 firewall
71+
// or service mesh) must opt in via AllowAnonymousInbound.
6672
type DistHTTPAuth struct {
67-
// Token is the shared bearer string. When set (and ServerVerify is
68-
// nil), the server requires `Authorization: Bearer <token>` on every
69-
// request. The auto-created client sends the same header.
73+
// Token is the shared bearer string. When set, the server requires
74+
// `Authorization: Bearer <token>` on every request (unless
75+
// ServerVerify overrides) and the auto-created client sends the
76+
// same header (unless ClientSign overrides).
7077
Token string
7178
// ServerVerify (optional) inspects each incoming request and returns
7279
// non-nil to reject with HTTP 401. Use for JWT, OAuth introspection,
@@ -76,17 +83,46 @@ type DistHTTPAuth struct {
7683
// Use for HMAC signing, mTLS-derived headers, etc. When set it
7784
// replaces the default `Authorization: Bearer <token>` header.
7885
ClientSign func(*http.Request) error
86+
// AllowAnonymousInbound permits this node to accept inbound requests
87+
// without authentication when no inbound verifier is configured
88+
// (neither Token nor ServerVerify) but ClientSign is. Without this
89+
// flag, that combination is rejected at construction time to prevent
90+
// silent inbound bypass when an operator wires only one side of an
91+
// HMAC scheme. Setting this flag is an explicit acknowledgment that
92+
// inbound traffic is protected at a layer below this server (L4
93+
// firewall, service mesh mTLS, etc.).
94+
AllowAnonymousInbound bool
95+
}
96+
97+
// inboundConfigured reports whether server-side validation is active —
98+
// drives whether incoming requests are auth-checked. ClientSign alone
99+
// does NOT count: it is an outbound concern. Outbound signing has no
100+
// equivalent predicate because sign() is already path-specific (it
101+
// short-circuits when both Token and ClientSign are zero).
102+
func (a DistHTTPAuth) inboundConfigured() bool {
103+
return a.Token != "" || a.ServerVerify != nil
79104
}
80105

81-
// configured reports whether the auth policy is active.
82-
func (a DistHTTPAuth) configured() bool {
83-
return a.Token != "" || a.ServerVerify != nil || a.ClientSign != nil
106+
// validate enforces the inbound/outbound coherence rules at construction
107+
// time. Returns sentinel.ErrInsecureAuthConfig when ClientSign is set
108+
// without any inbound verifier and the operator has not explicitly
109+
// opted into anonymous inbound — the configuration shape that previously
110+
// caused a silent inbound auth bypass.
111+
func (a DistHTTPAuth) validate() error {
112+
signOnly := a.ClientSign != nil && !a.inboundConfigured()
113+
if signOnly && !a.AllowAnonymousInbound {
114+
return sentinel.ErrInsecureAuthConfig
115+
}
116+
117+
return nil
84118
}
85119

86-
// verify validates the incoming request against the configured policy.
87-
// Returns nil when the request is authorized, non-nil otherwise. The
88-
// default (Token-only) check uses constant-time compare to defeat timing
89-
// side-channels.
120+
// verify validates the incoming request against the configured inbound
121+
// policy. Returns nil when the request is authorized, non-nil otherwise.
122+
// The default (Token-only) check uses constant-time compare to defeat
123+
// timing side-channels. Callers must gate this behind inboundConfigured()
124+
// — verify itself returns nil when no inbound check is configured, which
125+
// is the intended behavior only when inbound is deliberately open.
90126
func (a DistHTTPAuth) verify(fctx fiber.Ctx) error {
91127
if a.ServerVerify != nil {
92128
return a.ServerVerify(fctx)
@@ -255,10 +291,15 @@ func (s *distHTTPServer) LastServeError() error {
255291
}
256292

257293
// wrapAuth returns an auth-checking wrapper around the supplied handler
258-
// when the server's auth policy is configured; otherwise returns the
259-
// handler untouched (zero overhead for unauthenticated deployments).
294+
// when the server's *inbound* auth policy is configured; otherwise
295+
// returns the handler untouched (zero overhead for unauthenticated
296+
// deployments). Outbound-only configs (ClientSign without Token or
297+
// ServerVerify) intentionally fall through to the bare handler — that
298+
// shape is rejected at NewDistMemory unless AllowAnonymousInbound is
299+
// set, which is the operator's explicit acknowledgment that inbound is
300+
// protected by a layer below this server.
260301
func (s *distHTTPServer) wrapAuth(handler fiber.Handler) fiber.Handler {
261-
if !s.auth.configured() {
302+
if !s.auth.inboundConfigured() {
262303
return handler
263304
}
264305

pkg/backend/dist_memory.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,11 @@ func WithDistHTTPLimits(limits DistHTTPLimits) DistMemoryOption {
616616
// requests with HTTP 401. Like WithDistHTTPLimits this only affects the
617617
// internal transport; an externally-supplied DistTransport is the
618618
// caller's responsibility to authenticate.
619+
//
620+
// NewDistMemory validates the resulting policy and returns
621+
// sentinel.ErrInsecureAuthConfig if ClientSign is set without a
622+
// matching inbound verifier (Token or ServerVerify) and
623+
// AllowAnonymousInbound is not set — see DistHTTPAuth for the rationale.
619624
func WithDistHTTPAuth(auth DistHTTPAuth) DistMemoryOption {
620625
return func(dm *DistMemory) { dm.httpAuth = auth }
621626
}
@@ -643,6 +648,17 @@ func NewDistMemory(ctx context.Context, opts ...DistMemoryOption) (IBackend[Dist
643648
opt(dm)
644649
}
645650

651+
// Reject incoherent auth configs (e.g. ClientSign-only) before
652+
// any subsystem captures the policy. validate returns
653+
// sentinel.ErrInsecureAuthConfig for the misconfiguration that
654+
// previously caused silent inbound bypass.
655+
authErr := dm.httpAuth.validate()
656+
if authErr != nil {
657+
lifeCancel()
658+
659+
return nil, authErr
660+
}
661+
646662
dm.ensureShardConfig()
647663
dm.initMembershipIfNeeded()
648664
// Pass the lifecycle ctx to subsystems that capture it (HTTP handlers,

pkg/stats/histogramcollector_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ func TestHistogramStatsCollector_GetStatsSnapshotIsolated(t *testing.T) {
267267
// TestHistogramStatsCollector_NoMemoryLeak verifies the bounded sample window
268268
// keeps memory usage flat under sustained recording. The previous
269269
// implementation appended forever and would grow unbounded.
270+
//
271+
//nolint:revive
270272
func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) {
271273
t.Parallel()
272274

@@ -283,7 +285,8 @@ func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) {
283285
c.Histogram(constants.StatHistogram, int64(i))
284286
}
285287

286-
runtime.GC() //nolint:revive
288+
// Force a GC to clean up any garbage from priming the buffer, so we start with a clean slate.
289+
runtime.GC()
287290

288291
var before runtime.MemStats
289292

@@ -296,7 +299,7 @@ func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) {
296299
c.Histogram(constants.StatHistogram, int64(i))
297300
}
298301

299-
runtime.GC() //nolint:revive
302+
runtime.GC()
300303

301304
var after runtime.MemStats
302305

tests/dist_http_auth_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
fiber "github.com/gofiber/fiber/v3"
1212

13+
"github.com/hyp3rd/hypercache/internal/sentinel"
1314
"github.com/hyp3rd/hypercache/pkg/backend"
1415
cache "github.com/hyp3rd/hypercache/pkg/cache/v2"
1516
)
@@ -268,6 +269,133 @@ func TestDistHTTPAuth_ClientSignsRequests(t *testing.T) {
268269
t.Fatalf("replication did not propagate to nodeB — client likely failed to sign requests")
269270
}
270271

272+
// errClientSignInvoked is the sentinel returned by the client-sign hook
273+
// in TestDistHTTPAuth_RejectsClientSignOnlyConfig — a value the test
274+
// can identify if the hook were ever invoked (it should not be: the
275+
// constructor must reject before any HTTP traffic).
276+
var errClientSignInvoked = errors.New("client sign hook should not run")
277+
278+
// TestDistHTTPAuth_RejectsClientSignOnlyConfig pins the
279+
// constructor-time guard that prevents the silent-inbound-bypass shape
280+
// (CVE-style: ClientSign set, no Token, no ServerVerify, no opt-in).
281+
// Without this guard the dist HTTP server would have signed outbound
282+
// traffic while accepting unauthenticated inbound — see
283+
// sentinel.ErrInsecureAuthConfig for the rationale.
284+
func TestDistHTTPAuth_RejectsClientSignOnlyConfig(t *testing.T) {
285+
t.Parallel()
286+
287+
addr := AllocatePort(t)
288+
289+
bi, err := backend.NewDistMemory(context.Background(),
290+
backend.WithDistNode("auth-reject", addr),
291+
backend.WithDistReplication(1),
292+
backend.WithDistHTTPAuth(backend.DistHTTPAuth{
293+
ClientSign: func(*http.Request) error { return errClientSignInvoked },
294+
}),
295+
)
296+
if !errors.Is(err, sentinel.ErrInsecureAuthConfig) {
297+
t.Fatalf("expected ErrInsecureAuthConfig, got err=%v bi=%v", err, bi)
298+
}
299+
300+
if bi != nil {
301+
t.Fatalf("expected nil backend on validation failure, got %T", bi)
302+
}
303+
}
304+
305+
// TestDistHTTPAuth_AnonymousInboundOptIn confirms operators can
306+
// deliberately wire signed-out / open-in deployments by setting
307+
// AllowAnonymousInbound — used when an L4 firewall or service mesh
308+
// gates inbound at a layer below this server. The server must accept
309+
// anonymous /internal/* requests while the auto-client still signs
310+
// outbound.
311+
func TestDistHTTPAuth_AnonymousInboundOptIn(t *testing.T) {
312+
t.Parallel()
313+
314+
var signCalls atomic.Int64
315+
316+
auth := backend.DistHTTPAuth{
317+
ClientSign: func(req *http.Request) error {
318+
signCalls.Add(1)
319+
req.Header.Set("X-Asymmetric-Sig", "ok")
320+
321+
return nil
322+
},
323+
AllowAnonymousInbound: true,
324+
}
325+
326+
dm := newAuthDistNode(t, auth)
327+
328+
// Inbound /internal/get without any auth header must succeed
329+
// (returns 404 not-owner because no key is set, but importantly
330+
// not 401 — auth is skipped per the explicit opt-in).
331+
req, err := http.NewRequestWithContext(
332+
context.Background(),
333+
http.MethodGet,
334+
"http://"+dm.LocalNodeAddr()+"/internal/get?key=anything",
335+
nil,
336+
)
337+
if err != nil {
338+
t.Fatalf("build request: %v", err)
339+
}
340+
341+
resp, err := http.DefaultClient.Do(req)
342+
if err != nil {
343+
t.Fatalf("do request: %v", err)
344+
}
345+
346+
defer func() { _ = resp.Body.Close() }()
347+
348+
if resp.StatusCode == http.StatusUnauthorized {
349+
t.Fatalf("AllowAnonymousInbound did not skip auth wrap; got 401")
350+
}
351+
}
352+
353+
// TestDistHTTPAuth_TokenWithClientSignOverride covers the asymmetric
354+
// (but valid) shape where Token validates inbound and ClientSign
355+
// overrides the default outbound header — e.g. a node fronting an HMAC
356+
// peer mesh while still gating its own inbound on a shared bearer.
357+
func TestDistHTTPAuth_TokenWithClientSignOverride(t *testing.T) {
358+
t.Parallel()
359+
360+
var signCalls atomic.Int64
361+
362+
auth := backend.DistHTTPAuth{
363+
Token: authTestToken,
364+
ClientSign: func(req *http.Request) error {
365+
signCalls.Add(1)
366+
req.Header.Set("Authorization", "Bearer "+authTestToken)
367+
368+
return nil
369+
},
370+
}
371+
372+
// Construction must succeed — Token covers inbound, ClientSign
373+
// overrides outbound, no insecure shape.
374+
dm := newAuthDistNode(t, auth)
375+
376+
// Inbound without a token still 401s (Token-driven inbound).
377+
req, err := http.NewRequestWithContext(
378+
context.Background(),
379+
http.MethodGet,
380+
"http://"+dm.LocalNodeAddr()+"/internal/get?key=anything",
381+
nil,
382+
)
383+
if err != nil {
384+
t.Fatalf("build request: %v", err)
385+
}
386+
387+
resp, err := http.DefaultClient.Do(req)
388+
if err != nil {
389+
t.Fatalf("do request: %v", err)
390+
}
391+
392+
defer func() { _ = resp.Body.Close() }()
393+
394+
if resp.StatusCode != http.StatusUnauthorized {
395+
t.Fatalf("Token-inbound did not enforce; got %d", resp.StatusCode)
396+
}
397+
}
398+
271399
// TestDistHTTPAuth_CustomVerify proves the ServerVerify escape hatch is
272400
// invoked for every request and can deny on its own logic — used here
273401
// to allow /health while requiring the bearer token elsewhere.

0 commit comments

Comments
 (0)