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
151 changes: 148 additions & 3 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
175 changes: 175 additions & 0 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -1262,6 +1391,52 @@ func TestContext_Scheme(t *testing.T) {
}
}

func TestFirstForwardedProto(t *testing.T) {
tests := []struct {
name string
value string
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"},
{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"},
// 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) {
assert.Equal(t, tc.want, firstForwardedProto(tc.value))
})
}
}

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
Expand Down
3 changes: 3 additions & 0 deletions echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading