Bug bash fixes: special-file reads, per-client lockout, prefs validation, security headers, kernel-thread guard, login feedback, error text; plus journal-quiet sudoers + journal groups, upload overwrite guard, machines pattern, dnf freshness, cron/services speed, clean version

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 17:41:52 -07:00
co-authored by Claude Fable 5.1
parent 57c58ebffb
commit 27f9665606
18 changed files with 306 additions and 85 deletions
+68 -11
View File
@@ -9,6 +9,7 @@ import (
"io/fs"
"log"
"mime"
"net"
"net/http"
"net/url"
"os"
@@ -154,7 +155,17 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
return s
}
func (s *Server) Handler() http.Handler { return s.mux }
// Handler wraps the mux with headers a root-capable console should always send.
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
s.mux.ServeHTTP(w, r)
})
}
// ---- helpers ----
@@ -207,7 +218,7 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
// issueToken trades the password for a long-lived token, named by the caller (a hub).
// Rate-limited like login; an existing token with the same name is replaced.
func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
if err := s.limiter.Allow(); err != nil {
if err := s.limiter.Allow(clientKey(r)); err != nil {
jsonErr(w, 429, "too many attempts, try again later")
return
}
@@ -220,12 +231,12 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
s.limiter.Fail()
s.limiter.Fail(clientKey(r))
log.Printf("token request failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
return
}
s.limiter.Reset()
s.limiter.Reset(clientKey(r))
tok, hash, err := auth.NewToken()
if err != nil {
jsonErr(w, 500, "token error")
@@ -415,7 +426,7 @@ func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
// ---- auth ----
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if err := s.limiter.Allow(); err != nil {
if err := s.limiter.Allow(clientKey(r)); err != nil {
jsonErr(w, 429, "too many attempts, try again later")
return
}
@@ -427,12 +438,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
s.limiter.Fail()
s.limiter.Fail(clientKey(r))
log.Printf("login failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
return
}
s.limiter.Reset()
s.limiter.Reset(clientKey(r))
tok, err := s.sessions.Create()
if err != nil {
jsonErr(w, 500, "session error")
@@ -451,6 +462,19 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
// clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a
// reverse proxy set it, else the socket peer.
func clientKey(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
@@ -880,8 +904,13 @@ func fsErr(w http.ResponseWriter, err error) {
code = 403
}
msg := err.Error()
if pe, ok := err.(*os.PathError); ok { // "open /x: permission denied" -> "permission denied"
var pe *os.PathError
var le *os.LinkError
switch {
case errors.As(err, &pe): // "open /x: permission denied" -> "permission denied"
msg = pe.Err.Error()
case errors.As(err, &le): // "rename /a /b: no such file" -> "no such file or directory"
msg = le.Err.Error()
}
jsonErr(w, code, msg)
}
@@ -948,7 +977,12 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "is a directory")
return
}
if mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64); mx > 0 && in.Size > mx {
if in.Type == "other" {
jsonErr(w, 400, "not a regular file (device, pipe or socket)")
return
}
mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64)
if mx > 0 && in.Size > mx {
jsonErr(w, 413, "file too large")
return
}
@@ -958,6 +992,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
return
}
defer f.Close()
var body io.Reader = f
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
body = io.LimitReader(f, mx+1)
}
name := filepath.Base(p)
ct := mime.TypeByExtension(filepath.Ext(name))
if ct == "" {
@@ -975,10 +1013,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
} else {
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
}
if in.Size > 0 {
if in.Size > 0 && mx == 0 {
w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10))
}
_, _ = io.Copy(w, f)
_, _ = io.Copy(w, body)
}
func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) {
@@ -1014,6 +1052,12 @@ func (s *Server) filesUpload(w http.ResponseWriter, r *http.Request) {
return
}
dst := filepath.Join(dir, name)
if r.URL.Query().Get("overwrite") != "1" {
if _, err := fsys.Stat(r.Context(), dst); err == nil {
jsonErr(w, 409, "a file with that name already exists")
return
}
}
if err := fsys.Write(r.Context(), dst, r.Body); err != nil {
fsErr(w, err)
return
@@ -1241,6 +1285,19 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "expected a JSON object")
return
}
var p struct {
Theme *string `json:"theme"`
RefreshMs *float64 `json:"refreshMs"`
}
_ = json.Unmarshal(b, &p)
if p.RefreshMs != nil && (*p.RefreshMs < 100 || *p.RefreshMs > 60000) {
jsonErr(w, 400, "refreshMs must be between 100 and 60000")
return
}
if p.Theme != nil && (len(*p.Theme) > 32 || strings.ContainsAny(*p.Theme, "<>\"'")) {
jsonErr(w, 400, "theme must be a short identifier")
return
}
if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil {
jsonErr(w, 500, err.Error())
return