Skip to content

Commit fc41009

Browse files
authored
Merge commit from fork
* fix: trust forwarded scheme headers only from trusted proxies Context.Scheme() used X-Forwarded-Proto, X-Forwarded-Protocol, X-Forwarded-Ssl and X-Url-Scheme from any client, so a direct client could send X-Forwarded-Proto: https over plain HTTP and bypass middleware.HTTPSRedirect() (GHSA-2ffq-g2xg-c22p). The headers are now used only when the request comes directly from a loopback, link-local or private network address or a unix socket. Echo#SchemeExtractor (and Config.SchemeExtractor) selects the strategy: ExtractSchemeFromHeaders (default, accepts TrustOption), ExtractSchemeDirect, or LegacySchemeExtractor for the old behavior. The Secure middleware now uses Context.Scheme() for HSTS, and the Proxy middleware always sets X-Forwarded-Proto from Context.Scheme() instead of forwarding a client-supplied value. * fix(proxy): always set X-Real-IP from Context.RealIP() The Proxy middleware forwarded a client-supplied X-Real-IP header to the upstream when Echo#IPExtractor was not set, so any client could spoof the IP address seen by upstream services (GHSA-99jh-6h7p-pp36). Since v5.1.0, Context.RealIP() returns the address of the direct peer unless Echo#IPExtractor is configured, so the header is now always set from Context.RealIP(). * fix: validate JSONP callback and send nosniff header Context.JSONP and Context.JSONPBlob wrote the callback into the response unchanged, so a callback taken from a query parameter could inject arbitrary JavaScript (GHSA-h9g5-28mm-hx3g). The callback must now be empty, a JavaScript identifier or a dot-separated path of identifiers (ASCII letters, digits, _ and $). Otherwise nothing is written and a 400 Bad Request error wrapping ErrInvalidJSONPCallback is returned. JSONP responses also get the X-Content-Type-Options: nosniff header. Claude-Session: https://claude.ai/code/session_01QKDYQr53zNKkR7nif2CAAq * fix(method-override): do not override POST to safe methods MethodOverride rewrote a POST to any method, including GET and HEAD. When the middleware ran before CSRF, a cross-site form POST with _method=GET skipped the CSRF check (GHSA-r7w9-592q-9vg4). POST can no longer be overridden to GET, HEAD, OPTIONS, TRACE or CONNECT. The documentation now also says to register the middleware with Echo#Pre. Claude-Session: https://claude.ai/code/session_01QKDYQr53zNKkR7nif2CAAq * fix: percent-encode control characters in redirect paths The trailing slash middlewares and the static directory redirect only collapsed a leading double slash. Browsers remove tab and newline characters from URLs, so a path such as /%09/evil.example/ produced Location: /\t/evil.example/, which a browser follows to evil.example (GHSA-v753-g4cw-jm48). sanitizeURI now percent-encodes C0 control characters and DEL before the double slash check, so the browser requests the same path. Claude-Session: https://claude.ai/code/session_01QKDYQr53zNKkR7nif2CAAq * fix(static): resolve files from the routed path and reject dot segments The Static middleware resolved files from the decoded URL.Path while the router matched the escaped URL.RawPath, so /admin%2Fsecret.txt or /%61dmin/secret.txt reached a file under a guarded /admin/* route (GHSA-375p-5qhx-8wq4). It now uses the same form of the path as the router and unescapes it only when EnablePathUnescaping is set. Both the Static middleware and StaticDirectoryHandler (Echo.Static, Echo.StaticFS) resolved file names with path.Clean(), while the router matched the path as sent. A path with a ".", ".." or empty segment, such as /assets/../admin/secret.txt, could therefore reach a file under a route the router never matched (GHSA-3pmx-cf9f-34xr). Such paths are no longer served. The documented opt-in behavior of encoded dots with path unescaping enabled is unchanged. Claude-Session: https://claude.ai/code/session_01QKDYQr53zNKkR7nif2CAAq * docs: changelog for security release and Static middleware guard note Claude-Session: https://claude.ai/code/session_01QKDYQr53zNKkR7nif2CAAq * fix: harden security fixes after review - Scheme: when X-Forwarded-Proto is present, use only it (its last value, added by the nearest proxy) instead of falling back to other headers, and return the scheme in lowercase so HTTPSRedirect and HSTS work with "HTTPS". - Proxy: remove X-Forwarded-Ssl, X-Forwarded-Protocol and X-Url-Scheme before forwarding; X-Forwarded-Proto carries the scheme. - Static: check for dot segments again after path unescaping, keep the HTML5 index fallback for unclean paths, check the path only once, and document how EnablePathUnescaping affects non-default escaping. - Tests: guardable route-level static test, group static test, and a control-character redirect test that does not rely on traversal. - Docs: IPExtractor default, SchemeExtractor trust notes, changelog behavior changes; bump Version to 5.4.0. Claude-Session: https://claude.ai/code/session_01QKDYQr53zNKkR7nif2CAAq * docs: set v5.4.0 release date Claude-Session: https://claude.ai/code/session_01QKDYQr53zNKkR7nif2CAAq
1 parent e804f42 commit fc41009

