From d3e935bdbd1aa883aa37bda38b427e5318c04603 Mon Sep 17 00:00:00 2001 From: sonnemusk Date: Fri, 24 Jul 2026 10:03:57 +0800 Subject: [PATCH 1/2] Support RFC 7239 Forwarded header proto in Scheme() Parse the standardized Forwarded header for a valid proto= value when resolving the request scheme. Prefer Forwarded over the non-standard X-Forwarded-* headers while keeping TLS first and falling through on missing or invalid proto. Only http, https, ws, and wss are accepted. Multi-proxy and multi-header Forwarded values are handled with left-most valid proto; quoted-string values are unescaped per RFC 7230. Fixes #2694 --- context.go | 151 ++++++++++++++++++++++++++++++++++++++++++++++- context_test.go | 154 ++++++++++++++++++++++++++++++++++++++++++++++++ echo.go | 3 + 3 files changed, 305 insertions(+), 3 deletions(-) diff --git a/context.go b/context.go index a8ee4b51d..b826d2990 100644 --- a/context.go +++ b/context.go @@ -207,13 +207,158 @@ func isValidProto(proto string) bool { return false } -// Scheme returns the HTTP protocol scheme, `http` or `https`. +// isTokenChar reports whether b is a valid RFC 7230 tchar. +func isTokenChar(b byte) bool { + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / + // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + switch b { + case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~': + return true + } + return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} + +// firstForwardedProto returns the first valid proto= value from a single Forwarded +// header field value (RFC 7239). Elements are comma-separated; pairs within an +// element are semicolon-separated. Parameter names are case-insensitive. Values +// may be tokens or quoted-strings. The left-most valid proto is returned (first +// proxy / closest to the client when proxies prepend). +func firstForwardedProto(value string) string { + i := 0 + n := len(value) + for i < n { + // skip OWS and empty list/pair positions + for i < n && (value[i] == ' ' || value[i] == '\t' || value[i] == ',' || value[i] == ';') { + i++ + } + if i >= n { + break + } + + // parameter name (token) + start := i + for i < n && isTokenChar(value[i]) { + i++ + } + if i == start { + // not a token; skip to next separator to avoid infinite loop + for i < n && value[i] != ',' && value[i] != ';' { + if value[i] == '"' { + // skip quoted-string + i++ + for i < n { + if value[i] == '\\' { + i += 2 + continue + } + if value[i] == '"' { + i++ + break + } + i++ + } + continue + } + i++ + } + continue + } + name := value[start:i] + + // optional OWS before '=' + for i < n && (value[i] == ' ' || value[i] == '\t') { + i++ + } + if i >= n || value[i] != '=' { + // malformed pair; skip to next separator + for i < n && value[i] != ',' && value[i] != ';' { + i++ + } + continue + } + i++ // '=' + + // optional OWS before value + for i < n && (value[i] == ' ' || value[i] == '\t') { + i++ + } + + var val string + if i < n && value[i] == '"' { + // quoted-string with optional quoted-pair escapes + i++ // opening " + var b strings.Builder + for i < n { + if value[i] == '\\' { + i++ + if i < n { + b.WriteByte(value[i]) + i++ + } + continue + } + if value[i] == '"' { + i++ // closing " + break + } + b.WriteByte(value[i]) + i++ + } + val = b.String() + } else { + // token + start = i + for i < n && isTokenChar(value[i]) { + i++ + } + val = value[start:i] + } + + if strings.EqualFold(name, "proto") && isValidProto(val) { + return val + } + + // optional OWS then separator handled at loop top + for i < n && (value[i] == ' ' || value[i] == '\t') { + i++ + } + } + return "" +} + +// schemeFromForwarded returns the first valid proto from Forwarded header field(s). +// Multiple Forwarded headers are inspected in order (each may list several elements). +func schemeFromForwarded(h http.Header) string { + for _, value := range h.Values(HeaderForwarded) { + if scheme := firstForwardedProto(value); scheme != "" { + return scheme + } + } + return "" +} + +// Scheme returns the HTTP protocol scheme, `http` or `https` (also `ws`/`wss` +// when disclosed by a proxy header). +// +// Resolution order: +// 1. Direct TLS connection → "https" +// 2. Forwarded header proto= (RFC 7239), left-most valid value +// 3. X-Forwarded-Proto +// 4. X-Forwarded-Protocol +// 5. X-Forwarded-Ssl == "on" → "https" +// 6. X-Url-Scheme +// 7. default "http" +// +// Only http, https, ws, and wss are accepted from proxy headers (case-insensitive match). +// Can't use r.URL.Scheme: see https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0 func (c *Context) Scheme() string { - // Can't use `r.Request.URL.Scheme` - // See: https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0 if c.IsTLS() { return "https" } + // Prefer standardized Forwarded (RFC 7239) over the non-standard X-Forwarded-* headers. + if scheme := schemeFromForwarded(c.request.Header); scheme != "" { + return scheme + } if scheme := c.request.Header.Get(HeaderXForwardedProto); isValidProto(scheme) { return scheme } diff --git a/context_test.go b/context_test.go index 83040dc76..8904f1197 100644 --- a/context_test.go +++ b/context_test.go @@ -1244,6 +1244,135 @@ func TestContext_Scheme(t *testing.T) { }, expect: "https", }, + // RFC 7239 Forwarded header + { + name: "uses Forwarded proto=https", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"proto=https"}, + }, + expect: "https", + }, + { + name: "uses Forwarded proto=http", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"proto=http"}, + }, + expect: "http", + }, + { + name: "uses Forwarded full element with for/proto/by", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"for=192.0.2.60;proto=https;by=203.0.113.43"}, + }, + expect: "https", + }, + { + name: "uses left-most proto from multi-proxy Forwarded chain", + givenIsTLS: false, + givenHeaders: http.Header{ + // first hop client→proxy (https), second hop proxy→origin (http) + HeaderForwarded: []string{"for=192.0.2.43;proto=https, for=198.51.100.17;proto=http"}, + }, + expect: "https", + }, + { + name: "uses later element proto when first hop omits proto", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"for=192.0.2.43, for=198.51.100.17;proto=https"}, + }, + expect: "https", + }, + { + name: "Forwarded proto is case insensitive for parameter name", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"For=192.0.2.60;Proto=HTTPS;By=203.0.113.43"}, + }, + expect: "HTTPS", + }, + { + name: "uses quoted Forwarded proto value", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{`for="[2001:db8:cafe::17]";proto="https"`}, + }, + expect: "https", + }, + { + name: "uses Forwarded proto=wss", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"proto=wss"}, + }, + expect: "wss", + }, + { + name: "inspects multiple Forwarded header fields", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{ + "for=192.0.2.43", + "for=198.51.100.17;proto=https", + }, + }, + expect: "https", + }, + { + name: "ignores invalid Forwarded proto and uses X-Forwarded-Proto", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"for=192.0.2.60;proto=ftp;by=203.0.113.43"}, + HeaderXForwardedProto: []string{"https"}, + }, + expect: "https", + }, + { + name: "ignores Forwarded without proto and uses X-Forwarded-Proto", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"for=192.0.2.60;by=203.0.113.43"}, + HeaderXForwardedProto: []string{"https"}, + }, + expect: "https", + }, + { + name: "Forwarded proto takes precedence over X-Forwarded-Proto", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"for=192.0.2.60;proto=https"}, + HeaderXForwardedProto: []string{"http"}, + }, + expect: "https", + }, + { + name: "TLS takes precedence over Forwarded proto", + givenIsTLS: true, + givenHeaders: http.Header{ + HeaderForwarded: []string{"proto=http"}, + }, + expect: "https", + }, + { + name: "ignores malformed Forwarded and falls back to http", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"not-a-pair, ;;; =broken"}, + }, + expect: "http", + }, + { + name: "ignores empty Forwarded proto value", + givenIsTLS: false, + givenHeaders: http.Header{ + HeaderForwarded: []string{"proto=;for=192.0.2.60"}, + HeaderXForwardedProto: []string{"https"}, + }, + expect: "https", + }, } for _, tc := range testCases { @@ -1262,6 +1391,31 @@ func TestContext_Scheme(t *testing.T) { } } +func TestFirstForwardedProto(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "empty", value: "", want: ""}, + {name: "proto only", value: "proto=https", want: "https"}, + {name: "spaces around separators", value: " for = 192.0.2.1 ; proto = https ", want: "https"}, + {name: "quoted with escapes yields valid proto", value: `proto="ht\tps"`, want: "https"}, + {name: "quoted invalid proto", value: `proto="ftp"`, want: ""}, + {name: "quoted https", value: `proto="https"`, want: "https"}, + {name: "skips unknown params", value: "secret=1;proto=https;extra=x", want: "https"}, + {name: "comma before pair", value: ",proto=https", want: "https"}, + {name: "ipv6 for with proto", value: `for="[2001:db8::1]:4711";proto=https`, want: "https"}, + {name: "proto in second element only", value: "for=_hidden, for=192.0.2.1;proto=http", want: "http"}, + {name: "invalid then valid", value: "proto=ftp, for=1.2.3.4;proto=https", want: "https"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, firstForwardedProto(tc.value)) + }) + } +} + func TestContext_IsWebSocket(t *testing.T) { tests := []struct { c *Context diff --git a/echo.go b/echo.go index 98e7558cb..198d5b24d 100644 --- a/echo.go +++ b/echo.go @@ -208,6 +208,9 @@ const ( HeaderUpgrade = "Upgrade" HeaderVary = "Vary" HeaderWWWAuthenticate = "WWW-Authenticate" + // HeaderForwarded is the standardized proxy header (RFC 7239). Prefer over X-Forwarded-* when present. + // See: https://datatracker.ietf.org/doc/html/rfc7239 + HeaderForwarded = "Forwarded" HeaderXForwardedFor = "X-Forwarded-For" HeaderXForwardedProto = "X-Forwarded-Proto" HeaderXForwardedProtocol = "X-Forwarded-Protocol" From 77d578106749948d2900827426e9df3dcdb7e867 Mon Sep 17 00:00:00 2001 From: sonnemusk Date: Fri, 24 Jul 2026 10:49:57 +0800 Subject: [PATCH 2/2] test: cover Forwarded proto parser edge cases Add firstForwardedProto cases for quoted garbage, missing '=', unclosed quotes, trailing OWS, and isTokenChar checks so codecov/patch covers the RFC 7239 parser branches. --- context_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/context_test.go b/context_test.go index 8904f1197..fb45b751c 100644 --- a/context_test.go +++ b/context_test.go @@ -1398,6 +1398,7 @@ func TestFirstForwardedProto(t *testing.T) { want string }{ {name: "empty", value: "", want: ""}, + {name: "whitespace only", value: " \t ", want: ""}, {name: "proto only", value: "proto=https", want: "https"}, {name: "spaces around separators", value: " for = 192.0.2.1 ; proto = https ", want: "https"}, {name: "quoted with escapes yields valid proto", value: `proto="ht\tps"`, want: "https"}, @@ -1408,6 +1409,17 @@ func TestFirstForwardedProto(t *testing.T) { {name: "ipv6 for with proto", value: `for="[2001:db8::1]:4711";proto=https`, want: "https"}, {name: "proto in second element only", value: "for=_hidden, for=192.0.2.1;proto=http", want: "http"}, {name: "invalid then valid", value: "proto=ftp, for=1.2.3.4;proto=https", want: "https"}, + // branch coverage for parser edge paths + {name: "leading quoted garbage then proto", value: `"not-a-param";proto=https`, want: "https"}, + {name: "quoted garbage with escapes then proto", value: `"a\"b";proto=wss`, want: "wss"}, + {name: "name without equals then valid", value: "for;proto=https", want: "https"}, + {name: "equals without name then proto", value: "=ignored;proto=http", want: "http"}, + {name: "unclosed quote does not hang", value: `proto="https`, want: "https"}, + {name: "backslash at end of quoted value", value: `proto="https\`, want: "https"}, + {name: "trailing OWS after value", value: "proto=https \t;for=1.2.3.4", want: "https"}, + {name: "only separators", value: ",,,;;;", want: ""}, + {name: "non token garbage then proto", value: "@@@;proto=ws", want: "ws"}, + {name: "token-like custom param", value: "x.y_z=1;proto=https", want: "https"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -1416,6 +1428,15 @@ func TestFirstForwardedProto(t *testing.T) { } } +func TestIsTokenChar(t *testing.T) { + for _, b := range []byte("!#$%&'*+-.^_`|~09AZaz") { + assert.True(t, isTokenChar(b), "expected token char %q", b) + } + for _, b := range []byte(" \t,;=\"<>()[]{}\\/") { + assert.False(t, isTokenChar(b), "expected non-token char %q", b) + } +} + func TestContext_IsWebSocket(t *testing.T) { tests := []struct { c *Context