返回 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 // 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, "&", "&amp;")
595 s = strings.ReplaceAll(s, "<", "&lt;")
596 s = strings.ReplaceAll(s, ">", "&gt;")
597 s = strings.ReplaceAll(s, "\"", "&#34;")
598 s = strings.ReplaceAll(s, "'", "&#39;")
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
686 lines GO