| 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 |