Merge issue/0004b-failopen-guard: close the fail-open startup (H2)
A non-loopback bind, or any TLS flag, now requires --bus-auth enforce or the service refuses to start.
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/enmanuel/unibus/pkg/membership"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isLoopbackBind reports whether the --bind value keeps the service reachable
|
||||||
|
// only from this host. An empty bind means "all interfaces" (public), and a
|
||||||
|
// hostname we cannot resolve to a loopback literal is treated as public — the
|
||||||
|
// conservative choice, so an unusual bind never silently slips past the guard.
|
||||||
|
func isLoopbackBind(bind string) bool {
|
||||||
|
switch bind {
|
||||||
|
case "localhost":
|
||||||
|
return true
|
||||||
|
case "":
|
||||||
|
return false // empty binds every interface
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(bind)
|
||||||
|
if ip == nil {
|
||||||
|
return false // a hostname we can't classify: assume public
|
||||||
|
}
|
||||||
|
return ip.IsLoopback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateBootConfig is the fail-open guard (audit H2). It refuses any startup
|
||||||
|
// configuration that would expose the bus without enforced authentication:
|
||||||
|
//
|
||||||
|
// - a non-loopback --bind without --bus-auth enforce (the data plane and
|
||||||
|
// control plane would both accept anyone), and
|
||||||
|
// - --tls-cert/--tls-key without --bus-auth enforce (TLS encrypts the channel
|
||||||
|
// but authenticates no one — encrypted access for everybody is still open).
|
||||||
|
//
|
||||||
|
// It is a pure function of the parsed flags so the command can fail fast at
|
||||||
|
// startup and tests can assert the policy without booting a server.
|
||||||
|
func validateBootConfig(bind string, mode membership.AuthMode, tlsCert, tlsKey string) error {
|
||||||
|
if !isLoopbackBind(bind) && mode != membership.AuthEnforce {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"refusing to start: --bind %q is not loopback but --bus-auth is %q; a public bind requires --bus-auth enforce (or bind 127.0.0.1 for local dev)",
|
||||||
|
bind, mode)
|
||||||
|
}
|
||||||
|
if (tlsCert != "" || tlsKey != "") && mode != membership.AuthEnforce {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"refusing to start: --tls-cert/--tls-key set but --bus-auth is %q; TLS without enforced auth is fail-open (encrypted channel, no authentication) — set --bus-auth enforce",
|
||||||
|
mode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
{"public+enforce+tls", "0.0.0.0", membership.AuthEnforce, "s.crt", "s.key", false},
|
||||||
|
{"public+enforce+notls", "0.0.0.0", membership.AuthEnforce, "", "", 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},
|
||||||
|
// 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: 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,13 @@ func main() {
|
|||||||
log.Fatalf("%v", err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fail-open guard (audit H2): a non-loopback bind, or any TLS flag, demands
|
||||||
|
// --bus-auth enforce. This makes an insecure public startup impossible rather
|
||||||
|
// than silently exposing the bus with the appearance of security.
|
||||||
|
if err := validateBootConfig(*bind, authMode, *tlsCert, *tlsKey); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||||
log.SetPrefix("[membershipd] ")
|
log.SetPrefix("[membershipd] ")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user