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>
108 lines
2.9 KiB
Go
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) }
|