Skip to content

Commit d3e935b

Browse files
committed
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
1 parent 2e1ed48 commit d3e935b

3 files changed

Lines changed: 305 additions & 3 deletions

File tree

context.go

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,158 @@ func isValidProto(proto string) bool {
207207
return false
208208
}
209209

210-
// Scheme returns the HTTP protocol scheme, `http` or `https`.
210+
// isTokenChar reports whether b is a valid RFC 7230 tchar.
211+
func isTokenChar(b byte) bool {
212+
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
213+
// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
214+
switch b {
215+
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
216+
return true
217+
}
218+
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
219+
}
220+
221+
// firstForwardedProto returns the first valid proto= value from a single Forwarded
222+
// header field value (RFC 7239). Elements are comma-separated; pairs within an
223+
// element are semicolon-separated. Parameter names are case-insensitive. Values
224+
// may be tokens or quoted-strings. The left-most valid proto is returned (first
225+
// proxy / closest to the client when proxies prepend).
226+
func firstForwardedProto(value string) string {
227+
i := 0
228+
n := len(value)
229+
for i < n {
230+
// skip OWS and empty list/pair positions
231+
for i < n && (value[i] == ' ' || value[i] == '\t' || value[i] == ',' || value[i] == ';') {
232+
i++
233+
}
234+
if i >= n {
235+
break
236+
}
237+
238+
// parameter name (token)
239+
start := i
240+
for i < n && isTokenChar(value[i]) {
241+
i++
242+
}
243+
if i == start {
244+
// not a token; skip to next separator to avoid infinite loop
245+
for i < n && value[i] != ',' && value[i] != ';' {
246+
if value[i] == '"' {
247+
// skip quoted-string
248+
i++
249+
for i < n {
250+
if value[i] == '\\' {
251+
i += 2
252+
continue
253+
}
254+
if value[i] == '"' {
255+
i++
256+
break
257+
}
258+
i++
259+
}
260+
continue
261+
}
262+
i++
263+
}
264+
continue
265+
}
266+
name := value[start:i]
267+
268+
// optional OWS before '='
269+
for i < n && (value[i] == ' ' || value[i] == '\t') {
270+
i++
271+
}
272+
if i >= n || value[i] != '=' {
273+
// malformed pair; skip to next separator
274+
for i < n && value[i] != ',' && value[i] != ';' {
275+
i++
276+
}
277+
continue
278+
}
279+
i++ // '='
280+
281+
// optional OWS before value
282+
for i < n && (value[i] == ' ' || value[i] == '\t') {
283+
i++
284+
}
285+
286+
var val string
287+
if i < n && value[i] == '"' {
288+
// quoted-string with optional quoted-pair escapes
289+
i++ // opening "
290+
var b strings.Builder
291+
for i < n {
292+
if value[i] == '\\' {
293+
i++
294+
if i < n {
295+
b.WriteByte(value[i])
296+
i++
297+
}
298+
continue
299+
}
300+
if value[i] == '"' {
301+
i++ // closing "
302+
break
303+
}
304+
b.WriteByte(value[i])
305+
i++
306+
}
307+
val = b.String()
308+
} else {
309+
// token
310+
start = i
311+
for i < n && isTokenChar(value[i]) {
312+
i++
313+
}
314+
val = value[start:i]
315+
}
316+
317+
if strings.EqualFold(name, "proto") && isValidProto(val) {
318+
return val
319+
}
320+
321+
// optional OWS then separator handled at loop top
322+
for i < n && (value[i] == ' ' || value[i] == '\t') {
323+
i++
324+
}
325+
}
326+
return ""
327+
}
328+
329+
// schemeFromForwarded returns the first valid proto from Forwarded header field(s).
330+
// Multiple Forwarded headers are inspected in order (each may list several elements).
331+
func schemeFromForwarded(h http.Header) string {
332+
for _, value := range h.Values(HeaderForwarded) {
333+
if scheme := firstForwardedProto(value); scheme != "" {
334+
return scheme
335+
}
336+
}
337+
return ""
338+
}
339+
340+
// Scheme returns the HTTP protocol scheme, `http` or `https` (also `ws`/`wss`
341+
// when disclosed by a proxy header).
342+
//
343+
// Resolution order:
344+
// 1. Direct TLS connection → "https"
345+
// 2. Forwarded header proto= (RFC 7239), left-most valid value
346+
// 3. X-Forwarded-Proto
347+
// 4. X-Forwarded-Protocol
348+
// 5. X-Forwarded-Ssl == "on" → "https"
349+
// 6. X-Url-Scheme
350+
// 7. default "http"
351+
//
352+
// Only http, https, ws, and wss are accepted from proxy headers (case-insensitive match).
353+
// Can't use r.URL.Scheme: see https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0
211354
func (c *Context) Scheme() string {
212-
// Can't use `r.Request.URL.Scheme`
213-
// See: https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0
214355
if c.IsTLS() {
215356
return "https"
216357
}
358+
// Prefer standardized Forwarded (RFC 7239) over the non-standard X-Forwarded-* headers.
359+
if scheme := schemeFromForwarded(c.request.Header); scheme != "" {
360+
return scheme
361+
}
217362
if scheme := c.request.Header.Get(HeaderXForwardedProto); isValidProto(scheme) {
218363
return scheme
219364
}

context_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,6 +1244,135 @@ func TestContext_Scheme(t *testing.T) {
12441244
},
12451245
expect: "https",
12461246
},
1247+
// RFC 7239 Forwarded header
1248+
{
1249+
name: "uses Forwarded proto=https",
1250+
givenIsTLS: false,
1251+
givenHeaders: http.Header{
1252+
HeaderForwarded: []string{"proto=https"},
1253+
},
1254+
expect: "https",
1255+
},
1256+
{
1257+
name: "uses Forwarded proto=http",
1258+
givenIsTLS: false,
1259+
givenHeaders: http.Header{
1260+
HeaderForwarded: []string{"proto=http"},
1261+
},
1262+
expect: "http",
1263+
},
1264+
{
1265+
name: "uses Forwarded full element with for/proto/by",
1266+
givenIsTLS: false,
1267+
givenHeaders: http.Header{
1268+
HeaderForwarded: []string{"for=192.0.2.60;proto=https;by=203.0.113.43"},
1269+
},
1270+
expect: "https",
1271+
},
1272+
{
1273+
name: "uses left-most proto from multi-proxy Forwarded chain",
1274+
givenIsTLS: false,
1275+
givenHeaders: http.Header{
1276+
// first hop client→proxy (https), second hop proxy→origin (http)
1277+
HeaderForwarded: []string{"for=192.0.2.43;proto=https, for=198.51.100.17;proto=http"},
1278+
},
1279+
expect: "https",
1280+
},
1281+
{
1282+
name: "uses later element proto when first hop omits proto",
1283+
givenIsTLS: false,
1284+
givenHeaders: http.Header{
1285+
HeaderForwarded: []string{"for=192.0.2.43, for=198.51.100.17;proto=https"},
1286+
},
1287+
expect: "https",
1288+
},
1289+
{
1290+
name: "Forwarded proto is case insensitive for parameter name",
1291+
givenIsTLS: false,
1292+
givenHeaders: http.Header{
1293+
HeaderForwarded: []string{"For=192.0.2.60;Proto=HTTPS;By=203.0.113.43"},
1294+
},
1295+
expect: "HTTPS",
1296+
},
1297+
{
1298+
name: "uses quoted Forwarded proto value",
1299+
givenIsTLS: false,
1300+
givenHeaders: http.Header{
1301+
HeaderForwarded: []string{`for="[2001:db8:cafe::17]";proto="https"`},
1302+
},
1303+
expect: "https",
1304+
},
1305+
{
1306+
name: "uses Forwarded proto=wss",
1307+
givenIsTLS: false,
1308+
givenHeaders: http.Header{
1309+
HeaderForwarded: []string{"proto=wss"},
1310+
},
1311+
expect: "wss",
1312+
},
1313+
{
1314+
name: "inspects multiple Forwarded header fields",
1315+
givenIsTLS: false,
1316+
givenHeaders: http.Header{
1317+
HeaderForwarded: []string{
1318+
"for=192.0.2.43",
1319+
"for=198.51.100.17;proto=https",
1320+
},
1321+
},
1322+
expect: "https",
1323+
},
1324+
{
1325+
name: "ignores invalid Forwarded proto and uses X-Forwarded-Proto",
1326+
givenIsTLS: false,
1327+
givenHeaders: http.Header{
1328+
HeaderForwarded: []string{"for=192.0.2.60;proto=ftp;by=203.0.113.43"},
1329+
HeaderXForwardedProto: []string{"https"},
1330+
},
1331+
expect: "https",
1332+
},
1333+
{
1334+
name: "ignores Forwarded without proto and uses X-Forwarded-Proto",
1335+
givenIsTLS: false,
1336+
givenHeaders: http.Header{
1337+
HeaderForwarded: []string{"for=192.0.2.60;by=203.0.113.43"},
1338+
HeaderXForwardedProto: []string{"https"},
1339+
},
1340+
expect: "https",
1341+
},
1342+
{
1343+
name: "Forwarded proto takes precedence over X-Forwarded-Proto",
1344+
givenIsTLS: false,
1345+
givenHeaders: http.Header{
1346+
HeaderForwarded: []string{"for=192.0.2.60;proto=https"},
1347+
HeaderXForwardedProto: []string{"http"},
1348+
},
1349+
expect: "https",
1350+
},
1351+
{
1352+
name: "TLS takes precedence over Forwarded proto",
1353+
givenIsTLS: true,
1354+
givenHeaders: http.Header{
1355+
HeaderForwarded: []string{"proto=http"},
1356+
},
1357+
expect: "https",
1358+
},
1359+
{
1360+
name: "ignores malformed Forwarded and falls back to http",
1361+
givenIsTLS: false,
1362+
givenHeaders: http.Header{
1363+
HeaderForwarded: []string{"not-a-pair, ;;; =broken"},
1364+
},
1365+
expect: "http",
1366+
},
1367+
{
1368+
name: "ignores empty Forwarded proto value",
1369+
givenIsTLS: false,
1370+
givenHeaders: http.Header{
1371+
HeaderForwarded: []string{"proto=;for=192.0.2.60"},
1372+
HeaderXForwardedProto: []string{"https"},
1373+
},
1374+
expect: "https",
1375+
},
12471376
}
12481377

