access-list: Hash basic-auth passwords and redact API reads
Format / gofmt (pull_request) Failing after 15s
Format / gofmt (push) Failing after 15s
CI / Build (push) Successful in 36s
CI / Build (pull_request) Successful in 37s
CI / Go Tests (pull_request) Successful in 1m20s
CI / Go Tests (push) Failing after 1m20s
Format / gofmt (pull_request) Failing after 15s
Format / gofmt (push) Failing after 15s
CI / Build (push) Successful in 36s
CI / Build (pull_request) Successful in 37s
CI / Go Tests (pull_request) Successful in 1m20s
CI / Go Tests (push) Failing after 1m20s
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.
This commit is contained in:
+64
-4
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -107,7 +108,7 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
|||||||
}
|
}
|
||||||
_ = claims
|
_ = claims
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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) {
|
r.Post("/access-lists", func(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, ok := auth.RequireAuth(w, r)
|
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 {
|
if payload.OwnerUserID == 0 {
|
||||||
payload.OwnerUserID = claims.UserID
|
payload.OwnerUserID = claims.UserID
|
||||||
}
|
}
|
||||||
|
if err := hashAccessListPasswords(&payload); err != nil {
|
||||||
|
http.Error(w, "password hash failed", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
created, err := st.CreateAccessList(payload)
|
created, err := st.CreateAccessList(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_ = json.NewEncoder(w).Encode(created)
|
_ = json.NewEncoder(w).Encode(redactAccessList(created))
|
||||||
eng.ReloadFromStore()
|
eng.ReloadFromStore()
|
||||||
_, _ = st.AddAuditLog(store.AuditLog{
|
_, _ = st.AddAuditLog(store.AuditLog{
|
||||||
UserID: claims.UserID,
|
UserID: claims.UserID,
|
||||||
@@ -153,7 +158,7 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if al, ok := st.GetAccessList(id); ok {
|
if al, ok := st.GetAccessList(id); ok {
|
||||||
_ = json.NewEncoder(w).Encode(al)
|
_ = json.NewEncoder(w).Encode(redactAccessList(al))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "not found", 404)
|
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
|
payload.OwnerUserID = existing.OwnerUserID
|
||||||
// preserve CreatedOn on update (server-set on create; do not lose on edit)
|
// preserve CreatedOn on update (server-set on create; do not lose on edit)
|
||||||
payload.CreatedOn = existing.CreatedOn
|
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)
|
updated, err := st.UpdateAccessList(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
eng.ReloadFromStore()
|
eng.ReloadFromStore()
|
||||||
_ = json.NewEncoder(w).Encode(updated)
|
_ = json.NewEncoder(w).Encode(redactAccessList(updated))
|
||||||
_, _ = st.AddAuditLog(store.AuditLog{
|
_, _ = st.AddAuditLog(store.AuditLog{
|
||||||
UserID: claims.UserID,
|
UserID: claims.UserID,
|
||||||
ObjectType: "access-list",
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -349,7 +350,7 @@ func (e *Engine) Handler() http.Handler {
|
|||||||
user, pass := creds[0], creds[1]
|
user, pass := creds[0], creds[1]
|
||||||
authed := false
|
authed := false
|
||||||
for _, item := range hcfg.AccessItems {
|
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
|
authed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1250,3 +1251,15 @@ func parseAdvancedConfig(adv string) (reqSets map[string]string, respAdds map[st
|
|||||||
}
|
}
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user