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>
239 lines
5.1 KiB
Go
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)
|
|
}
|