22 files changed

Lines changed: 1333 additions & 84 deletions

‎CHANGELOG.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,41 @@
11
# Changelog
22

33

4+
## v5.4.0 - 2026-09-27
5+
6+
**Security**
7+
8+
This release fixes several security issues. Upgrading is recommended. Some fixes change behavior; read "Behavior changes to check before upgrading" below.
9+
10+
* **Request scheme:** `Context.Scheme()` now uses the `X-Forwarded-Proto`, `X-Forwarded-Protocol`, `X-Forwarded-Ssl` and `X-Url-Scheme` headers only when the request comes directly from a loopback, link-local or private network address or a unix socket. Before this, any client could send `X-Forwarded-Proto: https` over plain HTTP and skip `HTTPSRedirect`. When `X-Forwarded-Proto` is present, only it is used (its last value), and the scheme is returned in lowercase. `Echo#SchemeExtractor` (and `Config.SchemeExtractor`) selects the strategy: `ExtractSchemeFromHeaders(...TrustOption)` (default), `ExtractSchemeDirect()` or `LegacySchemeExtractor()`. The Secure middleware now sets HSTS based on `Context.Scheme()`. The Proxy middleware always sets `X-Forwarded-Proto` from `Context.Scheme()` and removes `X-Forwarded-Ssl`, `X-Forwarded-Protocol` and `X-Url-Scheme` before forwarding. [GHSA-2ffq-g2xg-c22p](https://github.com/labstack/echo/security/advisories/GHSA-2ffq-g2xg-c22p)
11+
* **Proxy middleware:** always sets `X-Real-IP` from `Context.RealIP()`, so a client can no longer pass a spoofed `X-Real-IP` to the upstream. [GHSA-99jh-6h7p-pp36](https://github.com/labstack/echo/security/advisories/GHSA-99jh-6h7p-pp36)
12+
* **JSONP:** `Context.JSONP` and `Context.JSONPBlob` accept only a callback that is empty, a JavaScript identifier or a dot-separated path of identifiers (ASCII letters, digits, `_` and `$`). Any other callback returns a 400 Bad Request error that wraps the new `ErrInvalidJSONPCallback`, and nothing is written. JSONP responses now carry `X-Content-Type-Options: nosniff`. JSONP lets any website read the response with the user's cookies, so do not use it for data that needs authentication. [GHSA-h9g5-28mm-hx3g](https://github.com/labstack/echo/security/advisories/GHSA-h9g5-28mm-hx3g)
13+
* **MethodOverride:** a POST can no longer be overridden to `GET`, `HEAD`, `OPTIONS`, `TRACE` or `CONNECT`. Before this, with the `MethodFromForm` or `MethodFromQuery` getter and MethodOverride registered with `Use` before the CSRF middleware, `_method=GET` skipped the CSRF check. Register MethodOverride with `Echo#Pre`. [GHSA-r7w9-592q-9vg4](https://github.com/labstack/echo/security/advisories/GHSA-r7w9-592q-9vg4)
14+
* **Redirects:** the trailing slash middlewares and the static directory redirect percent-encode control characters in the redirect path. Before this, `/%09/evil.example/` redirected browsers to `evil.example`. [GHSA-v753-g4cw-jm48](https://github.com/labstack/echo/security/advisories/GHSA-v753-g4cw-jm48)
15+
* **Static files:** with the default settings, the Static middleware resolves files from the same form of the path that the router matched, so `/admin%2Fsecret.txt` or `/%61dmin/secret.txt` can no longer reach a file under a guarded `/admin/*` route. [GHSA-375p-5qhx-8wq4](https://github.com/labstack/echo/security/advisories/GHSA-375p-5qhx-8wq4) The Static middleware and `StaticDirectoryHandler` (used by `Echo.Static`, `Echo.StaticFS`, `Group.Static` and `Group.StaticFS`) no longer serve paths with a `.`, `..` or empty segment, such as `/assets/../admin/secret.txt`, also after path unescaping. [GHSA-3pmx-cf9f-34xr](https://github.com/labstack/echo/security/advisories/GHSA-3pmx-cf9f-34xr)
16+
17+
**Behavior changes to check before upgrading**
18+
19+
* **Proxies or load balancers with public IP addresses.** If a proxy connects to your app from a public (or `100.64.0.0/10`) address, its `X-Forwarded-Proto` is now ignored: `HTTPSRedirect` redirects in a loop and the Secure middleware stops sending HSTS. This affects, for example, Cloudflare, CloudFront and Azure Front Door connecting to a public origin, the GCP external HTTP(S) load balancer including GKE Ingress (`35.191.0.0/16`, `130.211.0.0/22`), and networks that use `100.64.0.0/10` (such as Alibaba Cloud SLB or EKS custom networking). Trust the proxy's address ranges:
20+
```go
21+
_, gclb1, _ := net.ParseCIDR("35.191.0.0/16")
22+
_, gclb2, _ := net.ParseCIDR("130.211.0.0/22")
23+
e.SchemeExtractor = echo.ExtractSchemeFromHeaders(echo.TrustIPRange(gclb1), echo.TrustIPRange(gclb2))
24+
```
25+
Proxies on the same host, in a private network (AWS ALB, in-cluster ingress controllers such as ingress-nginx or Traefik, most PaaS routers) or on a unix socket keep working without changes. `echo.LegacySchemeExtractor()` restores the old behavior but is not safe unless every request passes through a proxy that sets these headers. Serverless adapters or middleware that set `RemoteAddr` to the client's address also make `X-Forwarded-Proto` ignored (or, if they take it from a header, spoofable).
26+
* **Trusted proxies must set `X-Forwarded-Proto`.** A proxy on a trusted address that passes the client's `X-Forwarded-Proto` through (for example nginx without `proxy_set_header X-Forwarded-Proto $scheme;`) still lets the client choose the scheme. An invalid `X-Forwarded-Proto` value now results in `http` instead of falling back to the other scheme headers.
27+
* **Your own tests.** `httptest.NewRequest` sets `RemoteAddr` to `192.0.2.1:1234`, which is not trusted, so tests that set `X-Forwarded-Proto` now see `http`. Set `req.RemoteAddr = "10.0.0.1:1234"` or use `e.SchemeExtractor = echo.LegacySchemeExtractor()` in such tests.
28+
* **Proxy middleware headers.** `X-Real-IP` sent to the upstream is now always `Context.RealIP()`. In a chain like nginx → Echo Proxy → upstream, configure `Echo#IPExtractor` (for example `echo.ExtractIPFromRealIPHeader()`) to pass the client address on. `X-Forwarded-Ssl`, `X-Forwarded-Protocol` and `X-Url-Scheme` are no longer forwarded; `X-Forwarded-Proto` carries the scheme.
29+
* **MethodOverride.** Overriding a POST to `GET` (for example with `X-HTTP-Method-Override: GET` to send a long query in a POST body) is no longer done; such requests keep the POST method.
30+
* **Static files.** Paths with a double slash or dot segment (for example `/assets//app.js`) now return 404; in HTML5 mode the index is still served. The Static middleware no longer finds file names that the client sends with non-default escaping (for example `%2C`, `%40` or lowercase hex like `%c3%a9`) unless `StaticConfig.EnablePathUnescaping` is set; `Echo.Static` has behaved this way since v5.2.1. With `StaticConfig.EnablePathUnescaping` or `Config.EnablePathUnescapingStaticFiles`, encoded dots (`%2e%2e`) no longer traverse directories, but encoded slashes are still decoded, so do not combine these options with route-based access control.
31+
* **JSONP.** `Context.JSONP` returns an error for callbacks that are not JavaScript identifiers.
32+
33+
**Documentation**
34+
35+
* `IPExtractor` docs: corrected the description of the default (the direct peer address has been used since v5.1.0).
36+
* Static middleware: when registered with `Echo#Use` it runs before route and group middleware, so route guards do not protect the files it serves.
37+
38+
439
## v5.3.1 - 2026-07-21
540

641
**Fixes**

‎context.go‎

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -195,38 +195,20 @@ func (c *Context) IsWebSocket() bool {
195195
return strings.EqualFold(upgrade, "websocket") && strings.Contains(strings.ToLower(connection), "upgrade")
196196
}
197197

198-
func isValidProto(proto string) bool {
199-
if proto == "" {
200-
return false
201-
}
202-
for _, p := range []string{"http", "https", "ws", "wss"} {
203-
if strings.EqualFold(proto, p) {
204-
return true
205-
}
206-
}
207-
return false
208-
}
209-
210-
// Scheme returns the HTTP protocol scheme, `http` or `https`.
198+
// Scheme returns the protocol scheme in lowercase: `http` or `https`, or `ws` or `wss` when a trusted proxy reports
199+
// them.
200+
//
201+
// Echo#SchemeExtractor decides how the scheme is determined. When it is not set, the forwarding headers
202+
// (`X-Forwarded-Proto`, `X-Forwarded-Protocol`, `X-Forwarded-Ssl` and `X-Url-Scheme`) are used only for requests
203+
// that come directly from a loopback, link-local or private network address or a unix socket.
204+
// See ExtractSchemeFromHeaders.
211205
func (c *Context) Scheme() string {
212206
// Can't use `r.Request.URL.Scheme`
213207
// See: https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0
214-
if c.IsTLS() {
215-
return "https"
216-
}
217-
if scheme := c.request.Header.Get(HeaderXForwardedProto); isValidProto(scheme) {
218-
return scheme
208+
if c.echo != nil && c.echo.SchemeExtractor != nil {
209+
return c.echo.SchemeExtractor(c.request)
219210
}
220-
if scheme := c.request.Header.Get(HeaderXForwardedProtocol); isValidProto(scheme) {
221-
return scheme
222-
}
223-
if ssl := c.request.Header.Get(HeaderXForwardedSsl); ssl == "on" {
224-
return "https"
225-
}
226-
if scheme := c.request.Header.Get(HeaderXUrlScheme); isValidProto(scheme) {
227-
return scheme
228-
}
229-
return "http"
211+
return extractScheme(c.request, defaultSchemeChecker)
230212
}
231213

232214
// RealIP returns the client IP address using the configured extraction strategy.
@@ -524,9 +506,50 @@ func (c *Context) String(code int, s string) (err error) {
524506
return c.Blob(code, MIMETextPlainCharsetUTF8, stringToBytes(s))
525507
}
526508

527-
func (c *Context) jsonPBlob(code int, callback string, i any) (err error) {
509+
// isValidJSONPCallback reports whether callback can be used as a JSONP function name: an empty string, a JavaScript
510+
// identifier or a dot-separated path of identifiers (e.g. `cb`, `jQuery_123`, `ns.handlers.cb`). Only ASCII letters,
511+
// digits, `_` and `$` are allowed, so the callback cannot inject other JavaScript into the response.
512+
func isValidJSONPCallback(callback string) bool {
513+
if callback == "" {
514+
return true
515+
}
516+
atStart := true // at the start of an identifier
517+
for i := 0; i < len(callback); i++ {
518+
ch := callback[i]
519+
switch {
520+
case ch == '.':
521+
if atStart {
522+
return false
523+
}
524+
atStart = true
525+
case ch == '_' || ch == '$' || ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z'):
526+
atStart = false
527+
case '0' <= ch && ch <= '9':
528+
if atStart {
529+
return false
530+
}
531+
default:
532+
return false
533+
}
534+
}
535+
return !atStart
536+
}
537+
538+
// writeJSONPHeader validates the callback and writes the JSONP response headers.
539+
func (c *Context) writeJSONPHeader(code int, callback string) error {
540+
if !isValidJSONPCallback(callback) {
541+
return ErrBadRequest.Wrap(ErrInvalidJSONPCallback)
542+
}
528543
c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8)
544+
c.response.Header().Set(HeaderXContentTypeOptions, "nosniff")
529545
c.response.WriteHeader(code)
546+
return nil
547+
}
548+
549+
func (c *Context) jsonPBlob(code int, callback string, i any) (err error) {
550+
if err = c.writeJSONPHeader(code, callback); err != nil {
551+
return
552+
}
530553
if _, err = c.response.Write(stringToBytes(callback)); err != nil {
531554
return
532555
}
@@ -581,15 +604,23 @@ func (c *Context) JSONBlob(code int, b []byte) (err error) {
581604

582605
// JSONP sends a JSONP response with status code. It uses `callback` to construct
583606
// the JSONP payload.
607+
//
608+
// The callback must be empty, a JavaScript identifier or a dot-separated path of identifiers using only ASCII
609+
// letters, digits, `_` and `$`. Otherwise nothing is written and a 400 Bad Request error wrapping
610+
// ErrInvalidJSONPCallback is returned. The response has the `X-Content-Type-Options: nosniff` header.
611+
//
612+
// Any website can load a JSONP response with a <script> tag, including the user's cookies. Do not use JSONP for
613+
// data that requires authentication; use JSON with CORS instead.
584614
func (c *Context) JSONP(code int, callback string, i any) (err error) {
585615
return c.jsonPBlob(code, callback, i)
586616
}
587617

588618
// JSONPBlob sends a JSONP blob response with status code. It uses `callback`
589-
// to construct the JSONP payload.
619+
// to construct the JSONP payload. The callback is validated like in Context.JSONP.
590620
func (c *Context) JSONPBlob(code int, callback string, b []byte) (err error) {
591-
c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8)
592-
c.response.WriteHeader(code)
621+
if err = c.writeJSONPHeader(code, callback); err != nil {
622+
return
623+
}
593624
if _, err = c.response.Write(stringToBytes(callback)); err != nil {
594625
return
595626
}

‎context_test.go‎

Lines changed: 147 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,64 @@ func TestContextJSONWithNotEchoResponse(t *testing.T) {
260260
assert.Empty(t, rec.Body.String()) // body must not be sent to the client
261261
}
262262

263+
func TestContextJSONP_callbackValidation(t *testing.T) {
264+
var testCases = []struct {
265+
name string
266+
givenCallback string
267+
expectValid bool
268+
}{
269+
{name: "simple identifier", givenCallback: "callback", expectValid: true},
270+
{name: "jQuery style", givenCallback: "jQuery331_1700000000000", expectValid: true},
271+
{name: "dollar", givenCallback: "$", expectValid: true},
272+
{name: "underscore prefix", givenCallback: "_cb", expectValid: true},
273+
{name: "dotted path", givenCallback: "ns.handlers.cb", expectValid: true},
274+
{name: "empty", givenCallback: "", expectValid: true},
275+
{name: "function call injection", givenCallback: "alert(1)//", expectValid: false},
276+
{name: "statement injection", givenCallback: "cb;alert(1)", expectValid: false},
277+
{name: "html", givenCallback: "<script>", expectValid: false},
278+
{name: "space", givenCallback: "a b", expectValid: false},
279+
{name: "dash", givenCallback: "a-b", expectValid: false},
280+
{name: "leading digit", givenCallback: "1abc", expectValid: false},
281+
{name: "digit after dot", givenCallback: "a.1b", expectValid: false},
282+
{name: "leading dot", givenCallback: ".cb", expectValid: false},
283+
{name: "trailing dot", givenCallback: "cb.", expectValid: false},
284+
{name: "double dot", givenCallback: "a..b", expectValid: false},
285+
{name: "brackets", givenCallback: "a[0]", expectValid: false},
286+
{name: "newline", givenCallback: "cb\nalert(1)", expectValid: false},
287+
{name: "non-ASCII", givenCallback: "caf\u00e9", expectValid: false},
288+
}
289+
290+
for _, tc := range testCases {
291+
for _, method := range []string{"JSONP", "JSONPBlob"} {
292+
t.Run(method+" "+tc.name, func(t *testing.T) {
293+
e := New()
294+
rec := httptest.NewRecorder()
295+
req := httptest.NewRequest(http.MethodGet, "/", nil)
296+
c := e.NewContext(req, rec)
297+
298+
var err error
299+
if method == "JSONP" {
300+
err = c.JSONP(http.StatusOK, tc.givenCallback, user{ID: 1, Name: "Jon Snow"})
301+
} else {
302+
err = c.JSONPBlob(http.StatusOK, tc.givenCallback, []byte(userJSON))
303+
}
304+
305+
if tc.expectValid {
306+
assert.NoError(t, err)
307+
assert.Equal(t, http.StatusOK, rec.Code)
308+
assert.Equal(t, "nosniff", rec.Header().Get(HeaderXContentTypeOptions))
309+
assert.True(t, strings.HasPrefix(rec.Body.String(), tc.givenCallback+"("))
310+
return
311+
}
312+
assert.ErrorIs(t, err, ErrInvalidJSONPCallback)
313+
assert.Equal(t, http.StatusBadRequest, StatusCode(err))
314+
assert.Equal(t, 0, rec.Body.Len())
315+
assert.Equal(t, "", rec.Header().Get(HeaderContentType))
316+
})
317+
}
318+
}
319+
}
320+
263321
func TestContextJSONPretty(t *testing.T) {
264322
e := New()
265323
rec := httptest.NewRecorder()
@@ -1091,10 +1149,11 @@ func TestContext_Request(t *testing.T) {
10911149

10921150
func TestContext_Scheme(t *testing.T) {
10931151
var testCases = []struct {
1094-
name string
1095-
givenIsTLS bool
1096-
givenHeaders http.Header
1097-
expect string
1152+
name string
1153+
givenIsTLS bool
1154+
givenRemoteAddr string // defaults to a trusted reverse proxy in a private network
1155+
givenHeaders http.Header
1156+
expect string
10981157
}{
10991158
{
11001159
name: "defaults to http without TLS or headers",
@@ -1138,7 +1197,7 @@ func TestContext_Scheme(t *testing.T) {
11381197
givenHeaders: http.Header{
11391198
HeaderXForwardedProto: []string{"HTTPS"},
11401199
},
1141-
expect: "HTTPS",
1200+
expect: "https", // lowercase, so `Scheme() == "https"` checks (HTTPSRedirect, Secure) work
11421201
},
11431202
{
11441203
name: "uses X-Forwarded-Proto ws",
@@ -1157,13 +1216,35 @@ func TestContext_Scheme(t *testing.T) {
11571216
expect: "wss",
11581217
},
11591218
{
1160-
name: "ignores invalid X-Forwarded-Proto and uses X-Forwarded-Protocol",
1219+
name: "invalid X-Forwarded-Proto falls back to http, not to other headers",
11611220
givenIsTLS: false,
11621221
givenHeaders: http.Header{
11631222
HeaderXForwardedProto: []string{"ftp"},
11641223
HeaderXForwardedProtocol: []string{"https"},
11651224
},
1166-
expect: "https",
1225+
expect: "http",
1226+
},
1227+
{
1228+
name: "uses last value of comma separated X-Forwarded-Proto (added by the nearest proxy)",
1229+
givenHeaders: http.Header{
1230+
HeaderXForwardedProto: []string{"https, http"},
1231+
},
1232+
expect: "http",
1233+
},
1234+
{
1235+
name: "uses last X-Forwarded-Proto header line (added by the nearest proxy)",
1236+
givenHeaders: http.Header{
1237+
HeaderXForwardedProto: []string{"https", "http"},
1238+
},
1239+
expect: "http",
1240+
},
1241+
{
1242+
name: "X-Forwarded-Ssl can not override X-Forwarded-Proto list",
1243+
givenHeaders: http.Header{
1244+
HeaderXForwardedProto: []string{"http, http"},
1245+
HeaderXForwardedSsl: []string{"on"},
1246+
},
1247+
expect: "http",
11671248
},
11681249
{
11691250
name: "uses X-Forwarded-Protocol",
@@ -1244,11 +1325,70 @@ func TestContext_Scheme(t *testing.T) {
12441325
},
12451326
expect: "https",
12461327
},
1328+
{
1329+
name: "ignores X-Forwarded-Proto from untrusted client",
1330+
givenRemoteAddr: "203.0.113.10:1234",
1331+
givenHeaders: http.Header{HeaderXForwardedProto: []string{"https"}},
1332+
expect: "http",
1333+
},
1334+
{
1335+
name: "ignores X-Forwarded-Protocol from untrusted client",
1336+
givenRemoteAddr: "203.0.113.10:1234",
1337+
givenHeaders: http.Header{HeaderXForwardedProtocol: []string{"https"}},
1338+
expect: "http",
1339+
},
1340+
{
1341+
name: "ignores X-Forwarded-Ssl from untrusted client",
1342+
givenRemoteAddr: "203.0.113.10:1234",
1343+
givenHeaders: http.Header{HeaderXForwardedSsl: []string{"on"}},
1344+
expect: "http",
1345+
},
1346+
{
1347+
name: "ignores X-Url-Scheme from untrusted client",
1348+
givenRemoteAddr: "203.0.113.10:1234",
1349+
givenHeaders: http.Header{HeaderXUrlScheme: []string{"https"}},
1350+
expect: "http",
1351+
},
1352+
{
1353+
name: "returns https for TLS from untrusted client",
1354+
givenIsTLS: true,
1355+
givenRemoteAddr: "203.0.113.10:1234",
1356+
givenHeaders: http.Header{HeaderXForwardedProto: []string{"http"}},
1357+
expect: "https",
1358+
},
1359+
{
1360+
name: "trusts X-Forwarded-Proto from loopback",
1361+
givenRemoteAddr: "127.0.0.1:1234",
1362+
givenHeaders: http.Header{HeaderXForwardedProto: []string{"https"}},
1363+
expect: "https",
1364+
},
1365+
{
1366+
name: "trusts X-Forwarded-Proto from IPv6 loopback",
1367+
givenRemoteAddr: "[::1]:1234",
1368+
givenHeaders: http.Header{HeaderXForwardedProto: []string{"https"}},
1369+
expect: "https",
1370+
},
1371+
{
1372+
name: "trusts X-Forwarded-Proto from unix socket peer",
1373+
givenRemoteAddr: "@",
1374+
givenHeaders: http.Header{HeaderXForwardedProto: []string{"https"}},
1375+
expect: "https",
1376+
},
1377+
{
1378+
name: "ignores X-Forwarded-Proto from unparsable remote address",
1379+
givenRemoteAddr: "not-an-ip:1234",
1380+
givenHeaders: http.Header{HeaderXForwardedProto: []string{"https"}},
1381+
expect: "http",
1382+
},
12471383
}
12481384

12491385
for _, tc := range testCases {
12501386
t.Run(tc.name, func(t *testing.T) {
12511387
req := httptest.NewRequest(http.MethodGet, "/", nil)
1388+
req.RemoteAddr = "10.0.0.1:1234"
1389+
if tc.givenRemoteAddr != "" {
1390+
req.RemoteAddr = tc.givenRemoteAddr
1391+
}
12521392
if tc.givenHeaders != nil {
12531393
req.Header = tc.givenHeaders
12541394
}

0 commit comments

Comments
 (0)