Files
llmgw/internal/web/formguard.go
T
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

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

109 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")
// Plain text, but read by the person whose form was refused.
http.Error(w, langOf(r).T("login.err.rationed"), 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) }