mirror of
https://github.com/therootcompany/golib.git
synced 2026-01-27 23:18:05 +00:00
wip: feat: add net/formmailer for web forms with bot protection
This commit is contained in:
parent
1947b91c1d
commit
e2a50ae20e
381
net/formmailer/formmailer.go
Normal file
381
net/formmailer/formmailer.go
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
package formmailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"net/netip"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/phuslu/iploc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxFormSize = 10 * 1024 // 10KB total form limit
|
||||||
|
maxMessageLength = 4000
|
||||||
|
maxCompanyLength = 200
|
||||||
|
maxNameLength = 100
|
||||||
|
maxEmailLength = 254
|
||||||
|
maxPhoneLength = 20
|
||||||
|
|
||||||
|
requestsPerMinute = 5
|
||||||
|
burstSize = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidEmail = fmt.Errorf("email address doesn't look like an email address")
|
||||||
|
var ErrInvalidMX = fmt.Errorf("email address isn't deliverable")
|
||||||
|
var ErrInvalidPhone = fmt.Errorf("phone number is not properly formatted")
|
||||||
|
var ErrContentTooLong = fmt.Errorf("one or more of the field values was too long")
|
||||||
|
var ErrInvalidNewlines = fmt.Errorf("invalid use of newlines or returns")
|
||||||
|
|
||||||
|
var (
|
||||||
|
phoneRe = regexp.MustCompile(`^[0-9+\-\(\) ]{7,20}$`)
|
||||||
|
|
||||||
|
// Global per-IP limiter map
|
||||||
|
limiterMu sync.Mutex
|
||||||
|
limiters = make(map[string]*rate.Limiter)
|
||||||
|
)
|
||||||
|
|
||||||
|
type FormMailer struct {
|
||||||
|
showVersion bool
|
||||||
|
listenAddr string
|
||||||
|
smtpHost string
|
||||||
|
smtpFrom string
|
||||||
|
smtpToList string
|
||||||
|
smtpUser string
|
||||||
|
smtpPass string
|
||||||
|
smtpSubject string
|
||||||
|
successFile string
|
||||||
|
errorFile string
|
||||||
|
responseType string
|
||||||
|
Blacklist *ipcohort.Cohort
|
||||||
|
}
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
gitURL := "git@github.com:bitwire-it/ipblocklist.git"
|
||||||
|
blacklistPath := "/home/app/srv/ipblocklist/inbound.txt"
|
||||||
|
|
||||||
|
cfg := &FormMailer{
|
||||||
|
listenAddr: "localhost:3081",
|
||||||
|
smtpHost: os.Getenv("SMTP_HOST"),
|
||||||
|
smtpFrom: os.Getenv("SMTP_FROM"),
|
||||||
|
smtpToList: os.Getenv("SMTP_TO"),
|
||||||
|
smtpUser: os.Getenv("SMTP_USER"),
|
||||||
|
smtpPass: "",
|
||||||
|
smtpSubject: "Website contact request from {.Email}",
|
||||||
|
successFile: "success-file.html",
|
||||||
|
errorFile: "error-file.html",
|
||||||
|
responseType: "text/plain",
|
||||||
|
Blacklist: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.smtpHost == "" || cfg.smtpFrom == "" || cfg.smtpToList == "" {
|
||||||
|
return fmt.Errorf("missing required SMTP settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.ReadFile(cfg.successFile); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nError: couldn't read success response file %q: %v\n\n", cfg.successFile, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if _, err := os.ReadFile(cfg.errorFile); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nError: couldn't read error response file %q: %v\n\n", cfg.errorFile, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.smtpUser == "" {
|
||||||
|
cfg.smtpUser = cfg.smtpFrom
|
||||||
|
}
|
||||||
|
if cfg.smtpFrom == "" {
|
||||||
|
cfg.smtpFrom = cfg.smtpUser
|
||||||
|
}
|
||||||
|
|
||||||
|
if pass, hasPass := os.LookupEnv("SMTP_PASS"); !hasPass {
|
||||||
|
fmt.Fprintf(os.Stderr, "SMTP_PASS not set → ")
|
||||||
|
pwBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to read password: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
cfg.smtpPass = strings.TrimSpace(string(pwBytes))
|
||||||
|
} else {
|
||||||
|
cfg.smtpPass = pass
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(strings.ToLower(cfg.successFile), ".html") {
|
||||||
|
cfg.responseType = "text/html"
|
||||||
|
} else if strings.HasSuffix(strings.ToLower(cfg.successFile), ".json") {
|
||||||
|
cfg.responseType = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Blacklist = NewBlacklist(gitURL, blacklistPath)
|
||||||
|
fmt.Fprintf(os.Stderr, "Syncing git repo ...\n")
|
||||||
|
skipGCOnce := true
|
||||||
|
if n, err := cfg.Blacklist.Init(skipGCOnce); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: ip cohort: %v\n", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "ip cohort: loaded %d blacklist entries\n", n)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
cfg.Blacklist.Run(context.TODO())
|
||||||
|
}()
|
||||||
|
|
||||||
|
http.HandleFunc("POST /contact", cfg.submitHandler)
|
||||||
|
http.HandleFunc("POST /contact/", cfg.submitHandler)
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = fmt.Fprintln(w, "form2email server running. POST form data to /contact")
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("form2email listening on http://%s\n", cfg.listenAddr)
|
||||||
|
fmt.Printf("Forwarding submissions from %s → %s via %s\n", cfg.smtpFrom, cfg.smtpToList, cfg.smtpHost)
|
||||||
|
fmt.Printf("Rate limit: ~%d req/min per IP (burst %d)\n", requestsPerMinute, burstSize)
|
||||||
|
fmt.Println("CTRL+C to stop")
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe(cfg.listenAddr, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *FormMailer) submitHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", cfg.responseType)
|
||||||
|
|
||||||
|
// Parse form early (needed for rate limit decision, but still protected by size limit)
|
||||||
|
err := r.ParseMultipartForm(maxFormSize)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Form too large or invalid", http.StatusBadRequest)
|
||||||
|
log.Printf("ParseMultipartForm error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit FIRST (cheap check)
|
||||||
|
ipStr := getClientIP(r)
|
||||||
|
ip, err := netip.ParseAddr(ipStr)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
b := cfg.getErrorBody(fmt.Errorf("malformed proxy headers"))
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Blacklist.Contains(ipStr) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
b := cfg.getErrorBotty(fmt.Errorf("bots are not allowed to submit contact requests"))
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch iploc.IPCountry(ip) {
|
||||||
|
case "", "US", "CA", "MX", "CR", "VI":
|
||||||
|
// North America, or unknown
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
b := cfg.getErrorBody(fmt.Errorf("it appears that you are contacting us from outside of the United States, please email us directly for international inquiries"))
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validateRateLimit(ipStr) {
|
||||||
|
http.Error(w, "Rate limit exceeded (try again later)", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stuff := make(map[string]string)
|
||||||
|
// Extract & trim fields
|
||||||
|
email := strings.ToLower(strings.TrimSpace(r.FormValue("input_3")))
|
||||||
|
stuff["name"] = strings.TrimSpace(r.FormValue("input_1"))
|
||||||
|
stuff["phone"] = strings.TrimSpace(r.FormValue("input_4"))
|
||||||
|
stuff["company"] = strings.TrimSpace(r.FormValue("input_5"))
|
||||||
|
stuff["message"] = strings.TrimSpace(r.FormValue("input_7"))
|
||||||
|
|
||||||
|
// Validation chain
|
||||||
|
if err := validateLengths(stuff["name"], email, stuff["phone"], stuff["company"], stuff["message"]); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
b := cfg.getErrorBody(err)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validatePhone(stuff["phone"]); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
b := cfg.getErrorBody(err)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateNoHeaderInjection(stuff["name"], email, stuff["company"]); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
b := cfg.getErrorBody(err)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateEmailAndMX(email); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
b := cfg.getErrorBody(err)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log submission
|
||||||
|
n := min(len(stuff["message"]), 100)
|
||||||
|
log.Printf("Submission from %s | Name=%q Email=%q Phone=%q Company=%q Message=%q",
|
||||||
|
ipStr, stuff["name"], email, stuff["phone"], stuff["company"], stuff["message"][:n]+"...")
|
||||||
|
|
||||||
|
// TODO blacklist
|
||||||
|
|
||||||
|
if strings.HasSuffix(email, ".ru") {
|
||||||
|
b, _ := os.ReadFile(cfg.successFile)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build email
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
"New contact form submission:\n\n"+
|
||||||
|
"Name: %s\n"+
|
||||||
|
"Email: %s\n"+
|
||||||
|
"Phone: %s\n"+
|
||||||
|
"Company: %s\n"+
|
||||||
|
"Message:\n%s\n",
|
||||||
|
stuff["name"], email, stuff["phone"], stuff["company"], stuff["message"],
|
||||||
|
)
|
||||||
|
|
||||||
|
msg := fmt.Appendf(nil,
|
||||||
|
"To: %s\r\n"+
|
||||||
|
"From: %s\r\n"+
|
||||||
|
"Reply-To: %s\r\n"+
|
||||||
|
"Subject: %s\r\n"+
|
||||||
|
"\r\n"+
|
||||||
|
"%s\r\n",
|
||||||
|
cfg.smtpToList, cfg.smtpFrom, email, strings.ReplaceAll(cfg.smtpSubject, "{.Email}", email), body,
|
||||||
|
)
|
||||||
|
|
||||||
|
hostname := strings.Split(cfg.smtpHost, ":")[0]
|
||||||
|
auth := smtp.PlainAuth("", cfg.smtpUser, cfg.smtpPass, hostname)
|
||||||
|
|
||||||
|
smtpTo := strings.Split(cfg.smtpToList, ",")
|
||||||
|
err = smtp.SendMail(cfg.smtpHost, auth, cfg.smtpFrom, smtpTo, msg)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to send email", http.StatusInternalServerError)
|
||||||
|
log.Printf("SMTP error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, _ := os.ReadFile(cfg.successFile)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *FormMailer) getErrorBody(err error) []byte {
|
||||||
|
b, _ := os.ReadFile(cfg.errorFile)
|
||||||
|
b = bytes.ReplaceAll(b, []byte("{.Error}"), []byte(err.Error()))
|
||||||
|
b = bytes.ReplaceAll(b, []byte("{.SupportEmail}"), []byte(cfg.smtpFrom))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *FormMailer) getErrorBotty(err error) []byte {
|
||||||
|
b, _ := os.ReadFile(cfg.errorFile)
|
||||||
|
b = bytes.ReplaceAll(b, []byte("{.Error}"), []byte(err.Error()))
|
||||||
|
b = bytes.ReplaceAll(b, []byte("{.SupportEmail}"), []byte("[REDACTED]"))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Validation functions
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func validateRateLimit(ipStr string) bool {
|
||||||
|
limiterMu.Lock()
|
||||||
|
lim, ok := limiters[ipStr]
|
||||||
|
if !ok {
|
||||||
|
lim = rate.NewLimiter(rate.Every(time.Minute/time.Duration(requestsPerMinute)), burstSize)
|
||||||
|
limiters[ipStr] = lim
|
||||||
|
}
|
||||||
|
limiterMu.Unlock()
|
||||||
|
|
||||||
|
if !lim.Allow() {
|
||||||
|
log.Printf("Rate limited IP: %s", ipStr)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLengths(name, email, phone, company, message string) error {
|
||||||
|
if len(name) > maxNameLength ||
|
||||||
|
len(email) > maxEmailLength ||
|
||||||
|
len(phone) > maxPhoneLength ||
|
||||||
|
len(company) > maxCompanyLength ||
|
||||||
|
len(message) > maxMessageLength {
|
||||||
|
return ErrContentTooLong
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateEmailAndMX(email string) error {
|
||||||
|
_, err := mail.ParseAddress(email)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(email, "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return ErrInvalidEmail
|
||||||
|
}
|
||||||
|
domain := parts[1]
|
||||||
|
|
||||||
|
_, err = net.LookupMX(domain)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidMX
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePhone(phone string) error {
|
||||||
|
if phone == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !phoneRe.MatchString(phone) {
|
||||||
|
return ErrInvalidPhone
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNoHeaderInjection(name, email, company string) error {
|
||||||
|
combined := name + email + company
|
||||||
|
if strings.ContainsAny(combined, "\r\n") {
|
||||||
|
return ErrInvalidNewlines
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientIP prefers X-Forwarded-For (first value) over RemoteAddr
|
||||||
|
func getClientIP(r *http.Request) string {
|
||||||
|
xff := r.Header.Get("X-Forwarded-For")
|
||||||
|
if xff != "" {
|
||||||
|
// Take the first (original client) IP in case of multiple proxies
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
fmt.Println("Remote IP XFF:", xff)
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to RemoteAddr (strip port)
|
||||||
|
ip := r.RemoteAddr
|
||||||
|
if idx := strings.LastIndex(ip, ":"); idx > -1 {
|
||||||
|
ip = ip[:idx]
|
||||||
|
fmt.Println("Remote IP:", ip)
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user