Skip to content

Commit 4acd038

Browse files
committed
feat(tlsbridge): localhost-bind :9094 when bridging; validate the CA Secret
Two operator-path hardenings, both load-time: - When tls_bridge.mode=enabled, Load() rewrites listener.session_api_addr to 127.0.0.1 (preserving the port) so the session API — which now carries decrypted bodies — isn't reachable by other pods. kubectl port-forward (abctl) still works (it targets the pod's loopback). No auth, no redaction yet; this just shrinks the reachable surface. - NewFileSource now fails loud on a misissued cert-manager Secret: rejects a non-CA cert (IsCA=false / missing KeyUsageCertSign) and a cert/key public-key mismatch. Previously these loaded 'fine' and silently failed at signing time → every call tunnels with no error. Turns a misconfigured Secret into a clear startup failure. Tests: TestForceLocalhost, TestLoad_TLSBridgeHardensSessionAPI, TestFileSource_RejectsNonCAOrMismatch. Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 0138d24 commit 4acd038

4 files changed

Lines changed: 121 additions & 0 deletions

File tree

authbridge/authlib/config/config.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package config
55
import (
66
"encoding/json"
77
"fmt"
8+
"net"
89
"os"
910
"strings"
1011

@@ -510,7 +511,27 @@ func Load(path string) (*Config, error) {
510511
if err := cfg.TLSBridge.Validate(); err != nil {
511512
return nil, err
512513
}
514+
// With the bridge on, the session API may carry decrypted request/response
515+
// bodies; restrict its bind to loopback so other pods can't scrape it.
516+
// kubectl port-forward (abctl) still works — it targets the pod's loopback.
517+
if cfg.TLSBridge.Mode == "enabled" {
518+
cfg.Listener.SessionAPIAddr = forceLocalhost(cfg.Listener.SessionAPIAddr)
519+
}
513520
}
514521

515522
return &cfg, nil
516523
}
524+
525+
// forceLocalhost rewrites a bind address to 127.0.0.1, preserving the port:
526+
// ":9094" / "0.0.0.0:9094" / "[::]:9094" -> "127.0.0.1:9094". Empty stays empty;
527+
// a malformed address is left as-is so the bind itself surfaces the error.
528+
func forceLocalhost(addr string) string {
529+
if addr == "" {
530+
return ""
531+
}
532+
_, port, err := net.SplitHostPort(addr)
533+
if err != nil {
534+
return addr
535+
}
536+
return net.JoinHostPort("127.0.0.1", port)
537+
}

authbridge/authlib/config/config_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,44 @@ func TestTLSBridgeConfig_Validate(t *testing.T) {
779779
}
780780
}
781781

