// Copyright 2025 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package httpcommon import ( "errors" "fmt" "net/http" "net/http/httptrace" "sort" "strconv" "strings" "golang.org/x/net/http/httpguts" "golang.org/x/net/http2/hpack" ) var ( ErrRequestHeaderListSize = errors.New("request header list larger than peer's advertised limit") ) // EncodeHeadersParam is parameters to EncodeHeaders. type EncodeHeadersParam struct { Request *http.Request // AddGzipHeader indicates that an "accept-encoding: gzip" header should be // added to the request. AddGzipHeader bool // PeerMaxHeaderListSize, when non-zero, is the peer's MAX_HEADER_LIST_SIZE setting. PeerMaxHeaderListSize uint64 // DefaultUserAgent is the User-Agent header to send when the request // neither contains a User-Agent nor disables it. DefaultUserAgent string } // EncodeHeadersParam is the result of EncodeHeaders. type EncodeHeadersResult struct { HasBody bool HasTrailers bool } // EncodeHeaders constructs request headers common to HTTP/2 and HTTP/3. // It validates a request and calls headerf with each pseudo-header and header // for the request. // The headerf function is called with the validated, canonicalized header name. func EncodeHeaders(param EncodeHeadersParam, headerf func(name, value string)) (res EncodeHeadersResult, _ error) { req := param.Request // Check for invalid connection-level headers. if err := checkConnHeaders(req); err != nil { return res, err } if req.URL == nil { return res, errors.New("Request.URL is nil") } host := req.Host if host == "" { host = req.URL.Host } host, err := httpguts.PunycodeHostPort(host) if err != nil { return res, err } if !httpguts.ValidHostHeader(host) { return res, errors.New("invalid Host header") } // isNormalConnect is true if this is a non-extended CONNECT request. isNormalConnect := false protocol := req.Header.Get(":protocol") if req.Method == "CONNECT" && protocol == "" { isNormalConnect = true } else if protocol != "" && req.Method != "CONNECT" { return res, errors.New("invalid :protocol header in non-CONNECT request") } // Validate the path, except for non-extended CONNECT requests which have no path. var path string if !isNormalConnect { path = req.URL.RequestURI() if !validPseudoPath(path) { orig := path path = strings.TrimPrefix(path, req.URL.Scheme+"://"+host) if !validPseudoPath(path) { if req.URL.Opaque != "" { return res, fmt.Errorf("invalid request :path %q from URL.Opaque = %q", orig, req.URL.Opaque) } else { return res, fmt.Errorf("invalid request :path %q", orig) } } } } // Check for any invalid headers+trailers and return an error before we // potentially pollute our hpack state. (We want to be able to // continue to reuse the hpack encoder for future requests) if err := validateHeaders(req.Header); err != "" { return res, fmt.Errorf("invalid HTTP header %s", err) } if err := validateHeaders(req.Trailer); err != "" { return res, fmt.Errorf("invalid HTTP trailer %s", err) } contentLength := ActualContentLength(req) trailers, err := commaSeparatedTrailers(req) if err != nil { return res, err } enumerateHeaders := func(f func(name, value string)) { // 8.1.2.3 Request Pseudo-Header Fields // The :path pseudo-header field includes the path and query parts of the // target URI (the path-absolute production and optionally a '?' character // followed by the query production, see Sections 3.3 and 3.4 of // [RFC3986]). f(":authority", host) m := req.Method if m == "" { m = http.MethodGet } f(":method", m) if !isNormalConnect { f(":path", path) f(":scheme", req.URL.Scheme) } if protocol != "" { f(":protocol", protocol) } if trailers != "" { f("trailer", trailers) } var didUA bool for k, vv := range req.Header { if asciiEqualFold(k, "host") || asciiEqualFold(k, "content-length") { // Host is :authority, already sent. // Content-Length is automatic, set below. continue } else if asciiEqualFold(k, "connection") || asciiEqualFold(k, "proxy-connection") || asciiEqualFold(k, "transfer-encoding") || asciiEqualFold(k, "upgrade") || asciiEqualFold(k, "keep-alive") { // Per 8.1.2.2 Connection-Specific Header // Fields, don't send connection-specific // fields. We have already checked if any // are error-worthy so just ignore the rest. continue } else if asciiEqualFold(k, "user-agent") { // Match Go's http1 behavior: at most one // User-Agent. If set to nil or empty string, // then omit it. Otherwise if not mentioned, // include the default (below). didUA = true if len(vv) < 1 { continue } vv = vv[:1] if vv[0] == "" { continue } } else if asciiEqualFold(k, "cookie") { // Per 8.1.2.5 To allow for better compression efficiency, the // Cookie header field MAY be split into separate header fields, // each with one or more cookie-pairs. for _, v := range vv { for { p := strings.IndexByte(v, ';') if p < 0 { break } f("cookie", v[:p]) p++ // strip space after semicolon if any. for p+1 <= len(v) && v[p] == ' ' { p++ } v = v[p:] } if len(v) > 0 { f("cookie", v) } } continue } else if k == ":protocol" { // :protocol pseudo-header was already sent above. continue } for _, v := range vv { f(k, v) } } if shouldSendReqContentLength(req.Method, contentLength) { f("content-length", strconv.FormatInt(contentLength, 10)) } if param.AddGzipHeader { f("accept-encoding", "gzip") } if !didUA { f("user-agent", param.DefaultUserAgent) } } // Do a first pass over the headers counting bytes to ensure // we don't exceed cc.peerMaxHeaderListSize. This is done as a // separate pass before encoding the headers to prevent // modifying the hpack state. if param.PeerMaxHeaderListSize > 0 { hlSize := uint64(0) enumerateHeaders(func(name, value string) { hf := hpack.HeaderField{Name: name, Value: value} hlSize += uint64(hf.Size()) }) if hlSize > param.PeerMaxHeaderListSize { return res, ErrRequestHeaderListSize } } trace := httptrace.ContextClientTrace(req.Context()) // Header list size is ok. Write the headers. enumerateHeaders(func(name, value string) { name, ascii := LowerHeader(name) if !ascii { // Skip writing invalid headers. Per RFC 7540, Section 8.1.2, header // field names have to be ASCII characters (just as in HTTP/1.x). return } headerf(name, value) if trace != nil && trace.WroteHeaderField != nil { trace.WroteHeaderField(name, []string{value}) } }) res.HasBody = contentLength != 0 res.HasTrailers = trailers != "" return res, nil } // IsRequestGzip reports whether we should add an Accept-Encoding: gzip header // for a request. func IsRequestGzip(req *http.Request, disableCompression bool) bool { // TODO(bradfitz): this is a copy of the logic in net/http. Unify somewhere? if !disableCompression && req.Header.Get("Accept-Encoding") == "" && req.Header.Get("Range") == "" && req.Method != "HEAD" { // Request gzip only, not deflate. Deflate is ambiguous and // not as universally supported anyway. // See: https://zlib.net/zlib_faq.html#faq39 // // Note that we don't request this for HEAD requests, // due to a bug in nginx: // http://trac.nginx.org/nginx/ticket/358 // https://golang.org/issue/5522 // // We don't request gzip if the request is for a range, since // auto-decoding a portion of a gzipped document will just fail // anyway. See https://golang.org/issue/8923 return true } return false } // checkConnHeaders checks whether req has any invalid connection-level headers. // // https://www.rfc-editor.org/rfc/rfc9114.html#section-4.2-3 // https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.2-1 // // Certain headers are special-cased as okay but not transmitted later. // For example, we allow "Transfer-Encoding: chunked", but drop the header when encoding. func checkConnHeaders(req *http.Request) error { if v := req.Header.Get("Upgrade"); v != "" { return fmt.Errorf("invalid Upgrade request header: %q", req.Header["Upgrade"]) } if vv := req.Header["Transfer-Encoding"]; len(vv) > 0 && (len(vv) > 1 || vv[0] != "" && vv[0] != "chunked") { return fmt.Errorf("invalid Transfer-Encoding request header: %q", vv) } if vv := req.Header["Connection"]; len(vv) > 0 && (len(vv) > 1 || vv[0] != "" && !asciiEqualFold(vv[0], "close") && !asciiEqualFold(vv[0], "keep-alive")) { return fmt.Errorf("invalid Connection request header: %q", vv) } return nil } func commaSeparatedTrailers(req *http.Request) (string, error) { keys := make([]string, 0, len(req.Trailer)) for k := range req.Trailer { k = CanonicalHeader(k) switch k { case "Transfer-Encoding", "Trailer", "Content-Length": return "", fmt.Errorf("invalid Trailer key %q", k) } keys = append(keys, k) } if len(keys) > 0 { sort.Strings(keys) return strings.Join(keys, ","), nil } return "", nil } // ActualContentLength returns a sanitized version of // req.ContentLength, where 0 actually means zero (not unknown) and -1 // means unknown. func ActualContentLength(req *http.Request) int64 { if req.Body == nil || req.Body == http.NoBody { return 0 } if req.ContentLength != 0 { return req.ContentLength } return -1 } // validPseudoPath reports whether v is a valid :path pseudo-header // value. It must be either: // // - a non-empty string starting with '/' // - the string '*', for OPTIONS requests. // // For now this is only used a quick check for deciding when to clean // up Opaque URLs before sending requests from the Transport. // See golang.org/issue/16847 // // We used to enforce that the path also didn't start with "//", but // Google's GFE accepts such paths and Chrome sends them, so ignore // that part of the spec. See golang.org/issue/19103. func validPseudoPath(v string) bool { return (len(v) > 0 && v[0] == '/') || v == "*" } func validateHeaders(hdrs http.Header) string { for k, vv := range hdrs { if !httpguts.ValidHeaderFieldName(k) && k != ":protocol" { return fmt.Sprintf("name %q", k) } for _, v := range vv { if !httpguts.ValidHeaderFieldValue(v) { // Don't include the value in the error, // because it may be sensitive. return fmt.Sprintf("value for header %q", k) } } } return "" } // shouldSendReqContentLength reports whether we should send // a "content-length" request header. This logic is basically a copy of the net/http // transferWriter.shouldSendContentLength. // The contentLength is the corrected contentLength (so 0 means actually 0, not unknown). // -1 means unknown. func shouldSendReqContentLength(method string, contentLength int64) bool { if contentLength > 0 { return true } if contentLength < 0 { return false } // For zero bodies, whether we send a content-length depends on the method. // It also kinda doesn't matter for http2 either way, with END_STREAM. switch method { case "POST", "PUT", "PATCH": return true default: return false } }