From 094a4aa7239abbf894fd69998f6be77e0fbc42e9 Mon Sep 17 00:00:00 2001 From: xiantang Date: Sat, 24 Jan 2026 00:36:59 +0800 Subject: [PATCH] fix: handle brotli HTML proxy injection (#878) * fix: handle brotli HTML proxy injection * Run go mod tidy --- go.mod | 1 + go.sum | 4 +++ runner/proxy.go | 62 ++++++++++++++++++++++++--------------- runner/proxy_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index b54bf3c..3e9b5f9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25 require ( dario.cat/mergo v1.0.2 + github.com/andybalholm/brotli v1.2.0 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gohugoio/hugo v0.149.1 diff --git a/go.sum b/go.sum index 1faf00d..4454c07 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RX github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -172,6 +174,8 @@ github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZB github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= diff --git a/runner/proxy.go b/runner/proxy.go index 308c25b..3c2ab4c 100644 --- a/runner/proxy.go +++ b/runner/proxy.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/andybalholm/brotli" ) var ( @@ -30,6 +32,15 @@ type Streamer interface { Stop() } +// contentEncoding represents the type of content encoding used in HTTP responses. +type contentEncoding int + +const ( + encodingNone contentEncoding = iota + encodingGzip + encodingBrotli +) + type Proxy struct { server *http.Server client *http.Client @@ -71,32 +82,37 @@ func (p *Proxy) BuildFailed(msg BuildFailedMsg) { } func (p *Proxy) injectLiveReload(resp *http.Response) (string, bool, error) { - reader := resp.Body - decodedGzip := false - if isGzipEncoded(resp.Header) { + var reader io.Reader = resp.Body + decoded := false + + switch detectContentEncoding(resp.Header) { + case encodingGzip: gzipReader, err := gzip.NewReader(resp.Body) if err != nil { return "", false, fmt.Errorf("proxy inject: failed to init gzip reader: %w", err) } defer gzipReader.Close() reader = gzipReader - decodedGzip = true + decoded = true + case encodingBrotli: + reader = brotli.NewReader(resp.Body) + decoded = true } buf := new(bytes.Buffer) if _, err := buf.ReadFrom(reader); err != nil { - return "", decodedGzip, fmt.Errorf("proxy inject: failed to read body from http response: %w", err) + return "", decoded, fmt.Errorf("proxy inject: failed to read body from http response: %w", err) } page := buf.String() // the script will be injected before the end of the body tag. In case the tag is missing, the injection will be skipped with no error. body := strings.LastIndex(page, "") if body == -1 { - return page, decodedGzip, nil + return page, decoded, nil } script := "" - return page[:body] + script + page[body:], decodedGzip, nil + return page[:body] + script + page[body:], decoded, nil } func (p *Proxy) proxyHandler(w http.ResponseWriter, r *http.Request) { @@ -207,12 +223,12 @@ func (p *Proxy) proxyHandler(w http.ResponseWriter, r *http.Request) { } } else { // HTML: inject live reload script - page, decodedGzip, err := p.injectLiveReload(resp) + page, decoded, err := p.injectLiveReload(resp) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if decodedGzip { + if decoded { w.Header().Del("Content-Encoding") } w.Header().Set("Content-Length", strconv.Itoa((len([]byte(page))))) @@ -224,25 +240,25 @@ func (p *Proxy) proxyHandler(w http.ResponseWriter, r *http.Request) { } } -func isGzipEncoded(header http.Header) bool { +// detectContentEncoding determines the content encoding type from HTTP headers. +// Returns encodingNone for unsupported or multiple encodings (e.g., "gzip, br"). +func detectContentEncoding(header http.Header) contentEncoding { encoding := header.Get("Content-Encoding") if encoding == "" { - return false + return encodingNone } - hasGzip := false - for _, value := range strings.Split(encoding, ",") { - trimmed := strings.TrimSpace(strings.ToLower(value)) - if trimmed == "" { - continue - } - if trimmed != "gzip" && trimmed != "x-gzip" { - return false - } - hasGzip = true + // Only support single encoding; multiple encodings (e.g., "gzip, br") are rare + // and complex to handle, so we skip injection in those cases. + trimmed := strings.TrimSpace(strings.ToLower(encoding)) + switch trimmed { + case "gzip", "x-gzip": + return encodingGzip + case "br": + return encodingBrotli + default: + return encodingNone } - - return hasGzip } func (p *Proxy) reloadHandler(w http.ResponseWriter, r *http.Request) { diff --git a/runner/proxy_test.go b/runner/proxy_test.go index bb4824a..3853bfb 100644 --- a/runner/proxy_test.go +++ b/runner/proxy_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/andybalholm/brotli" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -335,6 +336,74 @@ func TestProxy_proxyHandler_GzipHTML(t *testing.T) { assert.Contains(t, string(responseBody), ProxyScript) } +func TestProxy_proxyHandler_BrotliHTML(t *testing.T) { + body := "

brotli

" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Encoding", "br") + brotliWriter := brotli.NewWriter(w) + if _, err := io.WriteString(brotliWriter, body); err != nil { + t.Errorf("write brotli body: %v", err) + } + if err := brotliWriter.Close(); err != nil { + t.Errorf("close brotli writer: %v", err) + } + })) + defer srv.Close() + + srvPort := getServerPort(t, srv) + proxy := NewProxy(&cfgProxy{ + Enabled: true, + ProxyPort: proxyPort, + AppPort: srvPort, + }) + + req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d/", proxyPort), nil) + req.Header.Set("Accept-Encoding", "br") + rec := httptest.NewRecorder() + proxy.proxyHandler(rec, req) + + resp := rec.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Empty(t, resp.Header.Get("Content-Encoding")) + + responseBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(responseBody), ProxyScript) +} + +func TestDetectContentEncoding(t *testing.T) { + tests := []struct { + name string + encoding string + expected contentEncoding + }{ + {name: "empty", encoding: "", expected: encodingNone}, + {name: "gzip", encoding: "gzip", expected: encodingGzip}, + {name: "x-gzip", encoding: "x-gzip", expected: encodingGzip}, + {name: "gzip_uppercase", encoding: "GZIP", expected: encodingGzip}, + {name: "brotli", encoding: "br", expected: encodingBrotli}, + {name: "brotli_uppercase", encoding: "BR", expected: encodingBrotli}, + {name: "deflate_unsupported", encoding: "deflate", expected: encodingNone}, + {name: "multiple_encodings", encoding: "gzip, br", expected: encodingNone}, + {name: "unknown", encoding: "unknown", expected: encodingNone}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + if tt.encoding != "" { + header.Set("Content-Encoding", tt.encoding) + } + result := detectContentEncoding(header) + assert.Equal(t, tt.expected, result) + }) + } +} + func TestProxy_proxyHandler_SSE(t *testing.T) { events := []string{ "event: message\ndata: {\"id\":1}\n\n",