Files
llmgw/internal/web/formguard.go
T
Ilker ManapandClaude Opus 5.5 fe5b7f88f0 Believe X-Forwarded-* only from router.trusted_proxies
A new internal/clientip says who sent a request and how: the connection's
address, scheme and Host, or through a configured proxy what it forwarded.
The base URL in pages and setup scripts, isHTTPS for cookies, and the
per-address limits of auth and the sign-in forms use it. /setup scripts are
no-store.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:29:02 +02:00

108 lines
2.9 KiB
Go

package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"net/http"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/clientip"
)
// The sign-in and signup forms are the two a browser posts before it has a
// session: nothing ties them to the person in front of it, and nothing slows
// a script posting them. Two guards: a token the form carries and a cookie
// matches, so another site cannot sign a visitor into an account of its
// choosing; and a ration per client address, so a script cannot spend the
// password hasher, the database or an address's attempts at the rate it can
// send.
// loginCSRFCookie is the sign-in form's token, before there is a session.
const loginCSRFCookie = "llmgw_login_csrf"
// newFormToken is a token for one form.
func newFormToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// setLoginToken gives the sign-in form its token and the browser its match.
func setLoginToken(w http.ResponseWriter, r *http.Request) string {
t := newFormToken()
http.SetCookie(w, &http.Cookie{
Name: loginCSRFCookie, Value: t, Path: "/login",
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: 3600,
})
return t
}
// loginTokenOK reports whether a posted sign-in form carries the token its
// cookie does.
func loginTokenOK(r *http.Request) bool {
c, err := r.Cookie(loginCSRFCookie)
if err != nil || c.Value == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(r.FormValue("csrf"))) == 1
}
// ration is a token bucket per client address.
type ration struct {
mu sync.Mutex
rate float64 // a second
burst float64
by map[string]*allowance
}
type allowance struct {
tokens float64
at time.Time
}
func newRation(perMinute, burst float64) *ration {
return &ration{rate: perMinute / 60, burst: burst, by: map[string]*allowance{}}
}
func (q *ration) allow(client string, now time.Time) bool {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.by) > 50_000 {
for k, v := range q.by {
if now.Sub(v.at).Seconds()*q.rate >= q.burst {
delete(q.by, k)
}
}
}
a, ok := q.by[client]
if !ok {
a = &allowance{tokens: q.burst, at: now}
q.by[client] = a
}
a.tokens = min(q.burst, a.tokens+now.Sub(a.at).Seconds()*q.rate)
a.at = now
if a.tokens < 1 {
return false
}
a.tokens--
return true
}
// rationed refuses a client past its ration with 429 before next runs.
func rationed(q *ration, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !q.allow(clientAddr(r), time.Now()) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many attempts from this address; wait a minute", http.StatusTooManyRequests)
return
}
next(w, r)
}
}
// clientAddr is the address a request came from: through a trusted proxy,
// the client's.
func clientAddr(r *http.Request) string { return clientip.Addr(r) }