| 1 | package serve |
| 2 | |
| 3 | import ( |
| 4 | "crypto/hmac" |
| 5 | "crypto/pbkdf2" |
| 6 | "crypto/rand" |
| 7 | "crypto/sha256" |
| 8 | "crypto/subtle" |
| 9 | _ "embed" |
| 10 | "encoding/base64" |
| 11 | "encoding/hex" |
| 12 | "fmt" |
| 13 | "log/slog" |
| 14 | "net" |
| 15 | "net/http" |
| 16 | "net/url" |
| 17 | "strconv" |
| 18 | "strings" |
| 19 | "sync" |
| 20 | "time" |
| 21 | |
| 22 | "golang.org/x/crypto/bcrypt" |
| 23 | |
| 24 | "reasonix/internal/config" |
| 25 | ) |
| 26 | |
| 27 | //go:embed login.html |
| 28 | var loginHTML []byte |
| 29 | |
| 30 | // authMode represents the authentication mode for the serve frontend. |
| 31 | type authMode int |
| 32 | |
| 33 | const ( |
| 34 | authInvalid authMode = iota // invalid config; deny all requests |
| 35 | authNone // no authentication (default, backward-compatible) |
| 36 | authToken // pre-shared token in URL or cookie |
| 37 | authPassword // login page with bcrypt password |
| 38 | ) |
| 39 | |
| 40 | const ( |
| 41 | cookieToken = "reasonix_token" // holds the token for token mode |
| 42 | cookieSession = "reasonix_session" // holds the HMAC-signed session for password mode |
| 43 | cookieRedirect = "reasonix_redirect" // temporary: where to go after login |
| 44 | tokenByteLen = 32 // 256-bit random token |
| 45 | sessionDuration = 30 * 24 * time.Hour // how long a password session lasts |
| 46 | bcryptCost = 12 // bcrypt cost factor |
| 47 | pbkdf2Iter = 4096 // deterministic session-key derivation from password_hash |
| 48 | ) |
| 49 | |
| 50 | // NormalizeAuthMode normalizes and validates the serve auth mode. |
| 51 | func NormalizeAuthMode(mode string) (string, error) { |
| 52 | mode = strings.ToLower(strings.TrimSpace(mode)) |
| 53 | if mode == "" { |
| 54 | mode = "none" |
| 55 | } |
| 56 | switch mode { |
| 57 | case "none", "token", "password": |
| 58 | return mode, nil |
| 59 | default: |
| 60 | return "", fmt.Errorf("auth mode must be none, token, or password, got %q", mode) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // rateLimit tracks login attempts per IP for brute-force protection. |
| 65 | type rateLimit struct { |
| 66 | mu sync.Mutex |
| 67 | attempts map[string]*rateWindow |
| 68 | } |
| 69 | |
| 70 | type rateWindow struct { |
| 71 | count int |
| 72 | start time.Time |
| 73 | } |
| 74 | |
| 75 | const ( |
| 76 | rateLimitMax = 5 |
| 77 | rateLimitWin = time.Minute |
| 78 | ) |
| 79 | |
| 80 | func newRateLimit() *rateLimit { |
| 81 | rl := &rateLimit{attempts: make(map[string]*rateWindow)} |
| 82 | go rl.cleanupLoop() |
| 83 | return rl |
| 84 | } |
| 85 | |
| 86 | // cleanupLoop periodically purges expired rate-limit windows so the map does not |
| 87 | // grow without bound over the lifetime of a long-running server. |
| 88 | func (rl *rateLimit) cleanupLoop() { |
| 89 | ticker := time.NewTicker(2 * rateLimitWin) |
| 90 | defer ticker.Stop() |
| 91 | for range ticker.C { |
| 92 | rl.mu.Lock() |
| 93 | now := time.Now() |
| 94 | for ip, w := range rl.attempts { |
| 95 | if now.Sub(w.start) > rateLimitWin { |
| 96 | delete(rl.attempts, ip) |
| 97 | } |
| 98 | } |
| 99 | rl.mu.Unlock() |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // allow reports whether the IP is allowed to attempt login. It also cleans up |
| 104 | // expired windows. |
| 105 | func (rl *rateLimit) allow(ip string) bool { |
| 106 | rl.mu.Lock() |
| 107 | defer rl.mu.Unlock() |
| 108 | now := time.Now() |
| 109 | w, ok := rl.attempts[ip] |
| 110 | if !ok || now.Sub(w.start) > rateLimitWin { |
| 111 | rl.attempts[ip] = &rateWindow{count: 1, start: now} |
| 112 | return true |
| 113 | } |
| 114 | w.count++ |
| 115 | return w.count <= rateLimitMax |
| 116 | } |
| 117 | |
| 118 | // authGate is the authentication middleware and its runtime state. |
| 119 | type authGate struct { |
| 120 | mode authMode |
| 121 | token string // pre-shared token (token mode) |
| 122 | passwordHash string // bcrypt hash for password verification (password mode) |
| 123 | sessKey []byte // HMAC key for session signing (password mode, generated at startup) |
| 124 | behindProxy bool // trust X-Forwarded-For / X-Forwarded-Proto headers |
| 125 | rateLimit *rateLimit // per-IP rate limiter for /login |
| 126 | } |
| 127 | |
| 128 | // newAuthGate creates the auth middleware from the serve config. For token mode |
| 129 | // without a configured token, it generates a random one. |
| 130 | func newAuthGate(cfg config.ServeConfig) *authGate { |
| 131 | ag := &authGate{ |
| 132 | rateLimit: newRateLimit(), |
| 133 | behindProxy: cfg.BehindProxy, |
| 134 | } |
| 135 | mode, err := NormalizeAuthMode(cfg.AuthMode) |
| 136 | if err != nil { |
| 137 | ag.mode = authInvalid |
| 138 | return ag |
| 139 | } |
| 140 | switch mode { |
| 141 | case "token": |
| 142 | ag.mode = authToken |
| 143 | ag.token = strings.TrimSpace(cfg.Token) |
| 144 | if ag.token == "" { |
| 145 | ag.token = generateToken() |
| 146 | } |
| 147 | case "password": |
| 148 | ag.mode = authPassword |
| 149 | ag.passwordHash = strings.TrimSpace(cfg.PasswordHash) |
| 150 | ag.sessKey = sessionKeyForPasswordHash(ag.passwordHash) |
| 151 | default: |
| 152 | ag.mode = authNone |
| 153 | } |
| 154 | return ag |
| 155 | } |
| 156 | |
| 157 | // Token returns the shared token (empty if not in token mode). |
| 158 | func (ag *authGate) Token() string { return ag.token } |
| 159 | |
| 160 | // Mode returns the auth mode name as a string. |
| 161 | func (ag *authGate) Mode() string { |
| 162 | switch ag.mode { |
| 163 | case authToken: |
| 164 | return "token" |
| 165 | case authPassword: |
| 166 | return "password" |
| 167 | case authInvalid: |
| 168 | return "invalid" |
| 169 | default: |
| 170 | return "none" |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // HashPassword returns a bcrypt hash of the given password. Exported for use by |
| 175 | // the CLI `--hash-password` flag. |
| 176 | func HashPassword(password string) (string, error) { |
| 177 | b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) |
| 178 | if err != nil { |
| 179 | return "", err |
| 180 | } |
| 181 | return string(b), nil |
| 182 | } |
| 183 | |
| 184 | func sessionKeyForPasswordHash(passwordHash string) []byte { |
| 185 | if passwordHash != "" { |
| 186 | key, err := pbkdf2.Key(sha256.New, passwordHash, []byte("reasonix serve session key"), pbkdf2Iter, 32) |
| 187 | if err != nil { |
| 188 | panic("serve/auth: pbkdf2 failed: " + err.Error()) |
| 189 | } |
| 190 | return key |
| 191 | } |
| 192 | key := make([]byte, 32) |
| 193 | if _, err := rand.Read(key); err != nil { |
| 194 | // crypto/rand.Read cannot fail on modern systems; panic rather than |
| 195 | // fall back to a deterministic key that would weaken every session. |
| 196 | panic("serve/auth: crypto/rand.Read failed: " + err.Error()) |
| 197 | } |
| 198 | return key |
| 199 | } |
| 200 | |
| 201 | // middleware returns an http.Handler that wraps next with authentication checks. |
| 202 | // In password mode, /login is handled directly to bypass the CSRF content-type |
| 203 | // guard (the login form uses application/x-www-form-urlencoded, not JSON). |
| 204 | func (ag *authGate) middleware(next http.Handler) http.Handler { |
| 205 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 206 | if ag.mode == authInvalid { |
| 207 | ag.deny(w, r) |
| 208 | return |
| 209 | } |
| 210 | if ag.mode == authNone { |
| 211 | next.ServeHTTP(w, r) |
| 212 | return |
| 213 | } |
| 214 | // /login and /login/ are handled directly by the auth gate — they must |
| 215 | // not pass through the CSRF guard (which rejects non-JSON POSTs). |
| 216 | if r.URL.Path == "/login" || r.URL.Path == "/login/" { |
| 217 | ag.handleLogin(w, r) |
| 218 | return |
| 219 | } |
| 220 | if ag.mode == authToken { |
| 221 | ag.checkToken(w, r, next) |
| 222 | return |
| 223 | } |
| 224 | // password mode |
| 225 | ag.checkSession(w, r, next) |
| 226 | }) |
| 227 | } |
| 228 | |
| 229 | // checkToken validates the token from cookie or query parameter. If the query |
| 230 | // parameter is valid, it sets a cookie and redirects to strip the token from the |
| 231 | // URL (preventing it from leaking via browser history or referrer headers). |
| 232 | func (ag *authGate) checkToken(w http.ResponseWriter, r *http.Request, next http.Handler) { |
| 233 | // 1. Check cookie first (fast path). |
| 234 | if c, err := r.Cookie(cookieToken); err == nil && strings.TrimSpace(c.Value) != "" { |
| 235 | if subtle.ConstantTimeCompare([]byte(c.Value), []byte(ag.token)) == 1 { |
| 236 | next.ServeHTTP(w, r) |
| 237 | return |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // 2. Check query parameter. |
| 242 | if q := r.URL.Query().Get("token"); q != "" { |
| 243 | if subtle.ConstantTimeCompare([]byte(q), []byte(ag.token)) == 1 { |
| 244 | // Set a persistent cookie so future requests (including SSE) are |
| 245 | // authenticated without the token in the URL. |
| 246 | ag.setAuthCookie(w, r, &http.Cookie{ |
| 247 | Name: cookieToken, |
| 248 | Value: ag.token, |
| 249 | Path: "/", |
| 250 | HttpOnly: true, |
| 251 | SameSite: http.SameSiteLaxMode, |
| 252 | MaxAge: int(sessionDuration.Seconds()), |
| 253 | }) |
| 254 | // Redirect to the same path without the token query parameter. |
| 255 | cleanURL := *r.URL |
| 256 | qry := cleanURL.Query() |
| 257 | qry.Del("token") |
| 258 | cleanURL.RawQuery = qry.Encode() |
| 259 | if cleanURL.RawQuery == "" { |
| 260 | cleanURL.RawQuery = "" |
| 261 | } |
| 262 | redirectToSafeTarget(w, r, cleanURL.RequestURI(), http.StatusFound) |
| 263 | return |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // 3. Not authenticated. |
| 268 | ag.deny(w, r) |
| 269 | } |
| 270 | |
| 271 | // checkSession validates the HMAC-signed session cookie for password mode. |
| 272 | // Unauthenticated browser requests are redirected to /login; API/SSE requests |
| 273 | // get a 401. The /login path is intercepted before this function by middleware. |
| 274 | func (ag *authGate) checkSession(w http.ResponseWriter, r *http.Request, next http.Handler) { |
| 275 | // Check session cookie. |
| 276 | if c, err := r.Cookie(cookieSession); err == nil { |
| 277 | if ag.verifySession(c.Value) { |
| 278 | next.ServeHTTP(w, r) |
| 279 | return |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // Not authenticated. |
| 284 | if acceptsHTML(r) { |
| 285 | // Store the original path so we can redirect back after login. |
| 286 | dest := safeRedirectTarget(r.URL.RequestURI()) |
| 287 | ag.setAuthCookie(w, r, &http.Cookie{ |
| 288 | Name: cookieRedirect, |
| 289 | Value: dest, |
| 290 | Path: "/", |
| 291 | HttpOnly: true, |
| 292 | SameSite: http.SameSiteLaxMode, |
| 293 | MaxAge: 300, // 5 minutes |
| 294 | }) |
| 295 | http.Redirect(w, r, "/login", http.StatusFound) |
| 296 | return |
| 297 | } |
| 298 | |
| 299 | ag.deny(w, r) |
| 300 | } |
| 301 | |
| 302 | // deny sends a 401 response. The message is intentionally generic to avoid |
| 303 | // leaking information about which auth mode is active. |
| 304 | func (ag *authGate) deny(w http.ResponseWriter, r *http.Request) { |
| 305 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 306 | w.WriteHeader(http.StatusUnauthorized) |
| 307 | _, _ = w.Write([]byte("Unauthorized\n")) |
| 308 | } |
| 309 | |
| 310 | // handleLogin serves the login page (GET) or processes a login attempt (POST). |
| 311 | func (ag *authGate) handleLogin(w http.ResponseWriter, r *http.Request) { |
| 312 | switch r.Method { |
| 313 | case http.MethodGet: |
| 314 | ag.loginPage(w, r) |
| 315 | case http.MethodPost: |
| 316 | ag.loginSubmit(w, r) |
| 317 | default: |
| 318 | http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | // loginPage serves the embedded login HTML. |
| 323 | func (ag *authGate) loginPage(w http.ResponseWriter, r *http.Request) { |
| 324 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 325 | _, _ = w.Write(loginHTML) |
| 326 | } |
| 327 | |
| 328 | // loginSubmit verifies the password and issues a session cookie. |
| 329 | func (ag *authGate) loginSubmit(w http.ResponseWriter, r *http.Request) { |
| 330 | // Rate limit. |
| 331 | ip := ag.clientIP(r) |
| 332 | if !ag.rateLimit.allow(ip) { |
| 333 | slog.Warn("serve/auth: rate-limited login attempt", "ip", ip) |
| 334 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 335 | w.WriteHeader(http.StatusTooManyRequests) |
| 336 | _, _ = w.Write([]byte("Too many attempts. Please wait a minute.\n")) |
| 337 | return |
| 338 | } |
| 339 | |
| 340 | // Parse the password from the form. |
| 341 | if err := r.ParseForm(); err != nil { |
| 342 | http.Error(w, "Bad Request", http.StatusBadRequest) |
| 343 | return |
| 344 | } |
| 345 | password := r.FormValue("password") |
| 346 | if password == "" { |
| 347 | ag.loginPageWithError(w, "Password is required.") |
| 348 | return |
| 349 | } |
| 350 | |
| 351 | // Verify against the stored bcrypt hash. |
| 352 | if ag.passwordHash == "" { |
| 353 | slog.Error("serve/auth: cannot verify password — no password_hash configured") |
| 354 | ag.loginPageWithError(w, "Server not configured for password authentication.") |
| 355 | return |
| 356 | } |
| 357 | |
| 358 | // Verify against bcrypt hash. |
| 359 | if err := bcrypt.CompareHashAndPassword([]byte(ag.passwordHash), []byte(password)); err != nil { |
| 360 | ag.loginPageWithError(w, "Invalid password.") |
| 361 | return |
| 362 | } |
| 363 | |
| 364 | // Create and sign a session. |
| 365 | session := ag.signSession() |
| 366 | |
| 367 | // Clear the redirect cookie and set the session cookie. |
| 368 | ag.setAuthCookie(w, r, &http.Cookie{ |
| 369 | Name: cookieRedirect, |
| 370 | Value: "", |
| 371 | Path: "/", |
| 372 | HttpOnly: true, |
| 373 | SameSite: http.SameSiteLaxMode, |
| 374 | MaxAge: -1, |
| 375 | }) |
| 376 | ag.setAuthCookie(w, r, &http.Cookie{ |
| 377 | Name: cookieSession, |
| 378 | Value: session, |
| 379 | Path: "/", |
| 380 | HttpOnly: true, |
| 381 | SameSite: http.SameSiteLaxMode, |
| 382 | MaxAge: int(sessionDuration.Seconds()), |
| 383 | }) |
| 384 | |
| 385 | // Redirect to the original destination, or /. |
| 386 | dest := "/" |
| 387 | if c, err := r.Cookie(cookieRedirect); err == nil && c.Value != "" { |
| 388 | dest = safeRedirectTarget(c.Value) |
| 389 | } |
| 390 | redirectToSafeTarget(w, r, dest, http.StatusFound) |
| 391 | } |
| 392 | |
| 393 | func (ag *authGate) setAuthCookie(w http.ResponseWriter, r *http.Request, c *http.Cookie) { |
| 394 | c.Secure = ag.authCookieSecure(r) |
| 395 | // codeql[go/cookie-secure-not-set] Secure cookies are only sent back over HTTPS; plain-HTTP serve must keep token/password auth usable. |
| 396 | http.SetCookie(w, c) |
| 397 | } |
| 398 | |
| 399 | func (ag *authGate) authCookieSecure(r *http.Request) bool { |
| 400 | return ag.isTLS(r) |
| 401 | } |
| 402 | |
| 403 | func safeRedirectTarget(raw string) string { |
| 404 | raw = strings.TrimSpace(raw) |
| 405 | raw = strings.ReplaceAll(raw, "\\", "/") |
| 406 | if i := strings.IndexByte(raw, '#'); i >= 0 { |
| 407 | raw = raw[:i] |
| 408 | } |
| 409 | if raw == "" { |
| 410 | return "/" |
| 411 | } |
| 412 | if raw != "/" && (len(raw) <= 1 || raw[0] != '/' || raw[1] == '/' || raw[1] == '\\') { |
| 413 | return "/" |
| 414 | } |
| 415 | u, err := url.Parse(raw) |
| 416 | if err != nil || u == nil || u.IsAbs() || u.Hostname() != "" { |
| 417 | return "/" |
| 418 | } |
| 419 | path := strings.ReplaceAll(u.Path, "\\", "/") |
| 420 | if path == "" { |
| 421 | return "/" |
| 422 | } |
| 423 | if path != "/" && (len(path) <= 1 || path[0] != '/' || path[1] == '/' || path[1] == '\\') { |
| 424 | return "/" |
| 425 | } |
| 426 | return u.RequestURI() |
| 427 | } |
| 428 | |
| 429 | func redirectToSafeTarget(w http.ResponseWriter, r *http.Request, raw string, status int) { |
| 430 | target := safeRedirectTarget(raw) |
| 431 | target = strings.ReplaceAll(target, "\\", "/") |
| 432 | u, err := url.Parse(target) |
| 433 | if err == nil && u != nil && !u.IsAbs() && u.Hostname() == "" { |
| 434 | redirect := u.RequestURI() |
| 435 | if redirect == "/" { |
| 436 | http.Redirect(w, r, "/", status) |
| 437 | return |
| 438 | } |
| 439 | if len(redirect) > 1 && redirect[0] == '/' && redirect[1] != '/' && redirect[1] != '\\' { |
| 440 | http.Redirect(w, r, redirect, status) |
| 441 | return |
| 442 | } |
| 443 | } |
| 444 | http.Redirect(w, r, "/", status) |
| 445 | } |
| 446 | |
| 447 | // signSession creates a new HMAC-signed session token valid for sessionDuration. |
| 448 | // Format: base64url(expiry_base10|random_16_bytes).hex(hmac_sha256) |
| 449 | func (ag *authGate) signSession() string { |
| 450 | expiry := time.Now().Add(sessionDuration).Unix() |
| 451 | nonce := make([]byte, 16) |
| 452 | if _, err := rand.Read(nonce); err != nil { |
| 453 | // crypto/rand.Read cannot fail on modern systems; panic rather than |
| 454 | // fall back to an all-zero nonce. Forging a cookie still requires the |
| 455 | // PBKDF2-derived sessKey, so this is not an auth bypass, but a constant |
| 456 | // nonce weakens session token uniqueness/unpredictability and is the |
| 457 | // same anti-pattern generateToken/sessionKeyForPasswordHash panic on. |
| 458 | panic("serve/auth: crypto/rand.Read failed: " + err.Error()) |
| 459 | } |
| 460 | |
| 461 | payload := strconv.FormatInt(expiry, 10) + "|" + base64.RawURLEncoding.EncodeToString(nonce) |
| 462 | mac := hmac.New(sha256.New, ag.sessKey) |
| 463 | mac.Write([]byte(payload)) |
| 464 | sig := hex.EncodeToString(mac.Sum(nil)) |
| 465 | |
| 466 | return payload + "." + sig |
| 467 | } |
| 468 | |
| 469 | // verifySession checks that a session token is valid (HMAC matches and not expired). |
| 470 | func (ag *authGate) verifySession(token string) bool { |
| 471 | // Split payload.signature |
| 472 | dot := strings.LastIndexByte(token, '.') |
| 473 | if dot < 0 { |
| 474 | return false |
| 475 | } |
| 476 | payload, sigHex := token[:dot], token[dot+1:] |
| 477 | |
| 478 | // Verify HMAC (constant-time via hmac.Equal; handles length mismatch |
| 479 | // internally so we don't leak timing information from a pre-check). |
| 480 | mac := hmac.New(sha256.New, ag.sessKey) |
| 481 | mac.Write([]byte(payload)) |
| 482 | expected := mac.Sum(nil) |
| 483 | sig, err := hex.DecodeString(sigHex) |
| 484 | if err != nil { |
| 485 | return false |
| 486 | } |
| 487 | if !hmac.Equal(sig, expected) { |
| 488 | return false |
| 489 | } |
| 490 | |
| 491 | // Check expiry (format: "unix_timestamp|base64nonce"). |
| 492 | pipe := strings.IndexByte(payload, '|') |
| 493 | if pipe < 0 { |
| 494 | return false |
| 495 | } |
| 496 | expiry, err := strconv.ParseInt(payload[:pipe], 10, 64) |
| 497 | if err != nil { |
| 498 | return false |
| 499 | } |
| 500 | return time.Now().Unix() < expiry |
| 501 | } |
| 502 | |
| 503 | // loginPageWithError renders the login page with an error message. |
| 504 | func (ag *authGate) loginPageWithError(w http.ResponseWriter, msg string) { |
| 505 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 506 | w.WriteHeader(http.StatusUnauthorized) |
| 507 | html := strings.Replace(string(loginHTML), "<!--ERROR-->", |
| 508 | `<div class="error">`+htmlEscape(msg)+`</div>`, 1) |
| 509 | _, _ = w.Write([]byte(html)) |
| 510 | } |
| 511 | |
| 512 | // htmlEscape does minimal escaping for display in an HTML context. |
| 513 | func htmlEscape(s string) string { |
| 514 | s = strings.ReplaceAll(s, "&", "&") |
| 515 | s = strings.ReplaceAll(s, "<", "<") |
| 516 | s = strings.ReplaceAll(s, ">", ">") |
| 517 | s = strings.ReplaceAll(s, "\"", """) |
| 518 | s = strings.ReplaceAll(s, "'", "'") |
| 519 | return s |
| 520 | } |
| 521 | |
| 522 | // generateToken returns a cryptographically random URL-safe token. |
| 523 | func generateToken() string { |
| 524 | b := make([]byte, tokenByteLen) |
| 525 | if _, err := rand.Read(b); err != nil { |
| 526 | // crypto/rand.Read failure is fatal for token generation. |
| 527 | panic("serve/auth: crypto/rand.Read failed: " + err.Error()) |
| 528 | } |
| 529 | return base64.RawURLEncoding.EncodeToString(b) |
| 530 | } |
| 531 | |
| 532 | // acceptsHTML reports whether the request's Accept header prefers text/html. |
| 533 | func acceptsHTML(r *http.Request) bool { |
| 534 | for _, h := range strings.Fields(r.Header.Get("Accept")) { |
| 535 | if strings.HasPrefix(h, "text/html") { |
| 536 | return true |
| 537 | } |
| 538 | } |
| 539 | return false |
| 540 | } |
| 541 | |
| 542 | // clientIP extracts the client IP from the request. When behindProxy is true, |
| 543 | // it trusts the leftmost entry in X-Forwarded-For (set by a trusted reverse |
| 544 | // proxy). Otherwise it uses RemoteAddr directly — X-Forwarded-For is ignored |
| 545 | // because an attacker can forge it. |
| 546 | func (ag *authGate) clientIP(r *http.Request) string { |
| 547 | if ag.behindProxy { |
| 548 | if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { |
| 549 | if i := strings.IndexByte(fwd, ','); i >= 0 { |
| 550 | return strings.TrimSpace(fwd[:i]) |
| 551 | } |
| 552 | return strings.TrimSpace(fwd) |
| 553 | } |
| 554 | } |
| 555 | // Strip port from RemoteAddr. |
| 556 | addr := r.RemoteAddr |
| 557 | if i := strings.LastIndexByte(addr, ':'); i >= 0 { |
| 558 | return addr[:i] |
| 559 | } |
| 560 | return addr |
| 561 | } |
| 562 | |
| 563 | // isTLS reports whether the request arrived over TLS. It trusts |
| 564 | // X-Forwarded-Proto only when behindProxy is true. |
| 565 | func (ag *authGate) isTLS(r *http.Request) bool { |
| 566 | if r.TLS != nil { |
| 567 | return true |
| 568 | } |
| 569 | if ag.behindProxy { |
| 570 | return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") |
| 571 | } |
| 572 | return false |
| 573 | } |
| 574 | |
| 575 | func isLoopbackHost(hostport string) bool { |
| 576 | hostport = strings.TrimSpace(hostport) |
| 577 | if hostport == "" { |
| 578 | return false |
| 579 | } |
| 580 | host := hostport |
| 581 | if h, _, err := net.SplitHostPort(hostport); err == nil { |
| 582 | host = h |
| 583 | } |
| 584 | host = strings.Trim(host, "[]") |
| 585 | if strings.EqualFold(host, "localhost") { |
| 586 | return true |
| 587 | } |
| 588 | ip := net.ParseIP(host) |
| 589 | return ip != nil && ip.IsLoopback() |
| 590 | } |
| 591 | |
| 592 | // PlainHTTPAuthWarning reports whether serve is exposing authenticated access |
| 593 | // over a non-loopback plain-HTTP listener. The listener may still be valid for a |
| 594 | // trusted LAN or reverse-proxy setup, but users should see the risk explicitly. |
| 595 | func PlainHTTPAuthWarning(cfg config.ServeConfig, addr string) string { |
| 596 | mode, err := NormalizeAuthMode(cfg.AuthMode) |
| 597 | if err != nil || mode == "none" || isLoopbackHost(addr) { |
| 598 | return "" |
| 599 | } |
| 600 | return "warning: authenticated serve is listening on non-loopback HTTP; use HTTPS via a trusted reverse proxy or bind to 127.0.0.1 for local-only access" |
| 601 | } |
| 602 |