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:
co-authored by
Claude Fable 5.1
parent
57c58ebffb
commit
27f9665606
+68
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user