This commit is contained in:
+21
-21
@@ -15,14 +15,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxBodyBytes = 32 << 10 // 32 KiB is far more than this form needs
|
||||
maxBodyBytes = 32 << 10
|
||||
perIPLimit = 5
|
||||
perIPWindow = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Server backs the marketing site's two forms: contact, which is emailed and
|
||||
// never stored, and signup, which provisions an organisation and its owner
|
||||
// after the address has been verified.
|
||||
|
||||
|
||||
|
||||
type Server struct {
|
||||
mail mail.Config
|
||||
limiter *limiter
|
||||
@@ -49,7 +49,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/contact", s.handleContact)
|
||||
mux.HandleFunc("POST /api/signup", s.handleSignup)
|
||||
// Opened from an email client, so it is a GET that renders a page.
|
||||
|
||||
mux.HandleFunc("GET /api/verify", s.handleVerify)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
@@ -57,7 +57,7 @@ func (s *Server) Routes() http.Handler {
|
||||
return s.withCORS(mux)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- middleware
|
||||
|
||||
|
||||
func parseOrigins(raw string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
@@ -69,10 +69,10 @@ func parseOrigins(raw string) map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// withCORS reflects only origins named in SITE_ORIGIN. It never answers with a
|
||||
// wildcard: this endpoint sends mail, and an unset SITE_ORIGIN should fail
|
||||
// closed for cross-origin callers rather than open to every site on the
|
||||
// internet.
|
||||
|
||||
|
||||
|
||||
|
||||
func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
@@ -91,9 +91,9 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP prefers the left-most X-Forwarded-For entry, but only when the
|
||||
// service is explicitly told it sits behind a proxy. Trusting the header
|
||||
// unconditionally would let any caller spoof its way past the rate limiter.
|
||||
|
||||
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if s.trustProxy {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
@@ -110,7 +110,7 @@ func (s *Server) clientIP(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- handler
|
||||
|
||||
|
||||
type contactBody struct {
|
||||
Name string `json:"name"`
|
||||
@@ -118,7 +118,7 @@ type contactBody struct {
|
||||
Servers string `json:"servers"`
|
||||
Topic string `json:"topic"`
|
||||
Message string `json:"message"`
|
||||
Website string `json:"website"` // honeypot: real people leave this empty
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -137,8 +137,8 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A filled honeypot is a bot. Answer exactly as we would on success so it
|
||||
// learns nothing, and send nothing.
|
||||
|
||||
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
|
||||
return
|
||||
@@ -201,9 +201,9 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing is stored, so the send has to succeed before we can tell someone
|
||||
// their message arrived. This is the one place where a mail failure is the
|
||||
// caller's problem.
|
||||
|
||||
|
||||
|
||||
if err := s.mail.Send(subject(addr, fields), plainBody(addr, fields), addr); err != nil {
|
||||
log.Printf("contact send: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
@@ -233,7 +233,7 @@ func plainBody(addr string, fields map[string]string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// limiter is a fixed-window counter keyed by client IP. It exists to blunt
|
||||
// automated submission floods, not to be a precise quota: the window resets
|
||||
// wholesale, and state is per-process, so it is a speed bump rather than a
|
||||
// guarantee. The per-email check in the handler backs it up.
|
||||
|
||||
|
||||
|
||||
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string]*window
|
||||
@@ -51,8 +51,8 @@ func (l *limiter) allow(key string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// gc drops expired windows so a long-running process does not accumulate an
|
||||
// entry for every IP that ever hit it. Caller must hold the lock.
|
||||
|
||||
|
||||
func (l *limiter) gc(now time.Time) {
|
||||
if now.Sub(l.lastGC) < l.window {
|
||||
return
|
||||
|
||||
@@ -25,20 +25,20 @@ type signupBody struct {
|
||||
OrgName string `json:"org_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"` // honeypot
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
// handleSignup records an unverified signup and emails the confirmation link.
|
||||
// Nothing is created in orgs or users until that link is opened, so an address
|
||||
// nobody controls can never occupy an email or hold an organisation slug.
|
||||
|
||||
|
||||
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var body signupBody
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
// A filled honeypot is a bot. Answer as we would on success so it learns
|
||||
// nothing, and record nothing.
|
||||
|
||||
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
return
|
||||
@@ -114,9 +114,9 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
link := s.verifyURL(token)
|
||||
if err := s.mail.SendVerification(addr, orgName, link, store.PendingTTL); err != nil {
|
||||
// The pending record is useless without its email, and the address is
|
||||
// not registered, so the caller must be told rather than left waiting
|
||||
// for a message that will never arrive.
|
||||
|
||||
|
||||
|
||||
log.Printf("signup: send verification to %s: %v", addr, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "We could not send the confirmation email. Check the address, or email support@hostxtra.co.uk.",
|
||||
@@ -132,9 +132,9 @@ func (s *Server) verifyURL(token string) string {
|
||||
return fmt.Sprintf("%s/api/verify?token=%s", base, url.QueryEscape(token))
|
||||
}
|
||||
|
||||
// handleVerify consumes the token and provisions the organisation. It is opened
|
||||
// from an email client, so it answers with a page rather than JSON, and
|
||||
// redirects to the app's sign-in page on success when one is configured.
|
||||
|
||||
|
||||
|
||||
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
@@ -178,9 +178,9 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name))
|
||||
}
|
||||
|
||||
// verifyPage renders a minimal self-contained page. Everything interpolated is
|
||||
// escaped: the only dynamic value is an organisation name the visitor supplied
|
||||
// themselves, but it still reaches a browser as HTML.
|
||||
|
||||
|
||||
|
||||
func (s *Server) verifyPage(w http.ResponseWriter, status int, heading, detail string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Deliberately loose: the only thing worth rejecting here is something that
|
||||
// cannot be an address at all. Anything stricter starts refusing valid mail.
|
||||
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`)
|
||||
|
||||
const (
|
||||
@@ -23,9 +23,9 @@ type fieldError struct {
|
||||
|
||||
func (e fieldError) Error() string { return e.Field + ": " + e.Message }
|
||||
|
||||
// text trims, rejects empties when required, and caps length. The cap is on
|
||||
// runes rather than bytes so a multi-byte message is not silently truncated
|
||||
// mid-character.
|
||||
|
||||
|
||||
|
||||
func text(name, value string, required bool, max int) (string, *fieldError) {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
@@ -55,9 +55,9 @@ func email(name, value string) (string, *fieldError) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// oneOf constrains a value to a known set. Submitted values for dropdowns are
|
||||
// as attacker-controlled as any other field, so they are checked rather than
|
||||
// trusted and stored.
|
||||
|
||||
|
||||
|
||||
func oneOf(name, value string, allowed []string) (string, *fieldError) {
|
||||
v := strings.TrimSpace(value)
|
||||
for _, a := range allowed {
|
||||
|
||||
Reference in New Issue
Block a user