From daab7bf6995fb006d9612da9d9540f30b94889b5 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 31 May 2024 17:08:15 +0200 Subject: [PATCH 01/28] Can delete own aliases --- addy.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++------ login.go | 8 ++++++-- main.go | 3 ++- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/addy.go b/addy.go index fcb76d4..806717c 100644 --- a/addy.go +++ b/addy.go @@ -66,25 +66,32 @@ func checkAddyApiAuthorization(authorization []byte) *string { return &username } -func addyAliasAPI(w http.ResponseWriter, r *http.Request) { +func addyAliasAPIAuth(r *http.Request) (*string, error) { // Check authorization header fields := strings.Fields(r.Header.Get("Authorization")) if len(fields) != 2 || fields[0] != "Bearer" { - http.Error(w, "Authorization header should be a valid Bearer token", http.StatusUnauthorized) - return + return nil, fmt.Errorf("Authorization header should be a valid Bearer token") } // Decode header authorization, err := base32.StdEncoding.DecodeString(fields[1]) if err != nil { log.Println("Invalid Authorization header: %s", err.Error()) - http.Error(w, "Authorization header should be a valid Bearer token", http.StatusUnauthorized) - return + return nil, err } user := checkAddyApiAuthorization(authorization) if user == nil { - http.Error(w, "Not authorized", http.StatusUnauthorized) + return nil, fmt.Errorf("Not authorized") + } + + return user, nil +} + +func addyAliasAPI(w http.ResponseWriter, r *http.Request) { + user, err := addyAliasAPIAuth(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) return } @@ -154,6 +161,47 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) { } } +func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) { + user, err := addyAliasAPIAuth(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + email := r.PathValue("alias") + + conn, err := myLDAP.Connect() + if err != nil || conn == nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = conn.ServiceBind() + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + dn, err := conn.SearchDN(*user, true) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = conn.DelMailAlias(dn, email) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Printf("Alias deleted for %s: %s", dn, email) + http.Error(w, "", http.StatusOK) +} + func generateRandomString(length int) string { charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" result := make([]byte, length) diff --git a/login.go b/login.go index a702e07..a00ca0c 100644 --- a/login.go +++ b/login.go @@ -50,17 +50,21 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { log.Println(err) displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()}) } else { + apiToken := AddyAPIToken(r.PostFormValue("login")) + cnt := "

To use our Addy.io compatible API, use the following token: " + AddyAPIToken(r.PostFormValue("login")) + "

")}) + displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok

Here are the information we have about you:` + cnt + "

To use our Addy.io compatible API, use the following token: " + apiToken + "

")}) } } diff --git a/main.go b/main.go index 696b94e..f1b4436 100644 --- a/main.go +++ b/main.go @@ -148,8 +148,9 @@ func main() { signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) // Register handlers - http.HandleFunc(fmt.Sprintf("%s/", *baseURL), changePassword) + http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword) http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI) + http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", *baseURL), addyAliasAPIDelete) http.HandleFunc(fmt.Sprintf("%s/auth", *baseURL), httpBasicAuth) http.HandleFunc(fmt.Sprintf("%s/login", *baseURL), tryLogin) http.HandleFunc(fmt.Sprintf("%s/change", *baseURL), changePassword) From a9eae794147f4a0a5ad29832db8a71ebde2561e1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 31 May 2024 17:19:12 +0200 Subject: [PATCH 02/28] Hide krbPrincipalKey --- login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/login.go b/login.go index a00ca0c..8329600 100644 --- a/login.go +++ b/login.go @@ -55,7 +55,7 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { cnt := "

To use our Addy.io compatible API, use the following token: " + apiToken + "

")}) } } diff --git a/main.go b/main.go index 696b94e..f1b4436 100644 --- a/main.go +++ b/main.go @@ -148,8 +148,9 @@ func main() { signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) // Register handlers - http.HandleFunc(fmt.Sprintf("%s/", *baseURL), changePassword) + http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword) http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI) + http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", *baseURL), addyAliasAPIDelete) http.HandleFunc(fmt.Sprintf("%s/auth", *baseURL), httpBasicAuth) http.HandleFunc(fmt.Sprintf("%s/login", *baseURL), tryLogin) http.HandleFunc(fmt.Sprintf("%s/change", *baseURL), changePassword) From ee1f8ce69f9ec9b96834a3390be81437374ab108 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 31 May 2024 17:19:12 +0200 Subject: [PATCH 10/28] Hide krbPrincipalKey --- login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/login.go b/login.go index a00ca0c..8329600 100644 --- a/login.go +++ b/login.go @@ -55,7 +55,7 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { cnt := "
    " for _, e := range entries { for i, v := range e.Values { - if e.Name == "userPassword" { + if e.Name == "userPassword" || e.Name == "krbPrincipalKey" { cnt += "
  • " + e.Name + ": [...]
  • " } else if e.Name == "mailAlias" && len(strings.SplitN(v, "@", 2)[0]) == 10 { cnt += "
  • " + e.Name + ": " + v + `
  • ` From 0197446952df153df01465fff16891569ab2cb0d Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 26 Dec 2024 23:07:53 +0000 Subject: [PATCH 11/28] chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.10 --- go.mod | 6 +++--- go.sum | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0cddef4..e91a6a4 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,14 @@ go 1.22 require ( github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 - github.com/go-ldap/ldap/v3 v3.4.8 + github.com/go-ldap/ldap/v3 v3.4.10 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/google/uuid v1.6.0 // indirect - golang.org/x/crypto v0.21.0 // indirect + golang.org/x/crypto v0.31.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect ) diff --git a/go.sum b/go.sum index 94e9ed2..90b79da 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,13 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= +github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= +github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= +github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= @@ -44,11 +49,18 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -56,12 +68,19 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -69,24 +88,36 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= From 6836e70e830520e15e6dca342315dbb5f4215054 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 6 Jan 2025 14:46:11 +0100 Subject: [PATCH 12/28] Can launch the executable with arguments to get reset token --- lost.go | 44 ++++++++++++++++++++++++++------------------ main.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/lost.go b/lost.go index f4ccbe6..0ddd36f 100644 --- a/lost.go +++ b/lost.go @@ -51,6 +51,25 @@ func (l LDAPConn) genToken(dn string, previous bool) string { return base64.StdEncoding.EncodeToString(hash.Sum(nil)[:]) } +func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { + // Bind as service to perform the search + err := conn.ServiceBind() + if err != nil { + return "", "", err + } + + // Search the dn of the given user + dn, err := conn.SearchDN(login, true) + if err != nil { + return "", "", err + } + + // Generate the token + token := conn.genToken(dn, false) + + return token, dn, nil +} + func lostPassword(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { displayTmpl(w, "lost.html", map[string]interface{}{}) @@ -65,24 +84,13 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { return } - // Bind as service to perform the search - err = conn.ServiceBind() - if err != nil { - log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) - return - } - - // Search the dn of the given user - dn, err := conn.SearchDN(r.PostFormValue("login"), true) - if err != nil { - log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) - return - } - // Generate the token - token := conn.genToken(dn, false) + token, dn, err := lostPasswordToken(conn, r.PostFormValue("login")) + if err != nil { + log.Println(err) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) + return + } // Search the email address entries, err := conn.GetEntry(dn) @@ -114,7 +122,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { m.SetHeader("From", "noreply@nemunai.re") m.SetHeader("To", email) m.SetHeader("Subject", "SSO nemunai.re: password recovery") - m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\nhttps://ldap.nemunai.re/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nBest regards,\n-- \nnemunai.re SSO") + m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+BASEURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nBest regards,\n-- \nnemunai.re SSO") var s gomail.Sender if myLDAP.MailHost != "" { diff --git a/main.go b/main.go index f1b4436..9ab3e58 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,8 @@ import ( "syscall" ) +const BASEURL = "https://ldap.nemunai.re" + var myLDAP = LDAP{ Host: "localhost", Port: 389, @@ -143,6 +145,35 @@ func main() { myLDAP.MailPassword = val } + if flag.NArg() > 0 { + switch flag.Arg(0) { + case "generate-lost-password-link": + if flag.NArg() != 2 { + log.Fatal("Need a second argument: email of the user to reset") + } + + login := flag.Arg(1) + + conn, err := myLDAP.Connect() + if err != nil || conn == nil { + log.Fatalf("Unable to connect to LDAP: %s", err.Error()) + } + + token, dn, err := lostPasswordToken(conn, login) + if err != nil { + log.Fatal(err.Error()) + } + + fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, BASEURL, login, token) + return + case "serve": + case "server": + break + default: + log.Fatalf("%q is not a valid command", flag.Arg(0)) + } + } + // Prepare graceful shutdown interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) From 399e8b6367546cebee6e7480755a0587fbcd253e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 7 May 2025 03:03:41 +0000 Subject: [PATCH 13/28] chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.11 --- go.mod | 10 ++++++---- go.sum | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index e91a6a4..d395630 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,19 @@ module git.nemunai.re/chldapasswd -go 1.22 +go 1.23.0 + +toolchain go1.24.3 require ( github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 - github.com/go-ldap/ldap/v3 v3.4.10 + github.com/go-ldap/ldap/v3 v3.4.11 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/google/uuid v1.6.0 // indirect - golang.org/x/crypto v0.31.0 // indirect + golang.org/x/crypto v0.36.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect ) diff --git a/go.sum b/go.sum index 90b79da..8ebd98e 100644 --- a/go.sum +++ b/go.sum @@ -11,10 +11,14 @@ github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= +github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= +github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -56,6 +60,8 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= From 000f04a8f68dab5ecca9755f5e09a793aeee9674 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 18 Oct 2025 10:49:30 +0000 Subject: [PATCH 14/28] chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.12 --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d395630..10589df 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.24.3 require ( github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 - github.com/go-ldap/ldap/v3 v3.4.11 + github.com/go-ldap/ldap/v3 v3.4.12 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) diff --git a/go.sum b/go.sum index 8ebd98e..ea772ab 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJ github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= From 4b7405fc613a20519c9d9675eb4174107d340000 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 2 Dec 2025 17:02:05 +0000 Subject: [PATCH 15/28] chore(deps): update dependency go to v1.25.5 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 10589df..efc9d1a 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module git.nemunai.re/chldapasswd go 1.23.0 -toolchain go1.24.3 +toolchain go1.25.5 require ( github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 From 121770c18a09241e7362d4073c5093ede7aa6d50 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 10 Feb 2026 21:01:49 +0000 Subject: [PATCH 16/28] chore(deps): update dependency go to v1.26.0 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index efc9d1a..17e467e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module git.nemunai.re/chldapasswd go 1.23.0 -toolchain go1.25.5 +toolchain go1.26.0 require ( github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 From 10f41e4ef84db8ee4842be47fd841f500cf54247 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:38:39 +0700 Subject: [PATCH 17/28] fix(security): escape LDAP filter inputs to prevent filter injection (CWE-90) Use ldap.EscapeFilter() on all user-controlled inputs before interpolating them into LDAP search filter strings in SearchDN and SearchMailAlias. Prevents authentication bypass via filter manipulation. Co-Authored-By: Claude Sonnet 4.6 --- ldap.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ldap.go b/ldap.go index 773d1a9..e890b27 100644 --- a/ldap.go +++ b/ldap.go @@ -74,7 +74,7 @@ func (l LDAPConn) SearchDN(username string, person bool) (string, error) { searchRequest := ldap.NewSearchRequest( l.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=%s)(uid=%s))", objectClass, username), + fmt.Sprintf("(&(objectClass=%s)(uid=%s))", ldap.EscapeFilter(objectClass), ldap.EscapeFilter(username)), []string{"dn"}, nil, ) @@ -147,7 +147,7 @@ func (l LDAPConn) SearchMailAlias(address string) (int, error) { searchRequest := ldap.NewSearchRequest( l.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=*)(mailAlias=%s))", address), + fmt.Sprintf("(&(objectClass=*)(mailAlias=%s))", ldap.EscapeFilter(address)), []string{"dn"}, nil, ) From a2f368eb029c259517ea81a1e9bfad4032f6f21e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:40:45 +0700 Subject: [PATCH 18/28] fix(security): add missing return after redirect in resetPassword handler http.Redirect only sets response headers; without return, handler execution continued with empty login/token strings, potentially causing unexpected LDAP queries and information leakage. Co-Authored-By: Claude Sonnet 4.6 --- reset.go | 1 + 1 file changed, 1 insertion(+) diff --git a/reset.go b/reset.go index 87dfc76..f644507 100644 --- a/reset.go +++ b/reset.go @@ -9,6 +9,7 @@ import ( func resetPassword(w http.ResponseWriter, r *http.Request) { if len(r.URL.Query().Get("l")) == 0 || len(r.URL.Query().Get("t")) == 0 { http.Redirect(w, r, "lost", http.StatusFound) + return } base := map[string]interface{}{ From 57775bbf895c89d4e18f9aa3f7a718d742f64a0e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:44:29 +0700 Subject: [PATCH 19/28] fix(security): redesign password reset tokens using crypto/rand with server-side storage - Replace SHA512-based deterministic token with 32-byte crypto/rand token - Store tokens server-side with 1-hour expiry and single-use semantics - Remove genToken (previously broken due to time.Add immutability bug) - Add CSRF double-submit cookie protection to change/lost/reset forms - Remove token from form action URL (use hidden fields only, POST body) - Add MailFrom field and SMTP_FROM env var for configurable sender address - Add SMTP_PASSWORD_FILE env var for secure SMTP password loading - Add PUBLIC_URL env var and --public-url flag for configurable reset link domain - Use generic error messages in handlers to avoid information disclosure Co-Authored-By: Claude Sonnet 4.6 --- change.go | 34 ++++++++++---- csrf.go | 39 ++++++++++++++++ ldap.go | 1 + lost.go | 111 ++++++++++++++++++++++++++++----------------- main.go | 26 +++++++++-- reset.go | 62 +++++++++++++------------ static/change.html | 1 + static/lost.html | 1 + static/reset.html | 3 +- 9 files changed, 194 insertions(+), 84 deletions(-) create mode 100644 csrf.go diff --git a/change.go b/change.go index 08dbd39..35d3877 100644 --- a/change.go +++ b/change.go @@ -16,34 +16,50 @@ func checkPasswdConstraint(password string) error { func changePassword(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - displayTmpl(w, "change.html", map[string]interface{}{}) + csrfToken, err := setCSRFToken(w) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + displayTmpl(w, "change.html", map[string]interface{}{"csrf_token": csrfToken}) return } + if !validateCSRF(r) { + csrfToken, _ := setCSRFToken(w) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken}) + return + } + + renderError := func(status int, msg string) { + csrfToken, _ := setCSRFToken(w) + displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken}) + } + // Check the two new passwords are identical if r.PostFormValue("newpassword") != r.PostFormValue("new2password") { - displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "New passwords are not identical. Please retry."}) + renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.") } else if len(r.PostFormValue("login")) == 0 { - displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "Please provide a valid login"}) + renderError(http.StatusNotAcceptable, "Please provide a valid login") } else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil { - displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "The password you chose doesn't respect all constraints: " + err.Error()}) + renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error()) } else { conn, err := myLDAP.Connect() if err != nil || conn == nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()}) + renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") } else if err := conn.ServiceBind(); err != nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()}) + renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") } else if dn, err := conn.SearchDN(r.PostFormValue("login"), true); err != nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()}) + renderError(http.StatusUnauthorized, "Invalid login or password.") } else if err := conn.Bind(dn, r.PostFormValue("password")); err != nil { log.Println(err) - displayTmplError(w, http.StatusUnauthorized, "change.html", map[string]interface{}{"error": err.Error()}) + renderError(http.StatusUnauthorized, "Invalid login or password.") } else if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()}) + renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") } else { displayMsg(w, "Password successfully changed!", http.StatusOK) } diff --git a/csrf.go b/csrf.go new file mode 100644 index 0000000..a94be83 --- /dev/null +++ b/csrf.go @@ -0,0 +1,39 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "net/http" +) + +func generateCSRFToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func setCSRFToken(w http.ResponseWriter) (string, error) { + token, err := generateCSRFToken() + if err != nil { + return "", err + } + http.SetCookie(w, &http.Cookie{ + Name: "csrf_token", + Value: token, + Path: "/", + HttpOnly: false, // must be readable via form hidden field comparison + SameSite: http.SameSiteStrictMode, + }) + return token, nil +} + +func validateCSRF(r *http.Request) bool { + cookie, err := r.Cookie("csrf_token") + if err != nil || cookie.Value == "" { + return false + } + formToken := r.PostFormValue("csrf_token") + return formToken != "" && cookie.Value == formToken +} diff --git a/ldap.go b/ldap.go index e890b27..85271fe 100644 --- a/ldap.go +++ b/ldap.go @@ -23,6 +23,7 @@ type LDAP struct { MailPort int MailUser string MailPassword string + MailFrom string } func (l LDAP) Connect() (*LDAPConn, error) { diff --git a/lost.go b/lost.go index 0ddd36f..6481d62 100644 --- a/lost.go +++ b/lost.go @@ -1,54 +1,64 @@ package main import ( - "crypto/sha512" + "crypto/rand" "encoding/base64" - "encoding/binary" "io" "log" "net/http" "os" "os/exec" + "sync" "time" "gopkg.in/gomail.v2" ) -func (l LDAPConn) genToken(dn string, previous bool) string { - hour := time.Now() - // Generate the previous token? - if previous { - hour.Add(time.Hour * -1) +type resetTokenEntry struct { + dn string + expiresAt time.Time +} + +var resetTokenStore = struct { + mu sync.Mutex + tokens map[string]resetTokenEntry +}{tokens: make(map[string]resetTokenEntry)} + +func generateResetToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err } + return base64.URLEncoding.EncodeToString(b), nil +} - b := make([]byte, binary.MaxVarintLen64) - binary.PutVarint(b, hour.Round(time.Hour).Unix()) +func storeResetToken(token string, dn string) { + resetTokenStore.mu.Lock() + defer resetTokenStore.mu.Unlock() - // Search the email address and current password - entries, err := l.GetEntry(dn) - if err != nil { - log.Println("Unable to generate token:", err) - return "#err" - } - - email := "" - curpasswd := "" - for _, e := range entries { - if e.Name == "mail" { - email += e.Values[0] - } else if e.Name == "userPassword" { - curpasswd += e.Values[0] + // Clean expired tokens + now := time.Now() + for t, e := range resetTokenStore.tokens { + if now.After(e.expiresAt) { + delete(resetTokenStore.tokens, t) } } + resetTokenStore.tokens[token] = resetTokenEntry{ + dn: dn, + expiresAt: now.Add(time.Hour), + } +} - // Hash that - hash := sha512.New() - hash.Write(b) - hash.Write([]byte(dn)) - hash.Write([]byte(email)) - hash.Write([]byte(curpasswd)) - - return base64.StdEncoding.EncodeToString(hash.Sum(nil)[:]) +func consumeResetToken(token string) (string, bool) { + resetTokenStore.mu.Lock() + defer resetTokenStore.mu.Unlock() + entry, ok := resetTokenStore.tokens[token] + if !ok || time.Now().After(entry.expiresAt) { + delete(resetTokenStore.tokens, token) + return "", false + } + delete(resetTokenStore.tokens, token) + return entry.dn, true } func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { @@ -64,15 +74,31 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { return "", "", err } - // Generate the token - token := conn.genToken(dn, false) + // Generate a cryptographically random token + token, err := generateResetToken() + if err != nil { + return "", "", err + } + + // Store token server-side with expiration + storeResetToken(token, dn) return token, dn, nil } func lostPassword(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - displayTmpl(w, "lost.html", map[string]interface{}{}) + csrfToken, err := setCSRFToken(w) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + displayTmpl(w, "lost.html", map[string]interface{}{"csrf_token": csrfToken}) + return + } + + if !validateCSRF(r) { + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again."}) return } @@ -80,7 +106,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { conn, err := myLDAP.Connect() if err != nil || conn == nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to process your request. Please try again later."}) return } @@ -88,7 +114,8 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { token, dn, err := lostPasswordToken(conn, r.PostFormValue("login")) if err != nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) + // Return generic message to avoid user enumeration + displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK) return } @@ -96,7 +123,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { entries, err := conn.GetEntry(dn) if err != nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) + displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK) return } @@ -113,16 +140,16 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { if email == "" { log.Println("Unable to find a valid adress for user " + dn) - displayTmplError(w, http.StatusBadRequest, "lost.html", map[string]interface{}{"error": "We were unable to find a valid email address associated with your account. Please contact an administrator."}) + displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK) return } // Send the email m := gomail.NewMessage() - m.SetHeader("From", "noreply@nemunai.re") + m.SetHeader("From", myLDAP.MailFrom) m.SetHeader("To", email) m.SetHeader("Subject", "SSO nemunai.re: password recovery") - m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+BASEURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nBest regards,\n-- \nnemunai.re SSO") + m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+myPublicURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nThis link expires in 1 hour and can only be used once.\n\nBest regards,\n-- \nnemunai.re SSO") var s gomail.Sender if myLDAP.MailHost != "" { @@ -130,7 +157,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { s, err = d.Dial() if err != nil { log.Println("Unable to connect to email server: " + err.Error()) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to connect to email server: " + err.Error()}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."}) return } } else { @@ -165,7 +192,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { if err := gomail.Send(s, m); err != nil { log.Println("Unable to send email: " + err.Error()) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send email: " + err.Error()}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."}) return } diff --git a/main.go b/main.go index 9ab3e58..67e4234 100644 --- a/main.go +++ b/main.go @@ -17,13 +17,14 @@ import ( "syscall" ) -const BASEURL = "https://ldap.nemunai.re" +var myPublicURL = "https://ldap.nemunai.re" var myLDAP = LDAP{ Host: "localhost", Port: 389, BaseDN: "dc=example,dc=com", MailPort: 587, + MailFrom: "noreply@nemunai.re", } type ResponseWriterPrefix struct { @@ -70,8 +71,11 @@ func main() { var bind = flag.String("bind", "127.0.0.1:8080", "Bind port/socket") var baseURL = flag.String("baseurl", "/", "URL prepended to each URL") var configfile = flag.String("config", "", "path to the configuration file") + var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails") flag.Parse() + myPublicURL = *publicURL + // Sanitize options log.Println("Checking paths...") if *baseURL != "/" { @@ -141,9 +145,25 @@ func main() { if val, ok := os.LookupEnv("SMTP_USER"); ok { myLDAP.MailUser = val } - if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok { + if val, ok := os.LookupEnv("SMTP_PASSWORD_FILE"); ok { + if fd, err := os.Open(val); err != nil { + log.Fatal(err) + } else if cnt, err := os.ReadFile(val); err != nil { + fd.Close() + log.Fatal(err) + } else { + fd.Close() + myLDAP.MailPassword = string(cnt) + } + } else if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok { myLDAP.MailPassword = val } + if val, ok := os.LookupEnv("SMTP_FROM"); ok { + myLDAP.MailFrom = val + } + if val, ok := os.LookupEnv("PUBLIC_URL"); ok { + myPublicURL = val + } if flag.NArg() > 0 { switch flag.Arg(0) { @@ -164,7 +184,7 @@ func main() { log.Fatal(err.Error()) } - fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, BASEURL, login, token) + fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, myPublicURL, login, token) return case "serve": case "server": diff --git a/reset.go b/reset.go index f644507..225d22d 100644 --- a/reset.go +++ b/reset.go @@ -3,7 +3,6 @@ package main import ( "log" "net/http" - "strings" ) func resetPassword(w http.ResponseWriter, r *http.Request) { @@ -14,22 +13,46 @@ func resetPassword(w http.ResponseWriter, r *http.Request) { base := map[string]interface{}{ "login": r.URL.Query().Get("l"), - "token": strings.Replace(r.URL.Query().Get("t"), " ", "+", -1), + "token": r.URL.Query().Get("t"), } if r.Method != "POST" { + csrfToken, err := setCSRFToken(w) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + base["csrf_token"] = csrfToken displayTmpl(w, "reset.html", base) return } + renderError := func(status int, msg string) { + csrfToken, _ := setCSRFToken(w) + base["error"] = msg + base["csrf_token"] = csrfToken + displayTmplError(w, status, "reset.html", base) + } + + if !validateCSRF(r) { + renderError(http.StatusForbidden, "Invalid or missing CSRF token. Please try again.") + return + } + // Check the two new passwords are identical if r.PostFormValue("newpassword") != r.PostFormValue("new2password") { - base["error"] = "New passwords are not identical. Please retry." - displayTmplError(w, http.StatusNotAcceptable, "reset.html", base) + renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.") return } else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil { - base["error"] = "The password you chose doesn't respect all constraints: " + err.Error() - displayTmplError(w, http.StatusNotAcceptable, "reset.html", base) + renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error()) + return + } + + // Validate and consume the token (single-use, server-side) + token := r.PostFormValue("token") + dn, ok := consumeResetToken(token) + if !ok { + renderError(http.StatusNotAcceptable, "Token invalid or expired, please retry the lost password procedure. Tokens expire after 1 hour.") return } @@ -37,41 +60,22 @@ func resetPassword(w http.ResponseWriter, r *http.Request) { conn, err := myLDAP.Connect() if err != nil || conn == nil { log.Println(err) - base["error"] = err.Error() - displayTmplError(w, http.StatusInternalServerError, "reset.html", base) + renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") return } - // Bind as service to perform the search + // Bind as service to perform the password change err = conn.ServiceBind() if err != nil { log.Println(err) - base["error"] = err.Error() - displayTmplError(w, http.StatusInternalServerError, "reset.html", base) - return - } - - // Search the dn of the given user - dn, err := conn.SearchDN(r.PostFormValue("login"), true) - if err != nil { - log.Println(err) - base["error"] = err.Error() - displayTmplError(w, http.StatusInternalServerError, "reset.html", base) - return - } - - // Check token validity (allow current token + last one) - if conn.genToken(dn, false) != r.PostFormValue("token") && conn.genToken(dn, true) != r.PostFormValue("token") { - base["error"] = "Token invalid, please retry the lost password procedure. Please note that our token expires after 1 hour." - displayTmplError(w, http.StatusNotAcceptable, "reset.html", base) + renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") return } // Replace the password by the new given if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil { log.Println(err) - base["error"] = err.Error() - displayTmplError(w, http.StatusInternalServerError, "reset.html", base) + renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") return } diff --git a/static/change.html b/static/change.html index 019e7f5..60ffe7e 100644 --- a/static/change.html +++ b/static/change.html @@ -3,6 +3,7 @@
    {{if .error}}{{end}} +
    diff --git a/static/lost.html b/static/lost.html index 7b6e2e7..e924c3c 100644 --- a/static/lost.html +++ b/static/lost.html @@ -3,6 +3,7 @@ {{if .error}}{{end}} +
    diff --git a/static/reset.html b/static/reset.html index dac37d7..641f179 100644 --- a/static/reset.html +++ b/static/reset.html @@ -1,8 +1,9 @@ {{template "header"}}

    Forgot your password? Define a new one!

    - + {{if .error}}{{end}} +
    From 93673510d838d6d7c6b8c5af2c17bf8e55f118c3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:45:15 +0700 Subject: [PATCH 20/28] fix(security): escape LDAP attribute data in HTML output to prevent XSS (CWE-79) Use html.EscapeString for attribute names and values when building HTML. Move dynamic data (alias URL, API token) to data-* attributes and use a self-contained onclick function to read them, eliminating JS string injection via LDAP-controlled values. Co-Authored-By: Claude Sonnet 4.6 --- login.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/login.go b/login.go index 8329600..a93df7c 100644 --- a/login.go +++ b/login.go @@ -2,9 +2,11 @@ package main import ( "fmt" + "html" "html/template" "log" "net/http" + "net/url" "strings" "github.com/go-ldap/ldap/v3" @@ -55,16 +57,23 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { cnt := "
      " for _, e := range entries { for i, v := range e.Values { + safeName := html.EscapeString(e.Name) + safeVal := html.EscapeString(v) + elemID := fmt.Sprintf("mailAlias-%d", i) if e.Name == "userPassword" || e.Name == "krbPrincipalKey" { - cnt += "
    • " + e.Name + ": [...]
    • " + cnt += "
    • " + safeName + ": [...]
    • " } else if e.Name == "mailAlias" && len(strings.SplitN(v, "@", 2)[0]) == 10 { - cnt += "
    • " + e.Name + ": " + v + `
    • ` + safeURL := url.PathEscape(v) + safeToken := html.EscapeString(apiToken) + safeElemID := html.EscapeString(elemID) + cnt += `
    • ` + safeName + `: ` + safeVal + + `
    • ` } else { - cnt += "
    • " + e.Name + ": " + v + "
    • " + cnt += "
    • " + safeName + ": " + safeVal + "
    • " } } } - displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok

      Here are the information we have about you:` + cnt + "

    To use our Addy.io compatible API, use the following token: " + apiToken + "

    ")}) + displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok

    Here are the information we have about you:` + cnt + "

To use our Addy.io compatible API, use the following token: " + html.EscapeString(apiToken) + "

")}) } } From 2a9eec233aeca9e9b978d8522a8572df6e7bad78 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:46:13 +0700 Subject: [PATCH 21/28] fix(security): add per-IP rate limiting to all authentication endpoints Implement sliding window rate limiter to prevent brute-force attacks: - /auth and /login: 20 requests/minute per IP - /change: 10 POST requests/minute per IP - /lost: 5 POST requests/minute per IP (prevents email spam and user enumeration) - /reset: 10 POST requests/minute per IP - /api/v1/aliases: 30 requests/minute per IP Co-Authored-By: Claude Sonnet 4.6 --- addy.go | 10 +++++++++ change.go | 6 +++++ login.go | 12 ++++++++++ lost.go | 5 +++++ ratelimit.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ reset.go | 5 +++++ 6 files changed, 101 insertions(+) create mode 100644 ratelimit.go diff --git a/addy.go b/addy.go index d1573d7..3d3ab19 100644 --- a/addy.go +++ b/addy.go @@ -89,6 +89,11 @@ func addyAliasAPIAuth(r *http.Request) (*string, error) { } func addyAliasAPI(w http.ResponseWriter, r *http.Request) { + if !aliasLimiter.Allow(remoteIP(r)) { + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + user, err := addyAliasAPIAuth(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) @@ -162,6 +167,11 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) { } func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) { + if !aliasLimiter.Allow(remoteIP(r)) { + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + user, err := addyAliasAPIAuth(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) diff --git a/change.go b/change.go index 35d3877..0fd8d07 100644 --- a/change.go +++ b/change.go @@ -15,6 +15,12 @@ func checkPasswdConstraint(password string) error { } func changePassword(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) { + csrfToken, _ := setCSRFToken(w) + displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken}) + return + } + if r.Method != "POST" { csrfToken, err := setCSRFToken(w) if err != nil { diff --git a/login.go b/login.go index a93df7c..14b9fef 100644 --- a/login.go +++ b/login.go @@ -48,6 +48,11 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { return } + if !authLimiter.Allow(remoteIP(r)) { + displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]interface{}{"error": "Too many login attempts. Please try again later."}) + return + } + if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil { log.Println(err) displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()}) @@ -78,6 +83,13 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { } func httpBasicAuth(w http.ResponseWriter, r *http.Request) { + if !authLimiter.Allow(remoteIP(r)) { + w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`) + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte("Too many requests")) + return + } + if user, pass, ok := r.BasicAuth(); ok { if entries, err := login(user, pass); err != nil { w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`) diff --git a/lost.go b/lost.go index 6481d62..250ac42 100644 --- a/lost.go +++ b/lost.go @@ -87,6 +87,11 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { } func lostPassword(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && !lostLimiter.Allow(remoteIP(r)) { + displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]interface{}{"error": "Too many requests. Please try again later."}) + return + } + if r.Method != "POST" { csrfToken, err := setCSRFToken(w) if err != nil { diff --git a/ratelimit.go b/ratelimit.go new file mode 100644 index 0000000..28ad50f --- /dev/null +++ b/ratelimit.go @@ -0,0 +1,63 @@ +package main + +import ( + "net" + "net/http" + "sync" + "time" +) + +type rateLimiter struct { + mu sync.Mutex + counts map[string][]time.Time + limit int + window time.Duration +} + +func newRateLimiter(limit int, window time.Duration) *rateLimiter { + return &rateLimiter{ + counts: make(map[string][]time.Time), + limit: limit, + window: window, + } +} + +func (rl *rateLimiter) Allow(key string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + windowStart := now.Add(-rl.window) + + timestamps := rl.counts[key] + filtered := timestamps[:0] + for _, t := range timestamps { + if t.After(windowStart) { + filtered = append(filtered, t) + } + } + + if len(filtered) >= rl.limit { + rl.counts[key] = filtered + return false + } + + rl.counts[key] = append(filtered, now) + return true +} + +var ( + authLimiter = newRateLimiter(20, time.Minute) + changeLimiter = newRateLimiter(10, time.Minute) + lostLimiter = newRateLimiter(5, time.Minute) + resetLimiter = newRateLimiter(10, time.Minute) + aliasLimiter = newRateLimiter(30, time.Minute) +) + +func remoteIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/reset.go b/reset.go index 225d22d..c2172b0 100644 --- a/reset.go +++ b/reset.go @@ -6,6 +6,11 @@ import ( ) func resetPassword(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && !resetLimiter.Allow(remoteIP(r)) { + http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests) + return + } + if len(r.URL.Query().Get("l")) == 0 || len(r.URL.Query().Get("t")) == 0 { http.Redirect(w, r, "lost", http.StatusFound) return From 7b568607a6eda1049f809229173dc0518397162b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:47:08 +0700 Subject: [PATCH 22/28] fix(security): require configurable secret for X-Special-Auth docker registry bypass Replace hardcoded "docker-registry" check with a configurable secret via DOCKER_REGISTRY_SECRET env var. When the env var is unset, the anonymous docker registry bypass is disabled entirely, closing the unauthenticated access path if the service is accidentally exposed directly. Co-Authored-By: Claude Sonnet 4.6 --- login.go | 2 +- main.go | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/login.go b/login.go index 14b9fef..cd72b3c 100644 --- a/login.go +++ b/login.go @@ -108,7 +108,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) { } return } - } else if v := r.Header.Get("X-Special-Auth"); v == "docker-registry" { + } else if dockerRegistrySecret != "" && r.Header.Get("X-Special-Auth") == dockerRegistrySecret { method := r.Header.Get("X-Original-Method") uri := r.Header.Get("X-Original-URI") diff --git a/main.go b/main.go index 67e4234..f2c4c37 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,10 @@ import ( var myPublicURL = "https://ldap.nemunai.re" +// dockerRegistrySecret is required for X-Special-Auth anonymous access. +// If empty, the feature is disabled. +var dockerRegistrySecret string + var myLDAP = LDAP{ Host: "localhost", Port: 389, @@ -164,6 +168,9 @@ func main() { if val, ok := os.LookupEnv("PUBLIC_URL"); ok { myPublicURL = val } + if val, ok := os.LookupEnv("DOCKER_REGISTRY_SECRET"); ok { + dockerRegistrySecret = val + } if flag.NArg() > 0 { switch flag.Arg(0) { From 5451ec3918503d3f13dcb5978a9998d773c2dc42 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:47:30 +0700 Subject: [PATCH 23/28] fix(security): add HTTP security headers middleware Set X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP, and Strict-Transport-Security on all responses to mitigate clickjacking, MIME sniffing, XSS, and downgrade attacks. Co-Authored-By: Claude Sonnet 4.6 --- main.go | 3 ++- static.go | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index f2c4c37..f76f06c 100644 --- a/main.go +++ b/main.go @@ -216,7 +216,8 @@ func main() { http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword) srv := &http.Server{ - Addr: *bind, + Addr: *bind, + Handler: securityHeaders(http.DefaultServeMux), } // Serve content diff --git a/static.go b/static.go index 0570d91..e808fe1 100644 --- a/static.go +++ b/static.go @@ -7,6 +7,17 @@ import ( "net/http" ) +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com") + w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + next.ServeHTTP(w, r) + }) +} + //go:embed all:static var assets embed.FS From 78c4e9c3b0b0d07cc0bcf2377c27a130902206ee Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:48:00 +0700 Subject: [PATCH 24/28] fix(security): enforce domain allowlist for email alias creation Add ALIAS_ALLOWED_DOMAINS env var (comma-separated) that restricts which domains users may create aliases under. Alias creation is disabled when the env var is not set. Prevents users from creating aliases with arbitrary domains (e.g. for phishing/spoofing). Co-Authored-By: Claude Sonnet 4.6 --- addy.go | 17 +++++++++++++++++ main.go | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/addy.go b/addy.go index 3d3ab19..f45d4dc 100644 --- a/addy.go +++ b/addy.go @@ -129,6 +129,23 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) { return } + // Validate domain against allowlist + if len(allowedAliasDomains) == 0 { + http.Error(w, "Alias creation is not configured", http.StatusServiceUnavailable) + return + } + domainAllowed := false + for _, d := range allowedAliasDomains { + if body.Domain == d { + domainAllowed = true + break + } + } + if !domainAllowed { + http.Error(w, "Domain not allowed", http.StatusBadRequest) + return + } + if len(body.Alias) == 0 { body.Alias = generateRandomString(10) } diff --git a/main.go b/main.go index f76f06c..843dadf 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,10 @@ var myPublicURL = "https://ldap.nemunai.re" // If empty, the feature is disabled. var dockerRegistrySecret string +// allowedAliasDomains is the allowlist of domains users may create aliases under. +// If empty, alias creation is disabled. +var allowedAliasDomains []string + var myLDAP = LDAP{ Host: "localhost", Port: 389, @@ -171,6 +175,9 @@ func main() { if val, ok := os.LookupEnv("DOCKER_REGISTRY_SECRET"); ok { dockerRegistrySecret = val } + if val, ok := os.LookupEnv("ALIAS_ALLOWED_DOMAINS"); ok && val != "" { + allowedAliasDomains = strings.Split(val, ",") + } if flag.NArg() > 0 { switch flag.Arg(0) { From 9870fa7831deb99c69dcde57c28458af8c600d44 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:48:17 +0700 Subject: [PATCH 25/28] fix(security): use crypto/rand for alias prefix generation Replace math/rand.Intn with crypto/rand for generating random alias prefixes. While aliases are not security tokens, using a CSPRNG ensures consistent use of cryptographically secure randomness throughout. Co-Authored-By: Claude Sonnet 4.6 --- addy.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/addy.go b/addy.go index f45d4dc..c2878e1 100644 --- a/addy.go +++ b/addy.go @@ -3,13 +3,13 @@ package main import ( "bytes" "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/json" "flag" "fmt" "log" - "math/rand" "net/http" "os" "strings" @@ -230,10 +230,14 @@ func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) { } func generateRandomString(length int) string { - charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" result := make([]byte, length) - for i := range result { - result[i] = charset[rand.Intn(len(charset))] + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + panic("crypto/rand unavailable: " + err.Error()) + } + for i, b := range buf { + result[i] = charset[int(b)%len(charset)] } return string(result) } From 7b0f3bc61d8657579a48737278121d998a0266f3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 14:48:35 +0700 Subject: [PATCH 26/28] fix(security): strengthen password policy Increase minimum password length from 8 to 12 characters and require at least one uppercase letter, one lowercase letter, and one digit. Co-Authored-By: Claude Sonnet 4.6 --- change.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/change.go b/change.go index 0fd8d07..8d1b32f 100644 --- a/change.go +++ b/change.go @@ -4,11 +4,27 @@ import ( "errors" "log" "net/http" + "unicode" ) func checkPasswdConstraint(password string) error { - if len(password) < 8 { - return errors.New("too short, please choose a password at least 8 characters long.") + if len(password) < 12 { + return errors.New("too short, please choose a password at least 12 characters long") + } + + var hasUpper, hasLower, hasDigit bool + for _, r := range password { + switch { + case unicode.IsUpper(r): + hasUpper = true + case unicode.IsLower(r): + hasLower = true + case unicode.IsDigit(r): + hasDigit = true + } + } + if !hasUpper || !hasLower || !hasDigit { + return errors.New("password must contain at least one uppercase letter, one lowercase letter, and one digit") } return nil From 1e1888625da955a90c744b0c4208c2c43b86b945 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 15:24:59 +0700 Subject: [PATCH 27/28] feat(security): add altcha proof-of-work CAPTCHA to all sensitive forms Integrate go-altcha to protect login, change password, lost password, and reset password forms against automated submissions. Serves the altcha widget JS from the embedded library, exposes a challenge endpoint, validates responses server-side with replay prevention, and updates the CSP to allow self-hosted scripts and WebAssembly. Co-Authored-By: Claude Sonnet 4.6 --- altcha.go | 27 +++++++++++++++++++++++++++ change.go | 6 ++++++ go.mod | 2 ++ go.sum | 4 ++++ login.go | 5 +++++ lost.go | 5 +++++ main.go | 2 ++ reset.go | 5 +++++ static.go | 2 +- static/change.html | 3 +++ static/header.html | 1 + static/login.html | 3 +++ static/lost.html | 3 +++ static/reset.html | 3 +++ 14 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 altcha.go diff --git a/altcha.go b/altcha.go new file mode 100644 index 0000000..ea5e50b --- /dev/null +++ b/altcha.go @@ -0,0 +1,27 @@ +package main + +import ( + "net/http" + + goaltcha "github.com/k42-software/go-altcha" + altchahttp "github.com/k42-software/go-altcha/http" +) + +func serveAltchaJS(w http.ResponseWriter, r *http.Request) { + altchahttp.ServeJavascript(w, r) +} + +func serveAltchaChallenge(w http.ResponseWriter, r *http.Request) { + challenge := goaltcha.NewChallenge() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") + _, _ = w.Write([]byte(challenge.Encode())) +} + +func validateAltcha(r *http.Request) bool { + encoded := r.PostFormValue("altcha") + if encoded == "" { + return false + } + return goaltcha.ValidateResponse(encoded, true) +} diff --git a/change.go b/change.go index 8d1b32f..0a9e7e6 100644 --- a/change.go +++ b/change.go @@ -53,6 +53,12 @@ func changePassword(w http.ResponseWriter, r *http.Request) { return } + if !validateAltcha(r) { + csrfToken, _ := setCSRFToken(w) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken}) + return + } + renderError := func(status int, msg string) { csrfToken, _ := setCSRFToken(w) displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken}) diff --git a/go.mod b/go.mod index 17e467e..eb358eb 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/k42-software/go-altcha v0.1.1 + github.com/pkg/errors v0.9.1 // indirect golang.org/x/crypto v0.36.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect ) diff --git a/go.sum b/go.sum index ea772ab..318fb83 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/k42-software/go-altcha v0.1.1 h1:vfA+0+0gr7jK4vp21Q7xvEpIjDsx8PqzxS0obgIToQs= +github.com/k42-software/go-altcha v0.1.1/go.mod h1:2aX+0PkUSI0YPDVfjapZeuGELWt8ugEXkg8gr6QejMU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/login.go b/login.go index cd72b3c..44ffe66 100644 --- a/login.go +++ b/login.go @@ -53,6 +53,11 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { return } + if !validateAltcha(r) { + displayTmplError(w, http.StatusForbidden, "login.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) + return + } + if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil { log.Println(err) displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()}) diff --git a/lost.go b/lost.go index 250ac42..1e4c9d3 100644 --- a/lost.go +++ b/lost.go @@ -107,6 +107,11 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { return } + if !validateAltcha(r) { + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) + return + } + // Connect to the LDAP server conn, err := myLDAP.Connect() if err != nil || conn == nil { diff --git a/main.go b/main.go index 843dadf..ec6e888 100644 --- a/main.go +++ b/main.go @@ -213,6 +213,8 @@ func main() { signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) // Register handlers + http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", *baseURL), serveAltchaJS) + http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", *baseURL), serveAltchaChallenge) http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword) http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI) http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", *baseURL), addyAliasAPIDelete) diff --git a/reset.go b/reset.go index c2172b0..a37f99d 100644 --- a/reset.go +++ b/reset.go @@ -44,6 +44,11 @@ func resetPassword(w http.ResponseWriter, r *http.Request) { return } + if !validateAltcha(r) { + renderError(http.StatusForbidden, "Invalid or missing altcha response. Please try again.") + return + } + // Check the two new passwords are identical if r.PostFormValue("newpassword") != r.PostFormValue("new2password") { renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.") diff --git a/static.go b/static.go index e808fe1..6fd5ff6 100644 --- a/static.go +++ b/static.go @@ -12,7 +12,7 @@ func securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com") w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") next.ServeHTTP(w, r) }) diff --git a/static/change.html b/static/change.html index 60ffe7e..424b171 100644 --- a/static/change.html +++ b/static/change.html @@ -40,6 +40,9 @@ +
+ +
Forgot your password? diff --git a/static/header.html b/static/header.html index ba37563..f86b55b 100644 --- a/static/header.html +++ b/static/header.html @@ -10,6 +10,7 @@ nemunai.re password change +
diff --git a/static/login.html b/static/login.html index b8c9366..ec0678a 100644 --- a/static/login.html +++ b/static/login.html @@ -9,6 +9,9 @@
+
+ +
Forgot your password? diff --git a/static/lost.html b/static/lost.html index e924c3c..bba8878 100644 --- a/static/lost.html +++ b/static/lost.html @@ -7,6 +7,9 @@
+
+ +
Just want to change your password? diff --git a/static/reset.html b/static/reset.html index 641f179..382ab00 100644 --- a/static/reset.html +++ b/static/reset.html @@ -15,6 +15,9 @@
+
+ +
{{template "footer"}} From c98fe735ad48004f4e8d5c49dd75d37172d867bd Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 6 Mar 2026 15:27:39 +0700 Subject: [PATCH 28/28] feat: add -dev flag for local HTTP testing In development mode (-dev): - HSTS header is omitted (prevents browser caching HTTPS-only requirement) - CSRF cookie Secure flag is cleared (allows cookies over plain HTTP) - A warning is logged on startup Co-Authored-By: Claude Sonnet 4.6 --- csrf.go | 1 + main.go | 6 ++++++ static.go | 4 +++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/csrf.go b/csrf.go index a94be83..42f46d6 100644 --- a/csrf.go +++ b/csrf.go @@ -25,6 +25,7 @@ func setCSRFToken(w http.ResponseWriter) (string, error) { Path: "/", HttpOnly: false, // must be readable via form hidden field comparison SameSite: http.SameSiteStrictMode, + Secure: !devMode, }) return token, nil } diff --git a/main.go b/main.go index ec6e888..36ab61d 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( ) var myPublicURL = "https://ldap.nemunai.re" +var devMode bool // dockerRegistrySecret is required for X-Special-Auth anonymous access. // If empty, the feature is disabled. @@ -80,9 +81,14 @@ func main() { var baseURL = flag.String("baseurl", "/", "URL prepended to each URL") var configfile = flag.String("config", "", "path to the configuration file") var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails") + var dev = flag.Bool("dev", false, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing") flag.Parse() myPublicURL = *publicURL + devMode = *dev + if devMode { + log.Println("WARNING: running in development mode — security features relaxed, do not use in production") + } // Sanitize options log.Println("Checking paths...") diff --git a/static.go b/static.go index 6fd5ff6..4946083 100644 --- a/static.go +++ b/static.go @@ -13,7 +13,9 @@ func securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com") - w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + if !devMode { + w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + } next.ServeHTTP(w, r) }) }