782+
func TestForceLocalhost(t *testing.T) {
783+
cases := map[string]string{
784+
":9094": "127.0.0.1:9094",
785+
"0.0.0.0:9094": "127.0.0.1:9094",
786+
"[::]:9094": "127.0.0.1:9094",
787+
"127.0.0.1:9094": "127.0.0.1:9094",
788+
"": "",
789+
"no-port-malformed": "no-port-malformed", // left as-is; bind surfaces the error
790+
}
791+
for in, want := range cases {
792+
if got := forceLocalhost(in); got != want {
793+
t.Errorf("forceLocalhost(%q) = %q, want %q", in, got, want)
794+
}
795+
}
796+
}
797+
798+
// With the bridge enabled, Load() restricts the session API to loopback so
799+
// other pods can't scrape decrypted bodies.
800+
func TestLoad_TLSBridgeHardensSessionAPI(t *testing.T) {
801+
y := []byte("mode: proxy-sidecar\n" +
802+
"listener:\n" +
803+
" session_api_addr: \":9094\"\n" +
804+
"tls_bridge:\n" +
805+
" mode: enabled\n" +
806+
" ca_dir: /etc/authbridge/tls-bridge-ca\n")
807+
p := filepath.Join(t.TempDir(), "cfg.yaml")
808+
if err := os.WriteFile(p, y, 0o600); err != nil {
809+
t.Fatal(err)
810+
}
811+
cfg, err := Load(p)
812+
if err != nil {
813+
t.Fatalf("load: %v", err)
814+
}
815+
if cfg.Listener.SessionAPIAddr != "127.0.0.1:9094" {
816+
t.Errorf("session_api_addr = %q, want 127.0.0.1:9094 (bridge on => loopback)", cfg.Listener.SessionAPIAddr)
817+
}
818+
}
819+
782820
// Absent mtls block leaves cfg.MTLS nil — today's behavior, no TLS.
783821
func TestLoad_MTLS_AbsentBlock(t *testing.T) {
784822
dir := t.TempDir()

authbridge/authlib/tlsbridge/ca.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,21 @@ func NewFileSource(certPath, keyPath string) (CASource, error) {
9090
if err != nil {
9191
return nil, err
9292
}
93+
// Fail loud at load on a misissued Secret. Without these checks a non-CA or
94+
// mismatched cert/key loads "fine" and then silently fails to sign minted
95+
// leaves at request time → the agent rejects the chain → every call falls
96+
// open to tunnel, with no error. A cert-manager Secret can be misconfigured
97+
// (wrong issuerRef, leaf instead of CA, mid-rotation key mismatch), so verify.
98+
if !cert.IsCA {
99+
return nil, fmt.Errorf("tlsbridge: CA cert %s is not a CA (IsCA=false)", certPath)
100+
}
101+
if cert.KeyUsage != 0 && cert.KeyUsage&x509.KeyUsageCertSign == 0 {
102+
return nil, fmt.Errorf("tlsbridge: CA cert %s lacks KeyUsageCertSign", certPath)
103+
}
104+
pub, ok := cert.PublicKey.(interface{ Equal(x crypto.PublicKey) bool })
105+
if !ok || !pub.Equal(key.Public()) {
106+
return nil, fmt.Errorf("tlsbridge: CA cert %s and key %s do not match", certPath, keyPath)
107+
}
93108
return &staticSource{cert: cert, key: key, certPEM: certPEM}, nil
94109
}
95110

authbridge/authlib/tlsbridge/ca_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,50 @@ func TestFileSource_RejectsGarbage(t *testing.T) {
114114
})
115115
}
116116
}
117+
118+
func TestFileSource_RejectsNonCAOrMismatch(t *testing.T) {
119+
// writePair builds a self-signed cert from certKey and writes it next to
120+
// fileKey (PKCS#8). When certKey != fileKey the cert/key public keys differ.
121+
writePair := func(t *testing.T, isCA bool, certKey, fileKey *ecdsa.PrivateKey) (string, string) {
122+
t.Helper()
123+
tmpl := &x509.Certificate{
124+
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "t"},
125+
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
126+
IsCA: isCA, BasicConstraintsValid: true,
127+
}
128+
if isCA {
129+
tmpl.KeyUsage = x509.KeyUsageCertSign
130+
}
131+
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, certKey.Public(), certKey)
132+
if err != nil {
133+
t.Fatalf("create cert: %v", err)
134+
}
135+
dir := t.TempDir()
136+
certP := filepath.Join(dir, "tls.crt")
137+
keyP := filepath.Join(dir, "tls.key")
138+
kd, _ := x509.MarshalPKCS8PrivateKey(fileKey)
139+
if err := os.WriteFile(certP, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
140+
t.Fatalf("write cert: %v", err)
141+
}
142+
if err := os.WriteFile(keyP, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600); err != nil {
143+
t.Fatalf("write key: %v", err)
144+
}
145+
return certP, keyP
146+
}
147+
148+
k1, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
149+
k2, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
150+
151+
t.Run("non-CA cert", func(t *testing.T) {
152+
certP, keyP := writePair(t, false, k1, k1) // matching key, but IsCA=false
153+
if _, err := NewFileSource(certP, keyP); err == nil {
154+
t.Fatal("expected error for IsCA=false cert, got nil")
155+
}
156+
})
157+
t.Run("mismatched cert/key", func(t *testing.T) {
158+
certP, keyP := writePair(t, true, k1, k2) // cert carries k1's pubkey; file holds k2
159+
if _, err := NewFileSource(certP, keyP); err == nil {
160+
t.Fatal("expected error for cert/key mismatch, got nil")
161+
}
162+
})
163+
}

0 commit comments

Comments
 (0)