Files
rollcall/cmd/rollcall/ca_test.go
T
Ilker ManapandClaude Opus 5.5 a549f01ef5
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 34s
Security review: fix traversal, identity, injection and takeover paths
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>
2026-10-29 17:58:42 +01:00

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")
}
}