Files
ai-router/internal/server/security.go
T
2026-10-23 18:35:44 +08:00

204 lines
5.2 KiB
Go

package server
import (
"fmt"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"time"
)
const bodyReadTimeout = 30 * time.Second
const maxLimitEntries = 10000
// Login rate limits: failures only, per IP and globally; successful logins
// never consume or hit these buckets.
const (
loginFailuresPerMinute = 10
loginGlobalFailuresPerMinute = 300
)
// authFailuresPerMinute bounds how many failed /v1 authentications one client
// IP may attempt per minute before further attempts are rejected before the
// database lookup.
const authFailuresPerMinute = 60
func pruneFailures(m map[string][]time.Time, now time.Time) {
for k, ts := range m {
if len(ts) == 0 || now.Sub(ts[len(ts)-1]) > time.Minute {
delete(m, k)
}
}
}
// trimRecent drops entries older than one minute while reusing the backing
// array, so a long-lived failure window cannot grow without bound.
func trimRecent(ts []time.Time, now time.Time) []time.Time {
i := 0
for i < len(ts) && now.Sub(ts[i]) >= time.Minute {
i++
}
if i > 0 {
ts = append(ts[:0], ts[i:]...)
}
return ts
}
// authAllowed reports whether the client IP may attempt authentication. Only
// failures are counted, so legitimate traffic is unaffected.
func (s *Server) authAllowed(ip string) bool {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if now.Sub(s.authFailuresSeen) >= time.Minute {
pruneFailures(s.authFailures, now)
s.authFailuresSeen = now
}
recent := 0
for _, t := range s.authFailures[ip] {
if now.Sub(t) < time.Minute {
recent++
}
}
return recent < authFailuresPerMinute && (recent > 0 || len(s.authFailures) < maxLimitEntries)
}
// authFail records one failed authentication from ip.
func (s *Server) authFail(ip string) {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.authFailures[ip]; !ok && len(s.authFailures) >= maxLimitEntries {
return
}
s.authFailures[ip] = append(s.authFailures[ip], now)
}
func readBody(w http.ResponseWriter, r *http.Request, limit int64, timeout time.Duration, read func() error) error {
controller := http.NewResponseController(w)
_ = controller.SetReadDeadline(time.Now().Add(timeout))
defer controller.SetReadDeadline(time.Time{})
r.Body = http.MaxBytesReader(w, r.Body, limit)
defer r.Body.Close()
return read()
}
// SetTrustedProxies must be called before serving requests.
func (s *Server) SetTrustedProxies(cidrs []string) error {
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, cidr := range cidrs {
p, err := netip.ParsePrefix(cidr)
if err != nil {
return fmt.Errorf("invalid trusted proxy CIDR %q: %w", cidr, err)
}
if p.Bits() == 0 {
return fmt.Errorf("trusted proxy CIDR must not trust all addresses")
}
prefixes = append(prefixes, p)
}
s.trustedProxies = prefixes
return nil
}
func (s *Server) trusted(ip netip.Addr) bool {
for _, p := range s.trustedProxies {
if p.Contains(ip) {
return true
}
}
return false
}
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer, err := netip.ParseAddr(host)
if err != nil {
return host
}
peer = peer.Unmap()
if !s.trusted(peer) {
return peer.String()
}
parts := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
ips := make([]netip.Addr, len(parts))
for i, part := range parts {
ip, err := netip.ParseAddr(strings.TrimSpace(part))
if err != nil || ip.Zone() != "" {
return peer.String()
}
ips[i] = ip.Unmap()
}
for i := len(ips) - 1; i >= 0; i-- {
if !s.trusted(ips[i]) {
return ips[i].String()
}
}
return peer.String()
}
type limitEntry struct {
window time.Time
requests, active int
}
type requestLimiter struct {
mu sync.Mutex
rpm, perKey, global, active int
entries map[string]*limitEntry
nextCleanup time.Time
}
func newRequestLimiter(rpm, perKey, global int) *requestLimiter {
return &requestLimiter{rpm: rpm, perKey: perKey, global: global, entries: make(map[string]*limitEntry)}
}
// SetLimits must be called before serving requests.
func (s *Server) SetLimits(rpm, perKey, global int) error {
if rpm <= 0 || perKey <= 0 || global <= 0 {
return fmt.Errorf("limits must be positive integers")
}
s.limiter = newRequestLimiter(rpm, perKey, global)
return nil
}
func (l *requestLimiter) acquire(key string, now time.Time) (func(), string) {
l.mu.Lock()
defer l.mu.Unlock()
if !now.Before(l.nextCleanup) {
for k, e := range l.entries {
if e.active == 0 && now.Sub(e.window) >= time.Minute {
delete(l.entries, k)
}
}
l.nextCleanup = now.Add(time.Minute)
}
e := l.entries[key]
if e == nil {
if len(l.entries) >= maxLimitEntries {
return nil, "60"
}
e = &limitEntry{window: now}
l.entries[key] = e
}
if now.Sub(e.window) >= time.Minute {
e.window = now
e.requests = 0
}
if e.requests >= l.rpm {
seconds := int((time.Minute - now.Sub(e.window) + time.Second - 1) / time.Second)
return nil, strconv.Itoa(seconds)
}
if e.active >= l.perKey || l.active >= l.global {
return nil, "1"
}
e.requests++
e.active++
l.active++
var once sync.Once
return func() { once.Do(func() { l.mu.Lock(); defer l.mu.Unlock(); e.active--; l.active-- }) }, ""
}