diff --git a/pkg/config/config.go b/pkg/config/config.go index b090c6c..6f92b64 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -47,13 +47,21 @@ const ( ChangeEmailRedisKey = "change-email/%s" SignupTokenExpires = 24 * time.Hour // used for all tokens so far - // Rate limit + // Rate limits RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id LoginRateLimitWindow = 1 * time.Hour LoginRateLimit = 10 // 10 failed login attempts = locked for full hour LoginRateLimitCooldownAt = 3 // 3 failed attempts = start throttling LoginRateLimitCooldown = 30 * time.Second + // Contact form rate limits for logged-out users to curb spam robots: + // - One message can be submitted every 2 minutes + // - If they post 10 minutes in an hour they are paused for one hour. + ContactRateLimitWindow = 1 * time.Hour + ContactRateLimit = 10 + ContactRateLimitCooldownAt = 1 + ContactRateLimitCooldown = 2 * time.Minute + // How frequently to refresh LastLoginAt since sessions are long-lived. LastLoginAtCooldown = 8 * time.Hour ) diff --git a/pkg/config/variable.go b/pkg/config/variable.go index 75dd3a1..fcb55e5 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -15,11 +15,12 @@ var Current = DefaultVariable() // Variable configuration attributes (loaded from settings.json). type Variable struct { - BaseURL string - AdminEmail string - Mail Mail - Redis Redis - Database Database + BaseURL string + AdminEmail string + Mail Mail + Redis Redis + Database Database + UseXForwardedFor bool } // DefaultVariable returns the default settings.json data. diff --git a/pkg/controller/index/contact.go b/pkg/controller/index/contact.go index fc88903..450e099 100644 --- a/pkg/controller/index/contact.go +++ b/pkg/controller/index/contact.go @@ -11,6 +11,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/mail" "code.nonshy.com/nonshy/website/pkg/markdown" "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/ratelimit" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" ) @@ -26,6 +27,8 @@ func Contact() http.HandlerFunc { title = "Contact Us" message = r.FormValue("message") replyTo = r.FormValue("email") + trap1 = r.FormValue("url") != "https://" + trap2 = r.FormValue("comment") != "" tableID int tableName string tableLabel string // front-end user feedback about selected report item @@ -85,6 +88,32 @@ func Contact() http.HandlerFunc { replyTo = currentUser.Email } + // Rate limit submissions, especially for logged-out users. + if currentUser == nil { + limiter := &ratelimit.Limiter{ + Namespace: "contact", + ID: session.RemoteAddr(r), + Limit: config.ContactRateLimit, + Window: config.ContactRateLimitWindow, + CooldownAt: config.ContactRateLimitCooldownAt, + Cooldown: config.ContactRateLimitCooldown, + } + + if err := limiter.Ping(); err != nil { + session.FlashError(w, r, err.Error()) + templates.Redirect(w, r.URL.Path) + return + } + } + + // If they have tripped the spam bot trap fields, don't save their message. + if trap1 || trap2 { + log.Error("Contact form: bot has tripped the trap fields, do not save message") + session.Flash(w, r, success) + templates.Redirect(w, r.URL.Path) + return + } + // Store feedback in the database. fb := &models.Feedback{ Intent: intent, diff --git a/pkg/ratelimit/ratelimit.go b/pkg/ratelimit/ratelimit.go index 14d96ed..f682c7b 100644 --- a/pkg/ratelimit/ratelimit.go +++ b/pkg/ratelimit/ratelimit.go @@ -56,7 +56,7 @@ func (l *Limiter) Ping() error { } // Are we throttled? - if data.Pings >= l.CooldownAt { + if l.CooldownAt > 0 && data.Pings > l.CooldownAt { data.NotBefore = now.Add(l.Cooldown) if err := redis.Set(key, data, l.Window); err != nil { return fmt.Errorf("Couldn't set Redis key for rate limiter: %s", err) diff --git a/pkg/session/session.go b/pkg/session/session.go index 1d7ad46..66512b6 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "code.nonshy.com/nonshy/website/pkg/config" @@ -112,6 +113,19 @@ func Get(r *http.Request) *Session { return nil } +// RemoteAddr returns the user's remote IP address. If UseXForwardedFor is enabled in settings.json, +// the HTTP header X-Forwarded-For may be returned here or otherwise the request RemoteAddr is returned. +func RemoteAddr(r *http.Request) string { + if config.Current.UseXForwardedFor { + xff := r.Header.Get("X-Forwarded-For") + if len(xff) > 0 { + return strings.SplitN(xff, ",", 1)[0] + } + } + + return strings.SplitN(r.RemoteAddr, ":", 1)[0] +} + // ReadFlashes returns and clears the Flashes and Errors for this session. func (s *Session) ReadFlashes(w http.ResponseWriter) (flashes, errors []string) { flashes = s.Flashes diff --git a/web/static/css/theme.css b/web/static/css/theme.css index de31639..d1f5ca7 100644 --- a/web/static/css/theme.css +++ b/web/static/css/theme.css @@ -67,4 +67,9 @@ /* Collapsible cards for mobile (e.g. filter cards) */ .card.nonshy-collapsible-mobile { cursor: pointer; +} + +/* Hide an element */ +.nonshy-hidden { + display: none; } \ No newline at end of file diff --git a/web/templates/contact.html b/web/templates/contact.html index 58fec7f..fabfd75 100644 --- a/web/templates/contact.html +++ b/web/templates/contact.html @@ -89,6 +89,26 @@ {{end}} + +
+ + +

+ Do not touch this field. +

+
+
+ + +

+ Do not touch this field. +

+
+