9b96537aa6
A cluster is only as secure as its weakest node: the data plane forwards every
subject between nodes, so one node running without enforced auth lets an
unauthenticated peer Subscribe(">") on it and harvest the traffic forwarded from
the ACL'd nodes.
- validateClusterConfig now takes the auth mode and REFUSES to join a cluster
unless --bus-auth enforce, regardless of bind (a clustered node is a production
node; there is no safe dev cluster without auth). This binary therefore cannot
BE the weak node.
- Server.Posture {enforce,acl,tls,cluster,store} is published on /healthz (non
secret operational metadata, probe stays unauthenticated) so a monitor or peer
can detect a cluster member not running enforce+ACL+TLS — covering a peer that
runs a tampered/old binary outside this node's control.
Tests:
- TestAttack0008_N1: a clustered node with --bus-auth off is refused; the same
node with enforce + full route security is allowed.
- TestClusterConfigPolicy: extended with off/soft clustered cases (refused) and
the mode parameter throughout.
- TestHealthExposesPosture: /healthz returns the posture booleans + store backend.
CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
8.6 KiB
Go
189 lines
8.6 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
)
|
|
|
|
// TestAudit_FailOpenTLSWithoutAuth ports the auditor's H2 vector. Before the
|
|
// guard, booting with TLS on but the authenticator off ("--bind 0.0.0.0
|
|
// --tls-cert … " without enforce) produced an encrypted data plane that an
|
|
// unregistered, nkey-less client could still connect to — a fail-open config
|
|
// wearing the appearance of security. validateBootConfig now refuses it, so the
|
|
// insecure server never starts (the client therefore has nothing to connect to).
|
|
func TestAudit_FailOpenTLSWithoutAuth(t *testing.T) {
|
|
// The exact auditor configuration: public bind, TLS provided, auth off.
|
|
err := validateBootConfig("0.0.0.0", membership.AuthOff, "server.crt", "server.key")
|
|
if err == nil {
|
|
t.Fatalf("TLS without enforce on a public bind must be refused at startup")
|
|
}
|
|
if !strings.Contains(err.Error(), "enforce") {
|
|
t.Fatalf("error should point the operator at --bus-auth enforce, got: %v", err)
|
|
}
|
|
|
|
// And TLS without enforce is rejected even on loopback: TLS implies a
|
|
// security posture, so authenticating no one is always a misconfiguration.
|
|
if err := validateBootConfig("127.0.0.1", membership.AuthOff, "server.crt", "server.key"); err == nil {
|
|
t.Fatalf("TLS flags without enforce must be refused regardless of bind")
|
|
}
|
|
}
|
|
|
|
// TestGap_PublicEnforceNoTLS ports the re-auditor's N4 gap: the H2 guard refused
|
|
// "public without enforce" and "TLS without enforce", but ALLOWED a public bind
|
|
// with enforce and NO --tls-cert, so the control plane served metadata over
|
|
// plaintext HTTP publicly (H5 reappearing). The guard now refuses it.
|
|
func TestGap_PublicEnforceNoTLS(t *testing.T) {
|
|
// The exact auditor configuration: public bind, enforce on, no TLS cert/key.
|
|
err := validateBootConfig("0.0.0.0", membership.AuthEnforce, "", "")
|
|
if err == nil {
|
|
t.Fatalf("public bind + enforce + NO --tls-cert must be refused: the control plane would serve plaintext HTTP publicly (audit N4)")
|
|
}
|
|
if !strings.Contains(err.Error(), "tls-cert") {
|
|
t.Fatalf("error should point the operator at --tls-cert/--tls-key, got: %v", err)
|
|
}
|
|
|
|
// Golden: the same public+enforce config WITH a cert/key is allowed.
|
|
if err := validateBootConfig("0.0.0.0", membership.AuthEnforce, "server.crt", "server.key"); err != nil {
|
|
t.Fatalf("public + enforce + TLS is the intended production config, got: %v", err)
|
|
}
|
|
|
|
// Edge: loopback without TLS stays allowed (local dev is not a public exposure).
|
|
if err := validateBootConfig("127.0.0.1", membership.AuthOff, "", ""); err != nil {
|
|
t.Fatalf("loopback dev without TLS must remain allowed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBootConfigPolicy is the full table: the golden secure-public config is
|
|
// allowed, dev loopback is allowed, and every fail-open shape is refused.
|
|
func TestBootConfigPolicy(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
bind string
|
|
mode membership.AuthMode
|
|
cert string
|
|
key string
|
|
wantErr bool
|
|
}{
|
|
// Golden: the intended public production config — enforce AND TLS.
|
|
{"public+enforce+tls", "0.0.0.0", membership.AuthEnforce, "s.crt", "s.key", false},
|
|
// Edge: local dev on loopback may stay open (no auth, no TLS).
|
|
{"loopback+off", "127.0.0.1", membership.AuthOff, "", "", false},
|
|
{"loopback-ipv6+off", "::1", membership.AuthOff, "", "", false},
|
|
{"localhost+off", "localhost", membership.AuthOff, "", "", false},
|
|
{"loopback+soft", "127.0.0.1", membership.AuthSoft, "", "", false},
|
|
// Edge: loopback with full enforce+TLS is also fine.
|
|
{"loopback+enforce+tls", "127.0.0.1", membership.AuthEnforce, "s.crt", "s.key", false},
|
|
// Error: public bind without enforce.
|
|
{"public+off", "0.0.0.0", membership.AuthOff, "", "", true},
|
|
{"public+soft", "0.0.0.0", membership.AuthSoft, "", "", true},
|
|
{"lan-ip+off", "192.168.1.10", membership.AuthOff, "", "", true},
|
|
{"empty-bind+off", "", membership.AuthOff, "", "", true},
|
|
// Error (N4): public bind + enforce but NO TLS -> plaintext control plane.
|
|
{"public+enforce+notls", "0.0.0.0", membership.AuthEnforce, "", "", true},
|
|
{"public+enforce+certonly", "0.0.0.0", membership.AuthEnforce, "s.crt", "", true},
|
|
{"public+enforce+keyonly", "0.0.0.0", membership.AuthEnforce, "", "s.key", true},
|
|
{"lan-ip+enforce+notls", "192.168.1.10", membership.AuthEnforce, "", "", true},
|
|
// Error: TLS flags without enforce (cert or key alone is enough to trip it).
|
|
{"loopback+tlscert+off", "127.0.0.1", membership.AuthOff, "s.crt", "", true},
|
|
{"loopback+tlskey+soft", "127.0.0.1", membership.AuthSoft, "", "s.key", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := validateBootConfig(c.bind, c.mode, c.cert, c.key)
|
|
if c.wantErr && err == nil {
|
|
t.Fatalf("config %+v should be refused", c)
|
|
}
|
|
if !c.wantErr && err != nil {
|
|
t.Fatalf("config %+v should be allowed, got: %v", c, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestClusterConfigPolicy is the cluster route guard (issue 0003a): a standalone
|
|
// server is always fine; a loopback cluster is dev-only and unguarded; a public
|
|
// cluster demands both a route secret and complete mutual route TLS; and the
|
|
// route-TLS flags are all-or-nothing regardless of bind.
|
|
func TestClusterConfigPolicy(t *testing.T) {
|
|
const c, k, ca = "node.crt", "node.key", "ca.crt"
|
|
en := membership.AuthEnforce
|
|
off := membership.AuthOff
|
|
soft := membership.AuthSoft
|
|
cases := []struct {
|
|
name string
|
|
clusterName, bind string
|
|
user, pass string
|
|
rtCert, rtKey, rtCA string
|
|
mode membership.AuthMode
|
|
wantErr bool
|
|
}{
|
|
// Standalone (no cluster name) is always allowed, even on a public bind and
|
|
// without enforce — the cluster posture rule does not apply to a single node.
|
|
{"standalone-public-off", "", "0.0.0.0", "", "", "", "", "", off, false},
|
|
// Loopback dev cluster WITH enforce: allowed (unreachable from outside).
|
|
{"loopback-cluster-enforce", "unibus", "127.0.0.1", "", "", "", "", "", en, false},
|
|
// Golden: full public HA config under enforce.
|
|
{"public-full-enforce", "unibus", "0.0.0.0", "u", "p", c, k, ca, en, false},
|
|
// N1 (audit 0008): a clustered node WITHOUT enforce is refused — even on
|
|
// loopback — so no weak node can join the cluster.
|
|
{"cluster-off-refused", "unibus", "127.0.0.1", "", "", "", "", "", off, true},
|
|
{"cluster-soft-refused", "unibus", "0.0.0.0", "u", "p", c, k, ca, soft, true},
|
|
// Error: public cluster without a route secret (enforce on, fails on secret).
|
|
{"public-no-secret", "unibus", "0.0.0.0", "", "", c, k, ca, en, true},
|
|
{"public-half-secret", "unibus", "0.0.0.0", "u", "", c, k, ca, en, true},
|
|
// Error: public cluster without mutual route TLS.
|
|
{"public-no-tls", "unibus", "10.0.0.1", "u", "p", "", "", "", en, true},
|
|
// Error: partial route-TLS flags trip regardless of bind/mode.
|
|
{"loopback-partial-tls", "unibus", "127.0.0.1", "", "", c, "", "", en, true},
|
|
{"standalone-partial-tls", "", "127.0.0.1", "", "", c, k, "", off, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := validateClusterConfig(tc.clusterName, tc.bind, tc.user, tc.pass, tc.rtCert, tc.rtKey, tc.rtCA, tc.mode)
|
|
if tc.wantErr && err == nil {
|
|
t.Fatalf("cluster config %+v should be refused", tc)
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Fatalf("cluster config %+v should be allowed, got: %v", tc, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAttack0008_N1 is the regression for audit 0008 N1 scenario 2: a node
|
|
// configured to join a cluster while NOT enforcing auth (the weak node that lets
|
|
// an unauthenticated peer harvest the cluster's forwarded traffic) must be refused
|
|
// at startup. The homogeneous-posture rule makes this binary unable to BE that
|
|
// weak node.
|
|
func TestAttack0008_N1(t *testing.T) {
|
|
// Weak node: clustered but --bus-auth off -> refused.
|
|
if err := validateClusterConfig("unibus", "0.0.0.0", "u", "p", "n.crt", "n.key", "ca.crt", membership.AuthOff); err == nil {
|
|
t.Fatalf("a clustered node without enforce must be refused (audit 0008 N1)")
|
|
}
|
|
// Same node WITH enforce + full route security -> allowed.
|
|
if err := validateClusterConfig("unibus", "0.0.0.0", "u", "p", "n.crt", "n.key", "ca.crt", membership.AuthEnforce); err != nil {
|
|
t.Fatalf("a clustered enforce node with full route security must be allowed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSplitRoutes(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want int
|
|
}{
|
|
{"", 0},
|
|
{"nats://a:1", 1},
|
|
{"nats://a:1,nats://b:2", 2},
|
|
{" nats://a:1 , nats://b:2 ", 2}, // spaces trimmed
|
|
{"nats://a:1,,", 1}, // empty entries dropped
|
|
{",", 0},
|
|
}
|
|
for _, c := range cases {
|
|
if got := splitRoutes(c.in); len(got) != c.want {
|
|
t.Fatalf("splitRoutes(%q) = %v (len %d), want len %d", c.in, got, len(got), c.want)
|
|
}
|
|
}
|
|
}
|