Compare commits

..

20 commits

Author SHA1 Message Date
c98fe735ad feat: add -dev flag for local HTTP testing
All checks were successful
continuous-integration/drone/push Build is passing
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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
1e1888625d 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
7b0f3bc61d 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
9870fa7831 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
78c4e9c3b0 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
5451ec3918 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
7b568607a6 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
2a9eec233a 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
93673510d8 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
57775bbf89 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
a2f368eb02 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
10f41e4ef8 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 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
121770c18a chore(deps): update dependency go to v1.26.0 2026-03-06 15:30:48 +07:00
4b7405fc61 chore(deps): update dependency go to v1.25.5 2026-03-06 15:30:48 +07:00
000f04a8f6 chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.12 2026-03-06 15:30:48 +07:00
399e8b6367 chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.11 2026-03-06 15:30:48 +07:00
6836e70e83 Can launch the executable with arguments to get reset token 2026-03-06 15:30:48 +07:00
0197446952 chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.10 2026-03-06 15:30:48 +07:00
ee1f8ce69f Hide krbPrincipalKey 2026-03-06 15:30:48 +07:00
65d0d4a53e Can delete own aliases 2026-03-06 15:30:48 +07:00
18 changed files with 460 additions and 98 deletions

41
addy.go
View file

@ -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"
@ -76,7 +76,7 @@ func addyAliasAPIAuth(r *http.Request) (*string, error) {
// Decode header
authorization, err := base32.StdEncoding.DecodeString(fields[1])
if err != nil {
log.Println("Invalid Authorization header: %s", err.Error())
log.Printf("Invalid Authorization header: %s", err.Error())
return nil, err
}
@ -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)
@ -124,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)
}
@ -162,6 +184,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)
@ -203,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)
}

27
altcha.go Normal file
View file

@ -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)
}

View file

@ -4,46 +4,90 @@ 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
}
func changePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
displayTmpl(w, "change.html", map[string]interface{}{})
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 {
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
}
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})
}
// 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)
}

40
csrf.go Normal file
View file

@ -0,0 +1,40 @@
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,
Secure: !devMode,
})
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
}

2
go.mod
View file

@ -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
)

4
go.sum
View file

@ -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=

View file

@ -23,6 +23,7 @@ type LDAP struct {
MailPort int
MailUser string
MailPassword string
MailFrom string
}
func (l LDAP) Connect() (*LDAPConn, error) {
@ -74,7 +75,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 +148,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,
)

View file

@ -2,9 +2,11 @@ package main
import (
"fmt"
"html"
"html/template"
"log"
"net/http"
"net/url"
"strings"
"github.com/go-ldap/ldap/v3"
@ -46,6 +48,16 @@ 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 !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()})
@ -55,20 +67,34 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
cnt := "<ul>"
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 += "<li><strong>" + e.Name + ":</strong> <em>[...]</em></li>"
cnt += "<li><strong>" + safeName + ":</strong> <em>[...]</em></li>"
} else if e.Name == "mailAlias" && len(strings.SplitN(v, "@", 2)[0]) == 10 {
cnt += "<li id='" + fmt.Sprintf("mailAlias-%d", i) + "'><strong>" + e.Name + ":</strong> " + v + `<button type="button" class="mx-1 btn btn-sm btn-danger" onclick="fetch('/api/v1/aliases/` + v + `', {'method': 'delete', 'headers': {'Authorization': 'Bearer ` + apiToken + `'}}).then((res) => { if (res.ok) document.getElementById('` + fmt.Sprintf("mailAlias-%d", i) + `').remove(); });">Supprimer</a></li>`
safeURL := url.PathEscape(v)
safeToken := html.EscapeString(apiToken)
safeElemID := html.EscapeString(elemID)
cnt += `<li id="` + safeElemID + `"><strong>` + safeName + `:</strong> ` + safeVal +
`<button type="button" class="mx-1 btn btn-sm btn-danger" data-alias="` + safeURL + `" data-token="` + safeToken + `" data-elem="` + safeElemID + `" onclick="(function(b){fetch('/api/v1/aliases/'+b.dataset.alias,{'method':'delete','headers':{'Authorization':'Bearer '+b.dataset.token}}).then(function(r){if(r.ok)document.getElementById(b.dataset.elem).remove();})})(this)">Supprimer</button></li>`
} else {
cnt += "<li><strong>" + e.Name + ":</strong> " + v + "</li>"
cnt += "<li><strong>" + safeName + ":</strong> " + safeVal + "</li>"
}
}
}
displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok<br><br>Here are the information we have about you:` + cnt + "</ul><p>To use our Addy.io compatible API, use the following token: <code>" + apiToken + "</code></p>")})
displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok<br><br>Here are the information we have about you:` + cnt + "</ul><p>To use our Addy.io compatible API, use the following token: <code>" + html.EscapeString(apiToken) + "</code></p>")})
}
}
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"`)
@ -87,7 +113,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")

