Search code examples
goreverse-proxyhttp2

Golang H2C Server doesn't write full body. Chrome says: "Server reset stream". Only occurs over http2 connection


Most Recent Version of Go (1.153)

Below is the code for reproducibility. Please try to access https://easy-dp.ngrok.io to see the issue.

Here's what I did:

  1. Create a Reverse Proxy accessing Gzipped/ Br encoded Content
  2. Request a publicly available URL, I just grabbed Google Analytics
  3. Attempt to encode and decode the response via an http2 connection with a proxy.modifyresponse function
  4. Watch as content is dropped.

However, this only occurs under the following conditions:

  • Under SSL, like with https://easy-dp.ngrok.io
  • When running a proxy.ModifyResponse function
  • Decompressing and re-compressing the body (for example, just reading and rewriting the body to new bytes works)
package main

import (
    "bytes"
    "compress/gzip"
    "fmt"
    "golang.org/x/net/http2"
    "golang.org/x/net/http2/h2c"
    "io/ioutil"
    "net/http"
    "net/http/httputil"
    "strconv"
    "time"
)

func ForwardAnalytics(req *http.Request) {
    req.URL.Scheme = "https"
    req.URL.Host = "www.google-analytics.com"
    req.Host = "www.google-analytics.com"
    req.URL.Path = "/analytics.js"
    req.Header.Set("Accept-Encoding", "gzip")
}

func ModifyAnalytics(r *http.Response) error {
    bytesFromBody, err := ioutil.ReadAll(r.Body)
    defer r.Body.Close()
    if err != nil {
        return nil
    }
    if r.Header.Get("Content-Encoding") == "gzip" {
        gzipReader, err := gzip.NewReader(bytes.NewBuffer(bytesFromBody))
        if err != nil {
            return nil
        }
        defer gzipReader.Close()
        readableBytes, err := ioutil.ReadAll(gzipReader)
        var b bytes.Buffer
        gzipWriter, err := gzip.NewWriterLevel(&b, gzip.DefaultCompression)
        if err != nil {
            return nil
        }
        defer gzipWriter.Close()
        writtenLen, err := gzipWriter.Write(readableBytes)
        fmt.Println("Wrote ", writtenLen)
        if err != nil {
            return nil
        }
        r.ContentLength = int64(len(readableBytes))
        r.Header.Set("Content-Length", strconv.FormatInt(int64(len(readableBytes)), 10))
        r.Body = ioutil.NopCloser(&b)

        return nil
    } else {
        return nil
    }
}


func handleProxy(w http.ResponseWriter, req *http.Request) {
    proxy := httputil.ReverseProxy{
        Director: ForwardAnalytics
    }
    proxy.ModifyResponse = ModifyAnalytics
    proxy.ServeHTTP(w, req)

}

func main() {
    h2s := &http2.Server{
        IdleTimeout: 20 * time.Second,
    }
    mux := http.NewServeMux()
    mux.HandleFunc( "/", handleProxy)
    s := &http.Server{
        ReadHeaderTimeout: 20 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      30 * time.Second,
        Addr:              "localhost:8456",
        Handler:           h2c.NewHandler(mux, h2s),
    }
    s.ListenAndServe()
}

What did you expect to see?

I expect to see the ability to open the bytes, modify them, and update the response body on an H2C connection

What did you see instead?

Two things of note happen:

  1. Chrome gives a nice little error that expands upon what's going on
{"params":{"description":"Server reset stream.","net_error":"ERR_HTTP2_PROTOCOL_ERROR","stream_id":5},"phase":0,"source":{"id":1493828,"start_time":"732370299","type":1},"time":"732375561","type":224},
  1. Under the normal http connection, there's no problem, but under the https connection the script may or may not print out to a certain length. Sometimes it doesn't print at all, sometimes it prints about 30%.

This is a cross browser issue.


Solution

  • The Content-Length header indicates the size of the entity body in the message, in bytes. The size includes any content encodings (the Content-Length of a gzip-compressed text file will be the compressed size, not the original size).

    src

    I thought I had tried this but kept running into ERR_CONTENT_LENGTH_MISMATCH because of how I was closing my gzip writer. Related Question

    Final handler looked like this:

        if r.Header.Get("Content-Encoding") == "gzip" {
            gzipReader, err := gzip.NewReader(bytes.NewBuffer(bytesFromBody))
            if err != nil {
                return nil
            }
            defer gzipReader.Close()
            readableBytes, err := ioutil.ReadAll(gzipReader)
            var b bytes.Buffer
            gzipWriter, err := gzip.NewWriterLevel(&b, gzip.DefaultCompression)
            if err != nil {
                return nil
            }
            writtenLen, err := gzipWriter.Write(readableBytes)
            gzipWriter.Close() // This was the culprit. It needed to be closed here
            fmt.Println("Wrote ", writtenLen)
            if err != nil {
                return nil
            }
            r.ContentLength = int64(b.Len())
            r.Header.Set("Content-Length", strconv.FormatInt(int64(b.Len()), 10))
            r.Body = ioutil.NopCloser(&b)
            return nil
        }