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