diff --git a/cmd/helix-proxy/api.go b/cmd/helix-proxy/api.go index a158e18..9ab4d7b 100644 --- a/cmd/helix-proxy/api.go +++ b/cmd/helix-proxy/api.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "helix-proxy/internal/auth" "helix-proxy/internal/certificate" @@ -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,41 @@ 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..b63d845 100644 --- a/internal/proxy/engine.go +++ b/internal/proxy/engine.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/base64" "fmt" + "golang.org/x/crypto/bcrypt" "io" "log/slog" "net" @@ -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,14 @@ 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 +}