package endpoint import ( "encoding/json" "errors" "fmt" "html/template" "io" "log" "mime" "net" "net/http" "net/url" "os" "strings" "time" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/about" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forms" ) // Server answers /_hotdog/... for every configured site. type Server struct { cfg *Config sites map[string]*siteRuntime tokens tokens limit *limiter // searchLimit: searches are cheap but not free; 120 a minute per // connection is far beyond anyone typing. searchLimit *limiter mail *mailer store store trusted []*net.IPNet turnstile string // HOTDOG_TURNSTILE_SECRET search *searcher now func() time.Time log *log.Logger } // New builds a server from endpoint.yaml. The form secret must be at least 32 // characters: every endpoint behind one load balancer needs the same one. func New(configFile string, logw io.Writer) (*Server, error) { cfg, sites, err := LoadConfig(configFile) if err != nil { return nil, err } secret := os.Getenv("HOTDOG_FORM_SECRET") if len(secret) < 32 { return nil, errors.New("HOTDOG_FORM_SECRET must be set, at least 32 characters (openssl rand -hex 32)") } s := &Server{ cfg: cfg, sites: sites, tokens: tokens{secret: []byte(secret)}, limit: newLimiter(cfg.RateLimit.Count, cfg.RateLimit.Window), searchLimit: newLimiter(120, time.Minute), mail: newMailer(cfg.SMTP), turnstile: os.Getenv("HOTDOG_TURNSTILE_SECRET"), now: time.Now, log: log.New(logw, "", log.LstdFlags), } for _, p := range cfg.TrustedProxies { _, n, _ := net.ParseCIDR(p) s.trusted = append(s.trusted, n) } s.search = newSearcher() return s, nil } // Listen is the configured address. func (s *Server) Listen() string { return s.cfg.Listen } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Cache-Control", "no-store") if r.URL.Path == "/_hotdog/health" { fmt.Fprintln(w, "ok") return } if r.URL.Path == "/_hotdog/source" { http.Redirect(w, r, about.Get(s.cfg.Source, nil).Source, http.StatusFound) return } host := strings.ToLower(r.Host) if h, _, err := net.SplitHostPort(host); err == nil { host = h } rt := s.sites[host] if rt == nil { http.Error(w, "unknown site", http.StatusNotFound) return } path := strings.TrimPrefix(r.URL.Path, "/_hotdog/") switch { case strings.HasPrefix(path, "forms/"): rest := strings.TrimPrefix(path, "forms/") name, tail, _ := strings.Cut(rest, "/") f := rt.forms[name] if f == nil { http.Error(w, "no such form", http.StatusNotFound) return } switch { case tail == "token" && r.Method == http.MethodGet: writeJSON(w, http.StatusOK, map[string]string{"token": s.tokens.issue(host, name, s.now())}) case tail == "" && r.Method == http.MethodPost: s.submit(w, r, rt, host, f) default: http.Error(w, "not here", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "search/") && r.Method == http.MethodGet: s.handleSearch(w, r, rt, strings.TrimSuffix(strings.TrimPrefix(path, "search/"), "/")) default: http.NotFound(w, r) } } // clientIP is the visitor's address: the peer's, unless the peer is a // trusted proxy, in which case the nearest address in X-Forwarded-For that // isn't one of ours. func (s *Server) clientIP(r *http.Request) string { peer, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { peer = r.RemoteAddr } if !s.isTrusted(peer) { return peer } hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",") for i := len(hops) - 1; i >= 0; i-- { h := strings.TrimSpace(hops[i]) if h == "" { continue } if net.ParseIP(h) == nil { return peer } if !s.isTrusted(h) { return h } } return peer } // limitKey is what the rate limit counts by: an IPv4 address, or an IPv6 // address's /64, since one connection is usually given a whole /64 and could // otherwise use a new address for every message. func limitKey(ip string) string { p := net.ParseIP(ip) if p == nil || p.To4() != nil { return ip } return p.Mask(net.CIDRMask(64, 128)).String() + "/64" } func (s *Server) isTrusted(ip string) bool { p := net.ParseIP(ip) if p == nil { return false } for _, n := range s.trusted { if n.Contains(p) { return true } } return false } // sameSite reports whether the request came from a page on the site itself. // Browsers send Origin with every form POST; a post from another site, or // from a script that doesn't bother, is refused. func sameSite(r *http.Request, rt *siteRuntime) bool { o := r.Header.Get("Origin") if o == "" || o == "null" { return false } u, err := url.Parse(o) if err != nil { return false } h := strings.ToLower(u.Hostname()) for _, ok := range rt.hosts { if h == strings.ToLower(ok) { return true } } return false } type reply struct { OK bool `json:"ok"` Message string `json:"message,omitempty"` Problems []forms.Problem `json:"problems,omitempty"` Retoken bool `json:"retoken,omitempty"` } func (s *Server) submit(w http.ResponseWriter, r *http.Request, rt *siteRuntime, host string, f *forms.Form) { wantsJSON := strings.Contains(r.Header.Get("Accept"), "application/json") answer := func(code int, rp reply) { if wantsJSON { writeJSON(w, code, rp) return } if rp.OK && f.Success != "" { http.Redirect(w, r, f.Success, http.StatusSeeOther) return } writeHTML(w, code, rp) } if !sameSite(r, rt) { answer(http.StatusForbidden, reply{Message: "This form can only be sent from its own page."}) return } ip := s.clientIP(r) if !s.limit.allow(host+"|"+limitKey(ip), s.now()) { answer(http.StatusTooManyRequests, reply{Message: "Too many messages from your connection. Please wait a few minutes and try again."}) return } if ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); ct != "application/x-www-form-urlencoded" { answer(http.StatusUnsupportedMediaType, reply{Message: "This form takes text fields only."}) return } r.Body = http.MaxBytesReader(w, r.Body, 64<<10) if err := r.ParseForm(); err != nil { answer(http.StatusRequestEntityTooLarge, reply{Message: "That's more than this form accepts."}) return } values := r.PostForm // The trap field: a person never sees it; a bot fills in everything. // Answer as if it worked, so the bot learns nothing. if values.Get("_website") != "" { s.log.Printf("%s %s: trap field filled, dropped", host, f.Name) answer(http.StatusOK, reply{OK: true, Message: "Thanks, your message was sent."}) return } if err := s.tokens.check(values.Get("_t"), host, f.Name, s.now()); err != nil { msg := "This form needs JavaScript to send. Please reload the page and try again." if errors.Is(err, errTokenYoung) { msg = "That was quicker than a person types. Please wait a moment and send again." } else if errors.Is(err, errTokenOld) { msg = "This page has been open a long time. Please reload it and send again." } answer(http.StatusForbidden, reply{Message: msg, Retoken: true}) return } if f.Turnstile != "" && !turnstileOK(r.Context(), s.turnstile, values.Get("cf-turnstile-response"), ip) { answer(http.StatusForbidden, reply{Message: "Please complete the check above the button and send again."}) return } sub, problems := f.Check(values) if problems != nil { answer(http.StatusBadRequest, reply{Message: "Please check the highlighted fields.", Problems: problems}) return } stored := false if f.Store { if err := s.store.append(rt.cfg.Store, sub, s.now()); err != nil { s.log.Printf("%s %s: storing failed: %v", host, f.Name, err) } else { stored = true } } if err := s.mail.deliver(rt.site.Name, sub); err != nil { s.log.Printf("%s %s: delivery failed: %v", host, f.Name, err) if !stored { msg := "The message couldn't be sent just now. Please try again later." if f.Mailto != "" { msg += " You can also email " + f.Mailto + "." } answer(http.StatusBadGateway, reply{Message: msg}) return } } s.log.Printf("%s %s: sent", host, f.Name) answer(http.StatusOK, reply{OK: true, Message: "Thanks, your message was sent."}) } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } var replyPage = template.Must(template.New("reply").Parse(` {{ if .OK }}Sent{{ else }}Not sent{{ end }}

{{ if .OK }}Sent{{ else }}Not sent yet{{ end }}

{{ .Message }}

{{ with .Problems }}{{ end }}

Use your browser's Back button to return to the form; what you typed is still there.

`)) func writeHTML(w http.ResponseWriter, code int, rp reply) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(code) _ = replyPage.Execute(w, rp) }