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>
This commit is contained in:
1 parent
6e4be736ec
commit
a549f01ef5
28 files changed
+782
-154
No files matched your search
+77
-16
@@ -151,9 +151,14 @@ func logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rw := &statusWriter{ResponseWriter: w, code: 200}
|
||||
r.Body = http.MaxBytesReader(rw, r.Body, 16<<20) // handlers set tighter limits; nothing takes more
|
||||
next.ServeHTTP(rw, r)
|
||||
if (!strings.HasPrefix(r.URL.Path, "/rollcall/") && !strings.HasPrefix(r.URL.Path, "/images/") && !strings.HasPrefix(r.URL.Path, "/os/")) || rw.code >= 400 {
|
||||
log.Printf("%s %s %s %d %s", r.RemoteAddr, r.Method, r.URL.RequestURI(), rw.code, time.Since(start).Round(time.Millisecond))
|
||||
uri := r.URL.RequestURI()
|
||||
if r.URL.Query().Has("token") { // /?token=... signs the UI in; it does not go to the journal
|
||||
uri = r.URL.Path + "?token=REDACTED"
|
||||
}
|
||||
log.Printf("%s %s %s %d %s", r.RemoteAddr, r.Method, uri, rw.code, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -279,8 +284,25 @@ func (a *App) routes() http.Handler {
|
||||
mux.Handle("GET /os/", http.StripPrefix("/os/", http.FileServer(http.Dir(a.cfg.OSDir))))
|
||||
// embedded web UI
|
||||
sub, _ := fs.Sub(webFS, "web")
|
||||
mux.Handle("GET /", a.uiOverHTTPS(http.FileServer(http.FS(sub))))
|
||||
return logging(mux)
|
||||
mux.Handle("GET /", a.uiOverHTTPS(uiHeaders(http.FileServer(http.FS(sub)))))
|
||||
// Browsers send admin client certificates on their own, so a page elsewhere
|
||||
// could post to the API with the admin's identity: cross-origin requests
|
||||
// that change state are refused (Sec-Fetch-Site / Origin). The CLI, agents
|
||||
// and racks send neither header and are not affected.
|
||||
return logging(http.NewCrossOriginProtection().Handler(mux))
|
||||
}
|
||||
|
||||
// uiHeaders: the UI loads only its own scripts and cannot be framed, so
|
||||
// markup that slips through (machine-reported strings) does not run.
|
||||
func uiHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers over inventory JSON
|
||||
@@ -327,14 +349,23 @@ func listLen(v any) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func normMAC(s string) string { return strings.ToLower(strings.ReplaceAll(s, "-", ":")) }
|
||||
// normMAC is the canonical form of a hardware address, "" when s is not one.
|
||||
// MACs name files (pxelinux.cfg/01-...) and dnsmasq lines, so nothing else passes.
|
||||
func normMAC(s string) string {
|
||||
hw, err := net.ParseMAC(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return hw.String()
|
||||
}
|
||||
|
||||
var nonAlnum = regexp.MustCompile(`[^a-z0-9]`)
|
||||
|
||||
func machineID(uuid, serial string, macs []string) string {
|
||||
u := strings.ToLower(uuid)
|
||||
if u != "" && u != "00000000-0000-0000-0000-000000000000" && !strings.Contains(u, "not ") {
|
||||
u = strings.ReplaceAll(u, "-", "")
|
||||
if u != "" && u != "00000000-0000-0000-0000-000000000000" && !strings.Contains(u, "not ") &&
|
||||
nonAlnum.ReplaceAllString(u, "") != "" {
|
||||
u = nonAlnum.ReplaceAllString(u, "") // the ID names files and URLs: letters and digits only
|
||||
if len(u) > 12 {
|
||||
u = u[:12]
|
||||
}
|
||||
@@ -464,7 +495,7 @@ func (a *App) writePXE(m *Machine) error {
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20))
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
@@ -491,13 +522,26 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// identity strings come from the machine: one line each, they go into PXE menus and logs
|
||||
line := func(v any) *string {
|
||||
s := strings.Map(func(c rune) rune {
|
||||
if c < ' ' || c == 0x7f {
|
||||
return ' '
|
||||
}
|
||||
return c
|
||||
}, str(v))
|
||||
if s = strings.TrimSpace(s); len(s) > 128 {
|
||||
s = s[:128]
|
||||
}
|
||||
return strPtr(s)
|
||||
}
|
||||
m := &Machine{
|
||||
ID: machineID(str(ident["uuid"]), str(ident["serial"]), macs),
|
||||
UUID: strPtr(ident["uuid"]),
|
||||
Serial: strPtr(ident["serial"]),
|
||||
Hostname: strPtr(inv["hostname"]),
|
||||
Manufacturer: strPtr(dig(inv, "system", "system-manufacturer")),
|
||||
Product: strPtr(dig(inv, "system", "system-product-name")),
|
||||
UUID: line(ident["uuid"]),
|
||||
Serial: line(ident["serial"]),
|
||||
Hostname: line(inv["hostname"]),
|
||||
Manufacturer: line(dig(inv, "system", "system-manufacturer")),
|
||||
Product: line(dig(inv, "system", "system-product-name")),
|
||||
MACs: macs,
|
||||
}
|
||||
// Pull the BMC password out of the payload before anything is persisted.
|
||||
@@ -517,9 +561,20 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
m.Inventory, _ = json.Marshal(inv)
|
||||
|
||||
// Anyone on the provisioning network can post here, so a known machine is
|
||||
// only re-commissioned when that is expected: an operator asked
|
||||
// (recommission), or it has nothing to lose (ready, a failed job). A report
|
||||
// for a deployed or running machine is refused, not merged.
|
||||
old, oldErr := a.store.Get(r.Context(), m.ID)
|
||||
if oldErr == nil && !recommissionable(old.State) {
|
||||
_ = a.store.AddEvent(r.Context(), m.ID, "commission refused", fmt.Sprintf("state %s; from %s", old.State, r.RemoteAddr))
|
||||
log.Printf("commission refused for %s (state %s) from %s", m.ID, old.State, r.RemoteAddr)
|
||||
fail(w, 409, fmt.Errorf("machine %s is %s; recommission it first", m.ID, old.State))
|
||||
return
|
||||
}
|
||||
// a BMC an operator configured (driver_set) survives commissioning; what the agent
|
||||
// found is kept next to it for reference
|
||||
if old, err := a.store.Get(r.Context(), m.ID); err == nil && old.BMC != nil {
|
||||
if oldErr == nil && old.BMC != nil {
|
||||
var ob obj
|
||||
if json.Unmarshal(old.BMC, &ob) == nil && ob["driver_set"] == true {
|
||||
var found obj
|
||||
@@ -538,7 +593,9 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
_ = a.store.AddEvent(r.Context(), m.ID, "commissioned", detail)
|
||||
_ = a.store.SetRackController(r.Context(), m.ID, a.fromRack(r))
|
||||
if isNew || oldErr != nil || old.State == "recommission" || old.RackController == "" {
|
||||
_ = a.store.SetRackController(r.Context(), m.ID, a.fromRack(r)) // a ready machine is not moved by whoever reports it
|
||||
}
|
||||
if full, err := a.store.Get(r.Context(), m.ID); err == nil {
|
||||
go a.probeBMC(full)
|
||||
}
|
||||
@@ -555,6 +612,10 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"id": m.ID, "state": "ready", "new": isNew})
|
||||
}
|
||||
|
||||
func recommissionable(state string) bool {
|
||||
return state == "new" || state == "ready" || state == "recommission" || strings.HasSuffix(state, "-failed")
|
||||
}
|
||||
|
||||
func (a *App) handleHint(w http.ResponseWriter, r *http.Request) {
|
||||
want := map[string]bool{}
|
||||
for _, m := range strings.Split(r.URL.Query().Get("macs"), ",") {
|
||||
@@ -746,7 +807,7 @@ func serve(cfgPath string) error {
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(app.ca.Cert)
|
||||
tsrv := &http.Server{Addr: cfg.TLSListen, Handler: handler, ReadHeaderTimeout: 10 * time.Second,
|
||||
tsrv := &http.Server{Addr: cfg.TLSListen, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute,
|
||||
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{pair}, ClientCAs: pool, ClientAuth: tls.VerifyClientCertIfGiven}}
|
||||
go func() {
|
||||
log.Printf("rollcall HTTPS on %s (CA %s)", cfg.TLSListen, app.ca.Fingerprint())
|
||||
@@ -755,7 +816,7 @@ func serve(cfgPath string) error {
|
||||
}
|
||||
}()
|
||||
}
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second}
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute}
|
||||
log.Printf("rollcall listening on %s (image=%s tftp=%s agent_token=%v)", cfg.Listen, cfg.ImageDir, cfg.TFTPRoot, cfg.AgentToken != "")
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
Reference in new issue
Block a user