Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ environment variables that you can set.
| `EAB_HMAC_KEY` | The Base64-encoded EAB HMAC key to use when provisioning TLS certificates, if required. | None |
| `FORWARD_HEADERS` | Whether to forward X-Forwarded-* and X-Request-ID headers from the client. | Disabled when running with TLS; enabled otherwise |
| `LOG_REQUESTS` | Log all requests. Set to `0` or `false` to disable request logging | Enabled |
| `ENCODE_QUERY_SEMICOLONS` | Set to `1` or `true` to encode raw `;` in query strings as `%3B`, so they're passed upstream as part of the value. Otherwise Go drops any query parameter containing a raw `;`. | Disabled |
| `DEBUG` | Set to `1` or `true` to enable debug logging. | Disabled |

To prevent naming clashes with your application's own environment variables,
Expand Down
4 changes: 4 additions & 0 deletions internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ type Config struct {

ForwardHeaders bool

EncodeQuerySemicolons bool

LogLevel slog.Level
LogRequests bool
}
Expand Down Expand Up @@ -114,6 +116,8 @@ func NewConfig() (*Config, error) {

H2CEnabled: getEnvBool("H2C_ENABLED", defaultH2CEnabled),

EncodeQuerySemicolons: getEnvBool("ENCODE_QUERY_SEMICOLONS", false),

LogLevel: logLevel,
LogRequests: getEnvBool("LOG_REQUESTS", defaultLogRequests),
}
Expand Down
3 changes: 3 additions & 0 deletions internal/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ func TestConfig_defaults(t *testing.T) {
assert.Equal(t, defaultCacheSize, c.CacheSizeBytes)
assert.Equal(t, slog.LevelInfo, c.LogLevel)
assert.Equal(t, false, c.H2CEnabled)
assert.Equal(t, false, c.EncodeQuerySemicolons)
}

func TestConfig_override_defaults_with_env_vars(t *testing.T) {
Expand All @@ -121,6 +122,7 @@ func TestConfig_override_defaults_with_env_vars(t *testing.T) {
usingEnvVar(t, "H2C_ENABLED", "true")
usingEnvVar(t, "GZIP_COMPRESSION_DISABLE_ON_AUTH", "true")
usingEnvVar(t, "GZIP_COMPRESSION_JITTER", "64")
usingEnvVar(t, "ENCODE_QUERY_SEMICOLONS", "true")

c, err := NewConfig()
require.NoError(t, err)
Expand All @@ -136,6 +138,7 @@ func TestConfig_override_defaults_with_env_vars(t *testing.T) {
assert.Equal(t, true, c.H2CEnabled)
assert.Equal(t, true, c.GzipCompressionDisableOnAuth)
assert.Equal(t, 64, c.GzipCompressionJitter)
assert.Equal(t, true, c.EncodeQuerySemicolons)
}

func TestConfig_override_defaults_with_env_vars_using_prefix(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions internal/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ type HandlerOptions struct {
gzipCompressionJitter int
forwardHeaders bool
logRequests bool
encodeQuerySemicolons bool
}

func NewHandler(options HandlerOptions) http.Handler {
handler := NewProxyHandler(options.targetUrl, options.badGatewayPage, options.forwardHeaders)
handler = NewCacheHandler(options.cache, options.maxCacheableResponseBody, handler)
handler = NewQuerySemicolonHandler(options.encodeQuerySemicolons, handler)
handler = NewSendfileHandler(options.xSendfileEnabled, handler)
handler = NewRequestStartHandler(handler)
handler = NewRequestIDHandler(options.forwardHeaders, handler)
Expand Down
28 changes: 28 additions & 0 deletions internal/query_semicolon_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package internal

import (
"net/http"
"strings"
)

// Go drops query pairs containing a raw ";", so encode it to keep it as part of the value.
func NewQuerySemicolonHandler(enabled bool, next http.Handler) http.Handler {
if !enabled {
return next
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.RawQuery, ";") {
next.ServeHTTP(w, r)
return
}

r2 := new(http.Request)
*r2 = *r
u := *r.URL
u.RawQuery = strings.ReplaceAll(r.URL.RawQuery, ";", "%3B")
r2.URL = &u

next.ServeHTTP(w, r2)
})
}
86 changes: 86 additions & 0 deletions internal/query_semicolon_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package internal

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestQuerySemicolonHandler_encodes_semicolons_when_enabled(t *testing.T) {
tests := map[string]string{
"/?a=1;2&b=3": "a=1%3B2&b=3",
"/?a=1%3B2&b=3": "a=1%3B2&b=3",
"/?a=1%3b2&b=3": "a=1%3b2&b=3",
"/?a=1;;2": "a=1%3B%3B2",
}

for target, expected := range tests {
t.Run(target, func(t *testing.T) {
var rawQuery, value string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawQuery = r.URL.RawQuery
value = r.URL.Query().Get("a")
}))
defer upstream.Close()

options := handlerOptions(upstream.URL)
options.encodeQuerySemicolons = true
h := NewHandler(options)

w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", target, nil))

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, expected, rawQuery)
assert.Contains(t, value, ";")
})
}
}

func TestQuerySemicolonHandler_drops_semicolon_pairs_when_disabled(t *testing.T) {
var rawQuery string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawQuery = r.URL.RawQuery
}))
defer upstream.Close()

h := NewHandler(handlerOptions(upstream.URL))

w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/?a=1;2&b=3", nil))

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "b=3", rawQuery)
}

func TestQuerySemicolonHandler_keeps_cache_keys_distinct(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=60")
_, _ = w.Write([]byte(r.URL.Query().Get("a")))
}))
defer upstream.Close()

options := handlerOptions(upstream.URL)
options.encodeQuerySemicolons = true
h := NewHandler(options)

serveRequest := func(target string) (string, string) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", target, nil))
return w.Header().Get("X-Cache"), w.Body.String()
}

cache, body := serveRequest("/?a=1;2")
assert.Equal(t, "miss", cache)
assert.Equal(t, "1;2", body)

cache, body = serveRequest("/")
assert.Equal(t, "miss", cache)
assert.Equal(t, "", body)

cache, body = serveRequest("/?a=1%3B2")
assert.Equal(t, "hit", cache)
assert.Equal(t, "1;2", body)
}
1 change: 1 addition & 0 deletions internal/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func (s *Service) Run() int {
logRequests: s.config.LogRequests,
gzipCompressionDisableOnAuth: s.config.GzipCompressionDisableOnAuth,
gzipCompressionJitter: s.config.GzipCompressionJitter,
encodeQuerySemicolons: s.config.EncodeQuerySemicolons,
}

handler := NewHandler(handlerOptions)
Expand Down