From 65d0d4a53e1068e2cf2fc811f9a42be15e4bdd13 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 31 May 2024 17:08:15 +0200 Subject: [PATCH 01/20] Can delete own aliases --- addy.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++++------- login.go | 8 ++++++-- main.go | 3 ++- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/addy.go b/addy.go index fcb76d4..d1573d7 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 + log.Printf("Invalid Authorization header: %s", err.Error()) + 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 ee1f8ce69f9ec9b96834a3390be81437374ab108 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 31 May 2024 17:19:12 +0200 Subject: [PATCH 02/20] 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: " + 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 13/20] 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 14/20] 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 15/20] 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 16/20] 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 17/20] 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 18/20] 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 19/20] 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 20/20] 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) }) }