chldapasswd/main.go
Pierre-Olivier Mercier 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

242 lines
6.2 KiB
Go

package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"path"
"strconv"
"strings"
"syscall"
)
var myPublicURL = "https://ldap.nemunai.re"
// 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 {
real http.ResponseWriter
prefix string
}
func (r ResponseWriterPrefix) Header() http.Header {
return r.real.Header()
}
func (r ResponseWriterPrefix) WriteHeader(s int) {
if v, exists := r.real.Header()["Location"]; exists {
r.real.Header().Set("Location", r.prefix+v[0])
}
r.real.WriteHeader(s)
}
func (r ResponseWriterPrefix) Write(z []byte) (int, error) {
return r.real.Write(z)
}
func StripPrefix(prefix string, h http.Handler) http.Handler {
if prefix == "" {
return h
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if prefix != "/" && r.URL.Path == "/" {
http.Redirect(w, r, prefix+"/", http.StatusFound)
} else if p := strings.TrimPrefix(r.URL.Path, prefix); len(p) < len(r.URL.Path) {
r2 := new(http.Request)
*r2 = *r
r2.URL = new(url.URL)
*r2.URL = *r.URL
r2.URL.Path = p
h.ServeHTTP(ResponseWriterPrefix{w, prefix}, r2)
} else {
h.ServeHTTP(w, r)
}
})
}
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 != "/" {
tmp := path.Clean(*baseURL)
baseURL = &tmp
} else {
tmp := ""
baseURL = &tmp
}
// Load config file
if configfile != nil && *configfile != "" {
if fd, err := os.Open(*configfile); err != nil {
log.Fatal(err)
} else if cnt, err := ioutil.ReadAll(fd); err != nil {
log.Fatal(err)
} else if err := json.Unmarshal(cnt, &myLDAP); err != nil {
log.Fatal(err)
}
}
// Read configuration from environment
if val, ok := os.LookupEnv("LDAP_HOST"); ok {
myLDAP.Host = val
}
if val, ok := os.LookupEnv("LDAP_PORT"); ok {
if port, err := strconv.Atoi(val); err == nil {
myLDAP.Port = port
} else {
log.Println("Invalid value for LDAP_PORT:", val)
}
}
if val, ok := os.LookupEnv("LDAP_STARTTLS"); ok {
myLDAP.Starttls = val == "1" || val == "on" || val == "true"
}
if val, ok := os.LookupEnv("LDAP_SSL"); ok {
myLDAP.Ssl = val == "1" || val == "on" || val == "true"
}
if val, ok := os.LookupEnv("LDAP_BASEDN"); ok {
myLDAP.BaseDN = val
}
if val, ok := os.LookupEnv("LDAP_SERVICEDN"); ok {
myLDAP.ServiceDN = val
}
if val, ok := os.LookupEnv("LDAP_SERVICE_PASSWORD_FILE"); ok {
if fd, err := os.Open(val); err != nil {
log.Fatal(err)
} else if cnt, err := ioutil.ReadAll(fd); err != nil {
log.Fatal(err)
} else {
myLDAP.ServicePassword = string(cnt)
}
} else if val, ok := os.LookupEnv("LDAP_SERVICE_PASSWORD"); ok {
myLDAP.ServicePassword = val
}
if val, ok := os.LookupEnv("SMTP_HOST"); ok {
myLDAP.MailHost = val
}
if val, ok := os.LookupEnv("SMTP_PORT"); ok {
if port, err := strconv.Atoi(val); err == nil {
myLDAP.MailPort = port
} else {
log.Println("Invalid value for SMTP_PORT:", val)
}
}
if val, ok := os.LookupEnv("SMTP_USER"); ok {
myLDAP.MailUser = val
}
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) {
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, myPublicURL, 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)
// Register handlers
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)
http.HandleFunc(fmt.Sprintf("%s/reset", *baseURL), resetPassword)
http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword)
srv := &http.Server{
Addr: *bind,
Handler: securityHeaders(http.DefaultServeMux),
}
// Serve content
go func() {
log.Fatal(srv.ListenAndServe())
}()
log.Println(fmt.Sprintf("Ready, listening on %s", *bind))
// Wait shutdown signal
<-interrupt
log.Print("The service is shutting down...")
srv.Shutdown(context.Background())
log.Println("done")
}