Session 08 ยท ๐Ÿงฑ Hardening

Request Defense

Treat every byte from the client as hostile โ€” its content, its size, and how often it arrives.

โœ“
8ainjection
โœ“
8bsize + shape validation
โœ“
8crate limiting

8a

Input is data, never code

The same password field, sent to two versions of the login query. Real SQLite, real result.

password typed by the attacker: ' OR '1'='1
โœ— STRING CONCATENATION
// "... AND pw = '" + pw + "'"
SELECT count(*) FROM users
WHERE email = 'nobody@x.com'
  AND pw = '' OR '1'='1'
rows matched: 1 โ†’ logged in
โœ“ PLACEHOLDER
// db.QueryRow("... AND pw = ?", email, pw)
SELECT count(*) FROM users
WHERE email = ?
  AND pw = ?   โ† "' OR '1'='1"
rows matched: 0
โœ“Always pass values as arguments (? / $1). The driver sends them separately โ€” they can't change the query.
โ†’GORM's Where("email = ?", e) is safe; Where("email = " + e) is not.
โ†’Column names and ORDER BY can't be placeholders โ€” pick them from an allowlist.

8b

One gate for every request body

Size first, then JSON shape, then business rules. Each failure has its own status code.

1
Size limithttp.MaxBytesReader ยท 1 MB
413
2
Valid JSONjson.Decoder
400
3
No unknown fieldsDisallowUnknownFields
400
4
Business rulesValidate() ยท every error at once
422
โœ“
Handlertyped, checked input
โ†’
internal/httpx/decode.go
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
}
go run ./demos/08 ยท POST /login
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)
โ†’Go's default is no size limit โ€” without MaxBytesReader one request can fill your memory.
โ†’Validate, don't sanitize, on the way in. Escape on the way out โ€” HTML templates, SQL placeholders.
โ†’Unknown-field rejection stops "role":"admin" sneaking into a struct.

8c

A token bucket on /login

Five tokens, one back every 12 seconds โ€” 5 per minute per IP. Each frame is real x/time/rate output.

internal/httpx/ratelimit.go
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))
go run ./demos/08 ยท 7 wrong passwords, then a second IP
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
several API instances? share the count in Redis
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
}
go run ./demos/08b ยท two instances, one Redis
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
โ†’In-memory limits are per process: 3 instances = 3ร— the limit. Use Redis once you scale out.
โ†’Behind a load balancer RemoteAddr is the proxy โ€” read X-Forwarded-For only from a proxy you trust.
โ†’Also limit per account (email): a botnet with 1,000 IPs still hits one user's bucket.
โ†’The ips map grows forever โ€” evict idle entries on a timer in production.
HANDS-ON ยท TOPIC 3

5 login attempts per minute per IP

  • Wrap only POST /login in the limiter; return 429 with Retry-After.
  • Prove it: a test sends 7 requests from one IP (5ร— 401, 2ร— 429) and 1 from another (401).
  • Stretch: swap in the Redis Allow and run two server processes.

Recap

Session 08 in four lines

1Placeholders, always โ€” user input must never become part of the query text.
2Every body: size limit โ†’ valid JSON โ†’ no unknown fields โ†’ Validate().
3Token bucket: burst of 5, one token back every 12 s, 429 when empty.
4Share limits in Redis across instances; limit by IP and by account.