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