Findings from a full review (auth/API, CA and rack channel, config generation, integrations, UI/agents/scripts), each verified in code: - rack binary endpoint read any region file via arch=../.. (CA key) - render endpoint traversal; machine-reported MACs used as file paths - delegated intermediates could mint admin/rack identities and IPs; identities now require root-issued certs, intermediates are ServerAuth-only with both name types constrained - rack certs were ServerAuth for rack-chosen SANs: now client-only; racks accept only the region's own cert; join checks the CA fingerprint; unbound join codes cannot take an existing name; job results bound to the rack that took them - rack self-update: region signs binaries with the CA key, the root unit verifies against the join-time CA in a root-owned dir - dnsmasq reads a checked, root-owned copy limited to generated options - commissioning could overwrite deployed machines; MAC takeover of PXE - injection into dnsmasq/cloud-init/PXE/udev/DNS via names and machine-reported strings; stored XSS in the UI; CSP and cross-origin protection; deploy agent shell injection and seed traversal - Redfish requests confined to the BMC's /redfish/ tree - admin_http defaults to loopback; body limits and timeouts; secrets off command lines Also: IPAM claim/reconcile race, sync stopping on one error, watchdog race, release wiping the wrong disk, SAN LUNs as auto targets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
9.7 KiB
Go
265 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCAAndRegionCert(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ca, err := loadOrCreateCA(dir, "test CA")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again, err := loadOrCreateCA(dir, "test CA")
|
|
if err != nil || again.Fingerprint() != ca.Fingerprint() {
|
|
t.Fatalf("reload gives another CA: %v", err)
|
|
}
|
|
pair, err := regionCert(ca, dir, []string{"rollcall.example", "fd00:213:77::1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
leaf, _ := x509.ParseCertificate(pair.Certificate[0])
|
|
for _, h := range []string{"rollcall.example", "fd00:213:77::1", "127.0.0.1", "localhost"} {
|
|
if err := leaf.VerifyHostname(h); err != nil {
|
|
t.Errorf("region certificate does not cover %s: %v", h, err)
|
|
}
|
|
}
|
|
if len(pair.Certificate) != 2 {
|
|
t.Errorf("the chain should carry the CA for pinning clients")
|
|
}
|
|
// unchanged names: the same certificate is reused
|
|
p2, _ := regionCert(ca, dir, []string{"rollcall.example", "fd00:213:77::1"})
|
|
if string(p2.Certificate[0]) != string(pair.Certificate[0]) {
|
|
t.Errorf("certificate reissued without a reason")
|
|
}
|
|
}
|
|
|
|
func TestJoinCodeAndPinnedTLS(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ca, _ := loadOrCreateCA(dir, "test CA")
|
|
pair, _ := regionCert(ca, dir, nil)
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }))
|
|
srv.TLS = &tls.Config{Certificates: []tls.Certificate{pair}}
|
|
srv.StartTLS()
|
|
defer srv.Close()
|
|
|
|
var c joinCode
|
|
sum := certFingerprint(ca.Cert)
|
|
copy(c.Fingerprint[:], sum[:16])
|
|
c.Secret[0] = 7
|
|
c.Expires = time.Now().Add(time.Hour).Unix()
|
|
c.Addr = strings.TrimPrefix(srv.URL, "https://")
|
|
got, err := parseJoinCode(c.String())
|
|
if err != nil || got != c {
|
|
t.Fatalf("round trip: %+v %v", got, err)
|
|
}
|
|
ok := &http.Client{Transport: &http.Transport{TLSClientConfig: pinnedTLS(c.Fingerprint)}}
|
|
if resp, err := ok.Get(srv.URL); err != nil {
|
|
t.Fatalf("pinned CA rejected: %v", err)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
other, _ := newCA("another")
|
|
osum := sha256.Sum256(other.Cert.Raw)
|
|
var wrong [16]byte
|
|
copy(wrong[:], osum[:16])
|
|
bad := &http.Client{Transport: &http.Transport{TLSClientConfig: pinnedTLS(wrong)}}
|
|
if _, err := bad.Get(srv.URL); err == nil {
|
|
t.Errorf("a server from another authority was accepted")
|
|
}
|
|
expired := c
|
|
expired.Expires = time.Now().Add(-time.Minute).Unix()
|
|
if _, err := parseJoinCode(expired.String()); err == nil {
|
|
t.Errorf("an expired code was accepted")
|
|
}
|
|
}
|
|
|
|
func TestSignRackCSR(t *testing.T) {
|
|
ca, _ := newCA("test CA")
|
|
a := &App{ca: ca, cfg: &Config{}}
|
|
_, csr, err := newCSR("rack:hall-b")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, cert, err := a.signRackCSR(csr, "hall-b")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
pool.AddCert(ca.Cert)
|
|
if _, err := cert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err != nil {
|
|
t.Errorf("rack certificate is no client certificate: %v", err)
|
|
}
|
|
// a rack is a client only: no names, no server use, so it cannot pose as the region
|
|
if cert.Subject.CommonName != "rack:hall-b" || len(cert.IPAddresses)+len(cert.DNSNames) > 0 {
|
|
t.Errorf("subject %s, SANs %v %v", cert.Subject.CommonName, cert.IPAddresses, cert.DNSNames)
|
|
}
|
|
if _, err := cert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err == nil {
|
|
t.Error("rack certificate verifies as a server certificate")
|
|
}
|
|
}
|
|
|
|
func TestIntermediate(t *testing.T) {
|
|
root, err := newCA("test root")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !root.canDelegate() {
|
|
t.Fatal("new roots must be able to delegate one level")
|
|
}
|
|
dns, nets, err := parsePermits("int.example.com, 10.213.0.0/16")
|
|
if err != nil || len(dns) != 1 || len(nets) != 1 {
|
|
t.Fatalf("permits: %v %v %v", dns, nets, err)
|
|
}
|
|
ikey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
_, icert, err := root.intermediate(&ikey.PublicKey, "caddy", dns, nets, 30*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !icert.IsCA || !icert.MaxPathLenZero || !icert.PermittedDNSDomainsCritical {
|
|
t.Fatalf("intermediate shape: %+v", icert)
|
|
}
|
|
sub := &CA{Cert: icert, Key: ikey}
|
|
roots, inters := x509.NewCertPool(), x509.NewCertPool()
|
|
roots.AddCert(root.Cert)
|
|
inters.AddCert(icert)
|
|
verify := func(hosts ...string) error {
|
|
_, leaf, err := sub.sign(&ikey.PublicKey, hosts[0], hosts, x509.ExtKeyUsageServerAuth)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inters, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}})
|
|
return err
|
|
}
|
|
if err := verify("node001.int.example.com", "10.213.77.50"); err != nil {
|
|
t.Errorf("leaf inside the constraints: %v", err)
|
|
}
|
|
if err := verify("www.google.com"); err == nil {
|
|
t.Error("leaf outside the DNS constraint verified")
|
|
}
|
|
if err := verify("node001.int.example.com", "192.168.1.1"); err == nil {
|
|
t.Error("leaf outside the IP constraint verified")
|
|
}
|
|
// nothing it issues is a client identity (admin:, rack:)
|
|
if _, leaf, err := sub.sign(&ikey.PublicKey, "admin:mallory", nil, x509.ExtKeyUsageClientAuth); err == nil {
|
|
if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inters, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err == nil {
|
|
t.Error("the intermediate issued a client certificate that verifies")
|
|
}
|
|
}
|
|
// DNS only: no address may be certified (the region's IP, say)
|
|
dnsOnly, _, _ := parsePermits("int.example.com")
|
|
_, ic2, err := root.intermediate(&ikey.PublicKey, "dns-only", dnsOnly, nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inters2 := x509.NewCertPool()
|
|
inters2.AddCert(ic2)
|
|
if _, leaf, err := (&CA{Cert: ic2, Key: ikey}).sign(&ikey.PublicKey, "x", []string{"10.0.0.26"}, x509.ExtKeyUsageServerAuth); err == nil {
|
|
if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inters2}); err == nil {
|
|
t.Error("a DNS-only intermediate certified an IP address")
|
|
}
|
|
}
|
|
for _, bad := range []string{"*.", "", "foo bar"} {
|
|
if _, _, err := parsePermits(bad); err == nil {
|
|
t.Errorf("permit %q accepted", bad)
|
|
}
|
|
}
|
|
// a second level below the intermediate is refused by path length 0
|
|
k2, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
_, c2, err := sub.intermediate(&k2.PublicKey, "deeper", dns, nil, time.Hour)
|
|
if err == nil {
|
|
inters.AddCert(c2)
|
|
_, leaf, _ := (&CA{Cert: c2, Key: k2}).sign(&k2.PublicKey, "x.int.example.com", []string{"x.int.example.com"}, x509.ExtKeyUsageServerAuth)
|
|
if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inters}); err == nil {
|
|
t.Error("two levels of intermediates verified")
|
|
}
|
|
}
|
|
// old roots (path length 0) refuse to delegate
|
|
old := *root
|
|
oc := *root.Cert
|
|
oc.MaxPathLen, oc.MaxPathLenZero = 0, true
|
|
old.Cert = &oc
|
|
if _, _, err := old.intermediate(&ikey.PublicKey, "x", dns, nil, time.Hour); err == nil {
|
|
t.Error("path-length-0 root signed an intermediate")
|
|
}
|
|
}
|
|
|
|
func TestUIRedirectsToHTTPS(t *testing.T) {
|
|
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
|
try := func(admin, listen, remote, host string) (int, string) {
|
|
a := &App{cfg: &Config{AdminHTTP: admin, TLSListen: listen}}
|
|
req := httptest.NewRequest("GET", "/index.html?x=1", nil)
|
|
req.RemoteAddr, req.Host = remote, host
|
|
rec := httptest.NewRecorder()
|
|
a.uiOverHTTPS(ok).ServeHTTP(rec, req)
|
|
return rec.Code, rec.Header().Get("Location")
|
|
}
|
|
if c, l := try("loopback", ":443", "10.0.0.5:5555", "10.0.0.234"); c != 308 || l != "https://10.0.0.234/index.html?x=1" {
|
|
t.Errorf("remote over http: %d %s", c, l)
|
|
}
|
|
if c, l := try("deny", ":8443", "10.0.0.5:5555", "pxe.example:80"); c != 308 || l != "https://pxe.example:8443/index.html?x=1" {
|
|
t.Errorf("other port: %d %s", c, l)
|
|
}
|
|
if c, l := try("loopback", ":443", "[fd00::5]:5555", "[fd00::1]"); c != 308 || l != "https://[fd00::1]/index.html?x=1" {
|
|
t.Errorf("ipv6: %d %s", c, l)
|
|
}
|
|
if c, _ := try("loopback", ":443", "127.0.0.1:5555", "localhost"); c != 200 {
|
|
t.Errorf("loopback client redirected: %d", c)
|
|
}
|
|
if c, _ := try("allow", ":443", "10.0.0.5:5555", "10.0.0.234"); c != 200 {
|
|
t.Errorf("allow redirected: %d", c)
|
|
}
|
|
if c, _ := try("loopback", "", "10.0.0.5:5555", "10.0.0.234"); c != 200 {
|
|
t.Errorf("no TLS listener but redirected: %d", c)
|
|
}
|
|
}
|
|
|
|
func TestAdminClientCertificate(t *testing.T) {
|
|
ca, _ := newCA("test")
|
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
_, admin, _ := ca.sign(&key.PublicKey, "admin:t440s", nil, x509.ExtKeyUsageClientAuth)
|
|
_, server, _ := ca.sign(&key.PublicKey, "admin:t440s", []string{"x.example"}, x509.ExtKeyUsageServerAuth)
|
|
_, rack, _ := ca.sign(&key.PublicKey, "rack:r2", nil, x509.ExtKeyUsageClientAuth)
|
|
a := &App{cfg: &Config{AdminToken: "secret"}}
|
|
a.adminCC.serial, a.adminCC.at = map[string]string{admin.SerialNumber.Text(16): "t440s", server.SerialNumber.Text(16): "t440s"}, time.Now()
|
|
req := func(c *x509.Certificate, verified bool) *http.Request {
|
|
r := httptest.NewRequest("GET", "/api/v1/machines", nil)
|
|
r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{c}}
|
|
if verified {
|
|
r.TLS.VerifiedChains = [][]*x509.Certificate{{c, ca.Cert}}
|
|
}
|
|
return r
|
|
}
|
|
if !a.isAdmin(req(admin, true)) {
|
|
t.Error("registered admin certificate refused")
|
|
}
|
|
if a.isAdmin(req(admin, false)) {
|
|
t.Error("unverified certificate accepted")
|
|
}
|
|
if a.isAdmin(req(server, true)) {
|
|
t.Error("server certificate accepted as admin")
|
|
}
|
|
if a.isAdmin(req(rack, true)) {
|
|
t.Error("rack certificate accepted as admin")
|
|
}
|
|
a.adminCC.serial = map[string]string{} // revoked
|
|
if a.isAdmin(req(admin, true)) {
|
|
t.Error("revoked certificate accepted")
|
|
}
|
|
tok := httptest.NewRequest("GET", "/", nil)
|
|
tok.Header.Set("Authorization", "Bearer secret")
|
|
if !a.isAdmin(tok) {
|
|
t.Error("token no longer works")
|
|
}
|
|
}
|