fix: handle brotli HTML proxy injection (#878)

* fix: handle brotli HTML proxy injection

* Run go mod tidy
This commit is contained in:
xiantang
2026-01-24 00:36:59 +08:00
committed by GitHub
parent f16b3fa5f9
commit 094a4aa723
4 changed files with 113 additions and 23 deletions
+1
View File
@@ -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
+4
View File
@@ -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=
+39 -23
View File
@@ -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, "</body>")
if body == -1 {
return page, decodedGzip, nil
return page, decoded, nil
}
script := "<script>" + ProxyScript + "</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) {
+69
View File
@@ -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 := "<body><h1>brotli</h1></body>"
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",