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