access-list: Hash basic-auth passwords and redact API reads #18

Merged
eva merged 2 commits from blake/sec-accesslist-hash into develop 2026-09-14 08:41:00 -05:00
2 changed files with 78 additions and 5 deletions
Showing only changes of commit 61c7b5582c - Show all commits
+64 -4
View File
@@ -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
}
+14 -1
View File
@@ -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
}