fix: handle brotli HTML proxy injection (#878)
* fix: handle brotli HTML proxy injection * Run go mod tidy
This commit is contained in:
@@ -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,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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user