Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / ratelimit.go
package main

import (
	"net"
	"net/http"
	"strings"
	"sync"
	"time"
)

// rateLimiter is a small in-memory fixed-window rate limiter, used both to
// blunt brute-force/credential-stuffing against the web login (counting
// failed attempts per key) and to bound how often the anonymous git-HTTP
// clone endpoints can be hit per source IP (counting every request). It is
// intentionally process-local: the web UI runs as a single replica (see
// deploy/k8s/deployment.yaml), so there's no shared-state requirement, and
// a restart clearing the counters is an acceptable, fail-open-on-restart
// trade-off.
type rateLimiter struct {
	mu     sync.Mutex
	hits   map[string]*hitWindow
	max    int
	window time.Duration
}

type hitWindow struct {
	count int
	reset time.Time
}

func newRateLimiter(max int, window time.Duration) *rateLimiter {
	l := &rateLimiter{hits: make(map[string]*hitWindow), max: max, window: window}
	go l.gcLoop()
	return l
}

// allowed reports whether an attempt for key may proceed right now. It does
// not record anything — call record separately, so a caller that only wants
// to count failures (e.g. login) can skip it on success.
func (l *rateLimiter) allowed(key string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	w := l.hits[key]
	if w == nil || time.Now().After(w.reset) {
		return true
	}
	return w.count < l.max
}

func (l *rateLimiter) record(key string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	now := time.Now()
	if w := l.hits[key]; w != nil && !now.After(w.reset) {
		w.count++
		return
	}
	l.hits[key] = &hitWindow{count: 1, reset: now.Add(l.window)}
}

// reset clears the counter for key, e.g. after a successful login so the
// window doesn't linger against a user who has proven who they are.
func (l *rateLimiter) reset(key string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	delete(l.hits, key)
}

func (l *rateLimiter) gcLoop() {
	for range time.Tick(l.window) {
		l.mu.Lock()
		now := time.Now()
		for k, w := range l.hits {
			if now.After(w.reset) {
				delete(l.hits, k)
			}
		}
		l.mu.Unlock()
	}
}

// clientIP extracts the caller's IP for rate-limiting. Behind the ingress
// (Traefik) the real client address arrives in X-Real-IP or as the last hop
// of X-Forwarded-For — the last entry is the one our own trusted proxy
// appended, so it can't be spoofed by the client the way the left-most
// (client-supplied) entry can. Falls back to the transport RemoteAddr when
// no proxy header is present (direct connection in dev).
func clientIP(r *http.Request) string {
	if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" {
		return xr
	}
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		parts := strings.Split(xff, ",")
		if last := strings.TrimSpace(parts[len(parts)-1]); last != "" {
			return last
		}
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}