chldapasswd/addy.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

239 lines
5.1 KiB
Go

package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base32"
"encoding/json"
"flag"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"strings"
)
var apiSecret string
type addyForm struct {
Alias string `json:"alias"`
Description string `json:"description"`
Domain string `json:"domain"`
}
type addyResponse struct {
Data addyResponseData `json:"data"`
}
type addyResponseData struct {
Email string `json:"email"`
}
func init() {
if val, ok := os.LookupEnv("ADDY_API_SECRET"); ok {
apiSecret = val
}
flag.StringVar(&apiSecret, "addy-api-secret", apiSecret, "Secret used for the HMAC protecting addy.io-like API")
}
func AddyAPISignature(username string) []byte {
mac := hmac.New(sha256.New224, []byte(apiSecret))
mac.Write([]byte(username))
return mac.Sum(nil)
}
func AddyAPIToken(username string) string {
chain := []byte(username + ":")
chain = append(chain, AddyAPISignature(username)...)
return base32.StdEncoding.EncodeToString(chain)
}
func checkAddyApiAuthorization(authorization []byte) *string {
fields := bytes.SplitN(authorization, []byte(":"), 2)
if len(fields) != 2 {
return nil
}
username := string(fields[0])
expectedSign := AddyAPISignature(username)
if !hmac.Equal(expectedSign, fields[1]) {
return nil
}
return &username
}
func addyAliasAPIAuth(r *http.Request) (*string, error) {
// Check authorization header
fields := strings.Fields(r.Header.Get("Authorization"))
if len(fields) != 2 || fields[0] != "Bearer" {
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.Printf("Invalid Authorization header: %s", err.Error())
return nil, err
}
user := checkAddyApiAuthorization(authorization)
if user == nil {
return nil, fmt.Errorf("Not authorized")
}
return user, nil
}
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)
return
}
// Decode body
var body addyForm
err = json.NewDecoder(r.Body).Decode(&body)
if err != nil {
http.Error(w, "Invalid body", http.StatusBadRequest)
return
}
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
}
// 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)
}
email := body.Alias + "@" + body.Domain
nb, err := conn.SearchMailAlias(email)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if nb != 0 {
http.Error(w, fmt.Sprintf("The alias %q is already used.", email), http.StatusBadRequest)
return
}
err = conn.AddMailAlias(dn, email)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("New alias created for %s: %s", dn, email)
res := addyResponse{
Data: addyResponseData{
Email: email,
},
}
err = json.NewEncoder(w).Encode(res)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
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)
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)
for i := range result {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
}