121
lost.go
View file

@ -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,41 @@ 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" && !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" {
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
}
if !validateAltcha(r) {
displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."})
return
}
@ -80,7 +116,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 +124,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 +133,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 +150,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 +167,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 +202,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
}

51
main.go
View file

@ -17,13 +17,23 @@ import (
"syscall"
)
const BASEURL = "https://ldap.nemunai.re"
var myPublicURL = "https://ldap.nemunai.re"
var devMode bool
// dockerRegistrySecret is required for X-Special-Auth anonymous access.
// 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,
BaseDN: "dc=example,dc=com",
MailPort: 587,
MailFrom: "noreply@nemunai.re",
}
type ResponseWriterPrefix struct {
@ -70,8 +80,16 @@ 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")
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...")
if *baseURL != "/" {
@ -141,9 +159,31 @@ 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 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) {
@ -164,7 +204,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":
@ -179,6 +219,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)
@ -189,7 +231,8 @@ func main() {
http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword)
srv := &http.Server{
Addr: *bind,
Addr: *bind,
Handler: securityHeaders(http.DefaultServeMux),
}
// Serve content

63
ratelimit.go Normal file
View file

@ -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
}

View file

@ -3,32 +3,66 @@ package main
import (
"log"
"net/http"
"strings"
)
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
}
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
}
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") {
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
}
@ -36,41 +70,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
}

View file

@ -7,6 +7,19 @@ 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 '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")
if !devMode {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
//go:embed all:static
var assets embed.FS

View file

@ -3,6 +3,7 @@
<form method="post" action="change">
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
<input type="hidden" name="csrf_token" value="{{ .csrf_token }}">
<div class="form-group">
<input name="login" required="" class="form-control" id="input_0" type="text" placeholder="Login" autofocus>
</div>
@ -39,6 +40,9 @@
</button>
</div>
</div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<button class="btn btn-primary" type="submit">Change my password</button>
<a href="/lost" class="btn btn-outline-secondary">Forgot your password?</a>
</form>

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<title>nemunai.re password change</title>
<script src="altcha.min.js" async defer></script>
</head>
<body>
<div class="container">

View file

@ -9,6 +9,9 @@
<div class="form-group">
<input name="password" required="" class="form-control" id="input_1" type="password" placeholder="Current password">
</div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<button class="btn btn-primary" type="submit">Sign in</button>
<a href="/lost" class="btn btn-outline-secondary">Forgot your password?</a>
</form>

View file

@ -3,9 +3,13 @@
<form method="post" action="lost">
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
<input type="hidden" name="csrf_token" value="{{ .csrf_token }}">
<div class="form-group">
<input name="login" required="" class="form-control" id="input_0" type="text" placeholder="Login" autofocus>
</div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<button class="btn btn-primary" type="submit">Reset my password</button>
<a href="/change" class="btn btn-outline-success">Just want to change your password?</a>
</form>

View file

@ -1,8 +1,9 @@
{{template "header"}}
<h1 class="display-4">Forgot your password? <small class="text-muted">Define a new one!</small></h1>
<form method="post" action="reset?l={{ .login }}&t={{ .token }}">
<form method="post" action="reset">
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
<input type="hidden" name="csrf_token" value="{{ .csrf_token }}">
<div class="form-group">
<input required="" class="form-control" id="input_0" type="text" placeholder="Email" value="{{ .login }}" disabled="">
</div>
@ -14,6 +15,9 @@
<div class="form-group">
<input name="new2password" required="" class="form-control" id="input_3" type="password" placeholder="Retype new password">
</div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<button class="btn btn-primary" type="submit">Reset my password</button>
</form>
{{template "footer"}}