返回 DeepSeek-Reasonix
hostguard.go
根目录 / internal / serve / hostguard.go
1 package serve
2
3 import (
4 "net"
5 "net/http"
6 "strings"
7 )
8
9 // hostGateState captures which Host headers hostGuard accepts, derived from
10 // the listen address. Wildcard and non-loopback binds deliberately expose the
11 // server beyond this machine, so Host policing there adds nothing and is off.
12 type hostGateState struct {
13 behindProxy bool
14 allowAny bool
15 listenHost string // specific, non-wildcard host the server is bound to
16 }
17
18 // setListenAddr derives hostGate from the address Run-style entry points will
19 // listen on. It must be called before Handler(); a later call only affects
20 // servers whose Handler is rebuilt.
21 func (s *Server) setListenAddr(addr string) {
22 host := addr
23 if h, _, err := net.SplitHostPort(addr); err == nil {
24 host = h
25 }
26 host = strings.Trim(host, "[]")
27 s.hostGate = hostGateState{
28 behindProxy: s.auth != nil && s.auth.behindProxy &&
29 (s.auth.mode == authToken || s.auth.mode == authPassword),
30 listenHost: strings.ToLower(host),
31 allowAny: isUnspecifiedHost(host),
32 }
33 }
34
35 func isUnspecifiedHost(host string) bool {
36 ip := net.ParseIP(host)
37 return ip != nil && ip.IsUnspecified()
38 }
39
40 // hostGuard rejects requests whose Host header names neither a loopback
41 // interface nor the address serve actually listens on.
42 //
43 // csrfGuard's application/json requirement only holds while an attacker's page
44 // stays cross-origin: a DNS-rebinding page is served from evil.example, which
45 // is then re-pointed at 127.0.0.1, making every subsequent fetch same-origin —
46 // no preflight, any Content-Type, and full read access to responses. Such a
47 // page can drive the unauthenticated agent endpoints (POST /bypass, /submit)
48 // and read /history verbatim. Pinning Host to the interfaces we serve breaks
49 // that: the rebound name never matches the allowlist.
50 //
51 // Exemptions: behind_proxy deployments send the reverse proxy's public
52 // hostname and must instead run an authenticated mode; wildcard / non-loopback
53 // binds (allowAny) intentionally expose the server. Requests with no Host at
54 // all (raw HTTP/1.0 clients) pass — nothing to validate.
55 func (s *Server) hostGuard(next http.Handler) http.Handler {
56 gate := s.hostGate
57 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58 host := r.Host
59 if h, _, err := net.SplitHostPort(host); err == nil {
60 host = h
61 }
62 host = strings.ToLower(strings.Trim(host, "[]"))
63 if host == "" || gate.behindProxy || gate.allowAny ||
64 isLoopbackHost(host) || host == gate.listenHost {
65 next.ServeHTTP(w, r)
66 return
67 }
68 http.Error(w, "misdirected request: Host is not a serve listen address",
69 http.StatusMisdirectedRequest)
70 })
71 }
72
73 // csrfGuard rejects state-changing requests that don't carry a JSON content type.
74 // The command endpoints have no auth and bind to localhost, so a page the user
75 // visits could otherwise drive them with a simple cross-origin POST (text/plain,
76 // no preflight) — submitting prompts or auto-approving tool calls. Requiring
77 // application/json forces a CORS preflight the unauthenticated server never
78 // answers, blocking cross-site requests; the same-origin frontend (which always
79 // sends JSON) is unaffected.
80 func csrfGuard(next http.Handler) http.Handler {
81 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82 if r.Method == http.MethodPost {
83 ct := r.Header.Get("Content-Type")
84 if i := strings.IndexByte(ct, ';'); i >= 0 {
85 ct = ct[:i]
86 }
87 if !strings.EqualFold(strings.TrimSpace(ct), "application/json") {
88 http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
89 return
90 }
91 }
92 next.ServeHTTP(w, r)
93 })
94 }
95
95 lines GO