MosswartOverlord/go-services/tracker-go/website.go
Erik 5f7b4dcd55 fix(tracker-go): key login rate limiter on X-Real-IP, not spoofable XFF
nginx's X-Forwarded-For is $proxy_add_x_forwarded_for, which only appends
to whatever the client sends — a forged leftmost hop passed straight
through, letting an attacker rotate spoofed values to dodge the 5s login
cooldown. X-Real-IP is set by nginx to $remote_addr on every proxied
location and can't be forged by the client, so key on that instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 22:43:43 +02:00

174 lines
5.9 KiB
Go

package main
import (
"context"
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// Website-serving layer: static frontend + login/logout, porting main.py so the
// unchanged frontend loads on the Go tracker. Cookie issuing/verifying is in
// auth.go; this file is the handlers + the static file server.
// A fixed bcrypt hash used to keep the no-such-user path constant-time, matching
// Python's _DUMMY_HASH. (Hash of an arbitrary constant; never matches input.)
var dummyBcryptHash = []byte("$2a$12$C6UzMDM.H6dfI/f/IKcEeO3Jj6Q1jK7Z1qkq9b2yY6m4eW7N0pZ2K")
type loginLimiter struct {
mu sync.Mutex
last map[string]time.Time
}
func newLoginLimiter() *loginLimiter { return &loginLimiter{last: map[string]time.Time{}} }
// allow returns false if this IP attempted within the 5s cooldown (main.py).
func (l *loginLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
if t, ok := l.last[ip]; ok && now.Sub(t) < 5*time.Second {
return false
}
l.last[ip] = now
return true
}
// GET /login — serve the login page (main.py:login_page).
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
s.serveStaticFile(w, r, "login.html")
}
// loginClientIP returns the rate-limit key for a login attempt. Every nginx
// location proxying to the tracker sets X-Real-IP to $remote_addr (see
// nginx/overlord.conf), which the client cannot forge. X-Forwarded-For is
// $proxy_add_x_forwarded_for — nginx only APPENDS to it, so a client-supplied
// leftmost hop survives untouched and would let an attacker rotate spoofed
// values to dodge the cooldown.
func loginClientIP(r *http.Request) string {
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
return ip
}
return clientIP(r)
}
// POST /login — authenticate and set the session cookie (main.py:login).
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
ip := loginClientIP(r)
if !s.loginLimiter.allow(ip) {
writeJSON(w, http.StatusTooManyRequests, map[string]any{"detail": "Too many login attempts. Try again in a few seconds."})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Invalid request body"})
return
}
username := strings.ToLower(strings.TrimSpace(body.Username))
if username == "" || body.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Username and password required"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var dbUser, hash string
var isAdmin bool
err := s.pool.QueryRow(ctx,
"SELECT username, password_hash, is_admin FROM users WHERE LOWER(username) = $1", username,
).Scan(&dbUser, &hash, &isAdmin)
// Constant-time: always run bcrypt, even when the user doesn't exist.
pwOK := false
if err == nil {
pwOK = bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) == nil
} else {
_ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(body.Password))
}
if !pwOK {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid username or password"})
return
}
token := issueSessionCookie(s.secretKey, sessionUser{Username: dbUser, IsAdmin: isAdmin})
http.SetCookie(w, &http.Cookie{
Name: "session", Value: token, Path: "/", MaxAge: sessionMaxAge,
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "username": dbUser, "is_admin": isAdmin})
}
// GET /logout — clear the cookie and redirect to /login (main.py:logout).
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusFound)
}
// GET /icons/{filename} — serve an icon file (main.py:serve_icon).
func (s *Server) handleIcon(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("filename")
if name == "" || strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
http.NotFound(w, r)
return
}
s.serveStaticFile(w, r, filepath.Join("icons", name))
}
// handleStatic is the catch-all GET handler: serves files from staticDir, falls
// back to index.html for SPA routes (React client-side routing). Registered last
// so the specific API routes take precedence.
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
upath := path.Clean("/" + r.URL.Path)
full := filepath.Join(s.staticDir, filepath.FromSlash(upath))
// Guard against path traversal escaping staticDir.
if rel, err := filepath.Rel(s.staticDir, full); err != nil || strings.HasPrefix(rel, "..") {
http.NotFound(w, r)
return
}
if info, err := os.Stat(full); err == nil {
if info.IsDir() {
if idx := filepath.Join(full, "index.html"); fileExists(idx) {
http.ServeFile(w, r, idx)
return
}
} else {
http.ServeFile(w, r, full)
return
}
}
// SPA fallback — serve the app shell for unknown (client-routed) paths.
http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html"))
}
func (s *Server) serveStaticFile(w http.ResponseWriter, r *http.Request, rel string) {
full := filepath.Join(s.staticDir, filepath.FromSlash(rel))
if !fileExists(full) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, full)
}
func fileExists(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
}
// runIssueCookieCLI prints a session token for cross-checking itsdangerous
// cookie interop with the Python service.
func runIssueCookieCLI() {
if len(os.Args) < 5 {
os.Stderr.WriteString("usage: tracker-go issue-cookie <username> <is_admin:true|false> <secret_key>\n")
os.Exit(2)
}
os.Stdout.WriteString(issueSessionCookie(os.Args[4], sessionUser{Username: os.Args[2], IsAdmin: os.Args[3] == "true"}))
}