From 61c7b5582cfe6f08e608e2829e4ebe0dde39e8cb Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 14 Sep 2026 13:32:31 +0000 Subject: [PATCH 1/2] access-list: Hash basic-auth passwords and redact API reads Access-list item passwords were stored and returned in plaintext. Hash on write with bcrypt, compare hashes in the proxy engine (with legacy plaintext fallback), and omit passwords from admin GET JSON. --- cmd/helix-proxy/api.go | 68 +++++++++++++++++++++++++++++++++++++--- internal/proxy/engine.go | 15 ++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/cmd/helix-proxy/api.go b/cmd/helix-proxy/api.go index a158e18..85887c2 100644 --- a/cmd/helix-proxy/api.go +++ b/cmd/helix-proxy/api.go @@ -1,6 +1,7 @@ package main import ( + "strings" "encoding/json" "log/slog" "net/http" @@ -107,7 +108,7 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M } _ = claims w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(st.GetAccessLists()) + _ = json.NewEncoder(w).Encode(redactAccessLists(st.GetAccessLists())) }) r.Post("/access-lists", func(w http.ResponseWriter, r *http.Request) { claims, ok := auth.RequireAuth(w, r) @@ -122,13 +123,17 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M if payload.OwnerUserID == 0 { payload.OwnerUserID = claims.UserID } + if err := hashAccessListPasswords(&payload); err != nil { + http.Error(w, "password hash failed", 500) + return + } created, err := st.CreateAccessList(payload) if err != nil { http.Error(w, err.Error(), 500) return } w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(created) + _ = json.NewEncoder(w).Encode(redactAccessList(created)) eng.ReloadFromStore() _, _ = st.AddAuditLog(store.AuditLog{ UserID: claims.UserID, @@ -153,7 +158,7 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M return } if al, ok := st.GetAccessList(id); ok { - _ = json.NewEncoder(w).Encode(al) + _ = json.NewEncoder(w).Encode(redactAccessList(al)) return } http.Error(w, "not found", 404) @@ -187,13 +192,29 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M payload.OwnerUserID = existing.OwnerUserID // preserve CreatedOn on update (server-set on create; do not lose on edit) payload.CreatedOn = existing.CreatedOn + // Empty password on an item means "keep existing" when username matches. + for i := range payload.Items { + if payload.Items[i].Password != "" { + continue + } + for _, prev := range existing.Items { + if prev.Username == payload.Items[i].Username && prev.Password != "" { + payload.Items[i].Password = prev.Password + break + } + } + } + if err := hashAccessListPasswords(&payload); err != nil { + http.Error(w, "password hash failed", 500) + return + } updated, err := st.UpdateAccessList(payload) if err != nil { http.Error(w, err.Error(), 500) return } eng.ReloadFromStore() - _ = json.NewEncoder(w).Encode(updated) + _ = json.NewEncoder(w).Encode(redactAccessList(updated)) _, _ = st.AddAuditLog(store.AuditLog{ UserID: claims.UserID, ObjectType: "access-list", @@ -1281,3 +1302,42 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M }) }) } + + +func hashAccessListPasswords(al *store.AccessList) error { + for i := range al.Items { + pw := al.Items[i].Password + if pw == "" { + continue + } + if strings.HasPrefix(pw, "$2a$") || strings.HasPrefix(pw, "$2b$") || strings.HasPrefix(pw, "$2y$") { + continue + } + h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + if err != nil { + return err + } + al.Items[i].Password = string(h) + } + return nil +} + +func redactAccessList(al store.AccessList) store.AccessList { + out := al + out.Items = make([]store.AccessItem, len(al.Items)) + copy(out.Items, al.Items) + for i := range out.Items { + if out.Items[i].Password != "" { + out.Items[i].Password = "" + } + } + return out +} + +func redactAccessLists(list []store.AccessList) []store.AccessList { + out := make([]store.AccessList, len(list)) + for i, al := range list { + out[i] = redactAccessList(al) + } + return out +} diff --git a/internal/proxy/engine.go b/internal/proxy/engine.go index d2fb9d2..d86cd46 100644 --- a/internal/proxy/engine.go +++ b/internal/proxy/engine.go @@ -1,6 +1,7 @@ package proxy import ( + "golang.org/x/crypto/bcrypt" "context" "crypto/tls" "encoding/base64" @@ -349,7 +350,7 @@ func (e *Engine) Handler() http.Handler { user, pass := creds[0], creds[1] authed := false for _, item := range hcfg.AccessItems { - if item.Username == user && item.Password == pass { // demo: plain compare; real would hash + if item.Username == user && accessListPasswordOK(item.Password, pass) { authed = true break } @@ -1250,3 +1251,15 @@ func parseAdvancedConfig(adv string) (reqSets map[string]string, respAdds map[st } return } + + +func accessListPasswordOK(stored, provided string) bool { + if stored == "" { + return false + } + if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") { + return bcrypt.CompareHashAndPassword([]byte(stored), []byte(provided)) == nil + } + // Legacy plaintext rows until re-saved via admin API. + return stored == provided +} -- 2.39.5 From 308796846365b93a58e32bc618a2d18c7c41faa4 Mon Sep 17 00:00:00 2001 From: blake Date: Mon, 14 Sep 2026 13:38:52 +0000 Subject: [PATCH 2/2] format: gofmt api.go and engine.go --- cmd/helix-proxy/api.go | 3 +-- internal/proxy/engine.go | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/helix-proxy/api.go b/cmd/helix-proxy/api.go index 85887c2..9ab4d7b 100644 --- a/cmd/helix-proxy/api.go +++ b/cmd/helix-proxy/api.go @@ -1,11 +1,11 @@ package main import ( - "strings" "encoding/json" "log/slog" "net/http" "strconv" + "strings" "helix-proxy/internal/auth" "helix-proxy/internal/certificate" @@ -1303,7 +1303,6 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M }) } - func hashAccessListPasswords(al *store.AccessList) error { for i := range al.Items { pw := al.Items[i].Password diff --git a/internal/proxy/engine.go b/internal/proxy/engine.go index d86cd46..b63d845 100644 --- a/internal/proxy/engine.go +++ b/internal/proxy/engine.go @@ -1,11 +1,11 @@ package proxy import ( - "golang.org/x/crypto/bcrypt" "context" "crypto/tls" "encoding/base64" "fmt" + "golang.org/x/crypto/bcrypt" "io" "log/slog" "net" @@ -1252,7 +1252,6 @@ func parseAdvancedConfig(adv string) (reqSets map[string]string, respAdds map[st return } - func accessListPasswordOK(stored, provided string) bool { if stored == "" { return false -- 2.39.5