Treat every byte from the client as hostile โ its content, its size, and how often it arrives.
8a
The same password field, sent to two versions of the login query. Real SQLite, real result.
// "... AND pw = '" + pw + "'" SELECT count(*) FROM users WHERE email = 'nobody@x.com' AND pw = '' OR '1'='1'
// db.QueryRow("... AND pw = ?", email, pw) SELECT count(*) FROM users WHERE email = ? AND pw = ? โ "' OR '1'='1"
? / $1). The driver sends them separately โ they can't change the query.Where("email = ?", e) is safe; Where("email = " + e) is not.ORDER BY can't be placeholders โ pick them from an allowlist.8b
Size first, then JSON shape, then business rules. Each failure has its own status code.
func decode(w http.ResponseWriter, r *http.Request, v any) error { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() return dec.Decode(v) } func (l LoginReq) Validate() error { var errs []error if _, err := mail.ParseAddress(l.Email); err != nil { errs = append(errs, errors.New("email: invalid")) } if n := len(l.Password); n < 8 || n > 72 { errs = append(errs, errors.New("password: 8-72 bytes")) } return errors.Join(errs...) } // in the handler var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { http.Error(w, "body too large", 413) return }
401 invalid email or password # well-formed, wrong password
422 email: invalid; password: 8-72 bytes # both errors, one reply
400 bad json: json: unknown field "role" # mass-assignment attempt
400 bad json: unexpected EOF
413 body too large (2 MB body)
MaxBytesReader one request can fill your memory."role":"admin" sneaking into a struct.8c
/loginFive tokens, one back every 12 seconds โ 5 per minute per IP. Each frame is real x/time/rate output.
type IPLimiter struct { mu sync.Mutex ips map[string]*rate.Limiter r rate.Limit b int } func (l *IPLimiter) get(ip string) *rate.Limiter { l.mu.Lock() defer l.mu.Unlock() lim, ok := l.ips[ip] if !ok { lim = rate.NewLimiter(l.r, l.b) l.ips[ip] = lim } return lim } func (l *IPLimiter) Middleware(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { ip, _, _ := net.SplitHostPort(r.RemoteAddr) if !l.get(ip).Allow() { w.Header().Set("Retry-After", "12") http.Error(w, "too many requests", 429) return } next.ServeHTTP(w, r) } return http.HandlerFunc(fn) } // 5 per minute = one token every 12 s, burst 5 lim := NewIPLimiter(time.Minute/5, 5) mux.Handle("POST /login", lim.Middleware(login))
attempt 1 from 203.0.113.9 โ 401
attempt 2 from 203.0.113.9 โ 401
attempt 3 from 203.0.113.9 โ 401
attempt 4 from 203.0.113.9 โ 401
attempt 5 from 203.0.113.9 โ 401
attempt 6 from 203.0.113.9 โ 429
attempt 7 from 203.0.113.9 โ 429
attempt 1 from 198.51.100.4 โ 401 # its own bucket
func Allow(ctx context.Context, rdb *redis.Client, ip string, limit int64, win time.Duration) (bool, error) { key := "rl:login:" + ip n, err := rdb.Incr(ctx, key).Result() if err != nil { return false, err } if n == 1 { rdb.Expire(ctx, key, win) // first hit opens the window } return n <= limit, nil }
hit 1 via api-A โ allowed=true
hit 2 via api-B โ allowed=true
hit 3 via api-A โ allowed=true
hit 4 via api-B โ allowed=true
hit 5 via api-A โ allowed=true
hit 6 via api-B โ allowed=false
ttl: 1m0s
61s later โ allowed = true
RemoteAddr is the proxy โ read X-Forwarded-For only from a proxy you trust.ips map grows forever โ evict idle entries on a timer in production.POST /login in the limiter; return 429 with Retry-After.Allow and run two server processes.Recap
Validate().