12491378
for _, tc := range testCases {
@@ -1262,6 +1391,31 @@ func TestContext_Scheme(t *testing.T) {
12621391
}
12631392
}
12641393

1394+
func TestFirstForwardedProto(t *testing.T) {
1395+
tests := []struct {
1396+
name string
1397+
value string
1398+
want string
1399+
}{
1400+
{name: "empty", value: "", want: ""},
1401+
{name: "proto only", value: "proto=https", want: "https"},
1402+
{name: "spaces around separators", value: " for = 192.0.2.1 ; proto = https ", want: "https"},
1403+
{name: "quoted with escapes yields valid proto", value: `proto="ht\tps"`, want: "https"},
1404+
{name: "quoted invalid proto", value: `proto="ftp"`, want: ""},
1405+
{name: "quoted https", value: `proto="https"`, want: "https"},
1406+
{name: "skips unknown params", value: "secret=1;proto=https;extra=x", want: "https"},
1407+
{name: "comma before pair", value: ",proto=https", want: "https"},
1408+
{name: "ipv6 for with proto", value: `for="[2001:db8::1]:4711";proto=https`, want: "https"},
1409+
{name: "proto in second element only", value: "for=_hidden, for=192.0.2.1;proto=http", want: "http"},
1410+
{name: "invalid then valid", value: "proto=ftp, for=1.2.3.4;proto=https", want: "https"},
1411+
}
1412+
for _, tc := range tests {
1413+
t.Run(tc.name, func(t *testing.T) {
1414+
assert.Equal(t, tc.want, firstForwardedProto(tc.value))
1415+
})
1416+
}
1417+
}
1418+
12651419
func TestContext_IsWebSocket(t *testing.T) {
12661420
tests := []struct {
12671421
c *Context

echo.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ const (
208208
HeaderUpgrade = "Upgrade"
209209
HeaderVary = "Vary"
210210
HeaderWWWAuthenticate = "WWW-Authenticate"
211+
// HeaderForwarded is the standardized proxy header (RFC 7239). Prefer over X-Forwarded-* when present.
212+
// See: https://datatracker.ietf.org/doc/html/rfc7239
213+
HeaderForwarded = "Forwarded"
211214
HeaderXForwardedFor = "X-Forwarded-For"
212215
HeaderXForwardedProto = "X-Forwarded-Proto"
213216
HeaderXForwardedProtocol = "X-Forwarded-Protocol"

0 commit comments

Comments
 (0)