Skip to content

Commit d6d643f

Browse files
authored
Fix http auth header parsing (#34936)
Using `strings.EqualFold` is wrong in many cases.
1 parent 8cbec63 commit d6d643f

File tree

9 files changed

+136
-78
lines changed

9 files changed

+136
-78
lines changed

modules/auth/httpauth/httpauth.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package httpauth
5+
6+
import (
7+
"encoding/base64"
8+
"strings"
9+
10+
"code.gitea.io/gitea/modules/util"
11+
)
12+
13+
type BasicAuth struct {
14+
Username, Password string
15+
}
16+
17+
type BearerToken struct {
18+
Token string
19+
}
20+
21+
type ParsedAuthorizationHeader struct {
22+
BasicAuth *BasicAuth
23+
BearerToken *BearerToken
24+
}
25+
26+
func ParseAuthorizationHeader(header string) (ret ParsedAuthorizationHeader, _ bool) {
27+
parts := strings.Fields(header)
28+
if len(parts) != 2 {
29+
return ret, false
30+
}
31+
if util.AsciiEqualFold(parts[0], "basic") {
32+
s, err := base64.StdEncoding.DecodeString(parts[1])
33+
if err != nil {
34+
return ret, false
35+
}
36+
u, p, ok := strings.Cut(string(s), ":")
37+
if !ok {
38+
return ret, false
39+
}
40+
ret.BasicAuth = &BasicAuth{Username: u, Password: p}
41+
return ret, true
42+
} else if util.AsciiEqualFold(parts[0], "token") || util.AsciiEqualFold(parts[0], "bearer") {
43+
ret.BearerToken = &BearerToken{Token: parts[1]}
44+
return ret, true
45+
}
46+
return ret, false
47+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package httpauth
5+
6+
import (
7+
"encoding/base64"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestParseAuthorizationHeader(t *testing.T) {
14+
type parsed = ParsedAuthorizationHeader
15+
type basic = BasicAuth
16+
type bearer = BearerToken
17+
cases := []struct {
18+
headerValue string
19+
expected parsed
20+
ok bool
21+
}{
22+
{"", parsed{}, false},
23+
{"?", parsed{}, false},
24+
{"foo", parsed{}, false},
25+
{"any value", parsed{}, false},
26+
27+
{"Basic ?", parsed{}, false},
28+
{"Basic " + base64.StdEncoding.EncodeToString([]byte("foo")), parsed{}, false},
29+
{"Basic " + base64.StdEncoding.EncodeToString([]byte("foo:bar")), parsed{BasicAuth: &basic{"foo", "bar"}}, true},
30+
{"basic " + base64.StdEncoding.EncodeToString([]byte("foo:bar")), parsed{BasicAuth: &basic{"foo", "bar"}}, true},
31+
32+
{"token value", parsed{BearerToken: &bearer{"value"}}, true},
33+
{"Token value", parsed{BearerToken: &bearer{"value"}}, true},
34+
{"bearer value", parsed{BearerToken: &bearer{"value"}}, true},
35+
{"Bearer value", parsed{BearerToken: &bearer{"value"}}, true},
36+
{"Bearer wrong value", parsed{}, false},
37+
}
38+
for _, c := range cases {
39+
ret, ok := ParseAuthorizationHeader(c.headerValue)
40+
assert.Equal(t, c.ok, ok, "header %q", c.headerValue)
41+
assert.Equal(t, c.expected, ret, "header %q", c.headerValue)
42+
}
43+
}

modules/base/tool.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,10 @@ import (
88
"crypto/sha1"
99
"crypto/sha256"
1010
"crypto/subtle"
11-
"encoding/base64"
1211
"encoding/hex"
13-
"errors"
1412
"fmt"
1513
"hash"
1614
"strconv"
17-
"strings"
1815
"time"
1916

2017
"code.gitea.io/gitea/modules/setting"
@@ -36,19 +33,6 @@ func ShortSha(sha1 string) string {
3633
return util.TruncateRunes(sha1, 10)
3734
}
3835

39-
// BasicAuthDecode decode basic auth string
40-
func BasicAuthDecode(encoded string) (string, string, error) {
41-
s, err := base64.StdEncoding.DecodeString(encoded)
42-
if err != nil {
43-
return "", "", err
44-
}
45-
46-
if username, password, ok := strings.Cut(string(s), ":"); ok {
47-
return username, password, nil
48-
}
49-
return "", "", errors.New("invalid basic authentication")
50-
}
51-
5236
// VerifyTimeLimitCode verify time limit code
5337
func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool {
5438
if len(code) <= 18 {

modules/base/tool_test.go

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,6 @@ func TestShortSha(t *testing.T) {
2626
assert.Equal(t, "veryverylo", ShortSha("veryverylong"))
2727
}
2828

29-
func TestBasicAuthDecode(t *testing.T) {
30-
_, _, err := BasicAuthDecode("?")
31-
assert.Equal(t, "illegal base64 data at input byte 0", err.Error())
32-
33-
user, pass, err := BasicAuthDecode("Zm9vOmJhcg==")
34-
assert.NoError(t, err)
35-
assert.Equal(t, "foo", user)
36-
assert.Equal(t, "bar", pass)
37-
38-
_, _, err = BasicAuthDecode("aW52YWxpZA==")
39-
assert.Error(t, err)
40-
41-
_, _, err = BasicAuthDecode("invalid")
42-
assert.Error(t, err)
43-
44-
_, _, err = BasicAuthDecode("YWxpY2U=") // "alice", no colon
45-
assert.Error(t, err)
46-
}
47-
4829
func TestVerifyTimeLimitCode(t *testing.T) {
4930
defer test.MockVariableValue(&setting.InstallLock, true)()
5031
initGeneralSecret := func(secret string) {

modules/util/string.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,24 @@ func SplitTrimSpace(input, sep string) []string {
110110
}
111111
return stringList
112112
}
113+
114+
func asciiLower(b byte) byte {
115+
if 'A' <= b && b <= 'Z' {
116+
return b + ('a' - 'A')
117+
}
118+
return b
119+
}
120+
121+
// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go
122+
// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold]
123+
func AsciiEqualFold(s, t string) bool { //nolint:revive // PascalCase
124+
if len(s) != len(t) {
125+
return false
126+
}
127+
for i := 0; i < len(s); i++ {
128+
if asciiLower(s[i]) != asciiLower(t[i]) {
129+
return false
130+
}
131+
}
132+
return true
133+
}

routers/web/auth/oauth2_provider.go

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,16 @@
44
package auth
55

66
import (
7-
"errors"
87
"fmt"
98
"html"
109
"html/template"
1110
"net/http"
1211
"net/url"
1312
"strconv"
14-
"strings"
1513

1614
"code.gitea.io/gitea/models/auth"
1715
user_model "code.gitea.io/gitea/models/user"
18-
"code.gitea.io/gitea/modules/base"
16+
"code.gitea.io/gitea/modules/auth/httpauth"
1917
"code.gitea.io/gitea/modules/json"
2018
"code.gitea.io/gitea/modules/log"
2119
"code.gitea.io/gitea/modules/setting"
@@ -108,9 +106,8 @@ func InfoOAuth(ctx *context.Context) {
108106

109107
var accessTokenScope auth.AccessTokenScope
110108
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
111-
auths := strings.Fields(auHead)
112-
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
113-
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1])
109+
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
110+
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
114111
}
115112
}
116113

@@ -127,18 +124,12 @@ func InfoOAuth(ctx *context.Context) {
127124
ctx.JSON(http.StatusOK, response)
128125
}
129126

130-
func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
131-
authHeader := ctx.Req.Header.Get("Authorization")
132-
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
133-
return base.BasicAuthDecode(authData)
134-
}
135-
return "", "", errors.New("invalid basic authentication")
136-
}
137-
138127
// IntrospectOAuth introspects an oauth token
139128
func IntrospectOAuth(ctx *context.Context) {
140129
clientIDValid := false
141-
if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
130+
authHeader := ctx.Req.Header.Get("Authorization")
131+
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
132+
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
142133
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
143134
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
144135
// this is likely a database error; log it and respond without details
@@ -465,16 +456,16 @@ func AccessTokenOAuth(ctx *context.Context) {
465456
form := *web.GetForm(ctx).(*forms.AccessTokenForm)
466457
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
467458
if form.ClientID == "" || form.ClientSecret == "" {
468-
authHeader := ctx.Req.Header.Get("Authorization")
469-
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
470-
clientID, clientSecret, err := base.BasicAuthDecode(authData)
471-
if err != nil {
459+
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
460+
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
461+
if !ok || parsed.BasicAuth == nil {
472462
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
473463
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
474464
ErrorDescription: "cannot parse basic auth header",
475465
})
476466
return
477467
}
468+
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
478469
// validate that any fields present in the form match the Basic auth header
479470
if form.ClientID != "" && form.ClientID != clientID {
480471
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{

services/auth/basic.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ package auth
77
import (
88
"errors"
99
"net/http"
10-
"strings"
1110

1211
actions_model "code.gitea.io/gitea/models/actions"
1312
auth_model "code.gitea.io/gitea/models/auth"
1413
user_model "code.gitea.io/gitea/models/user"
15-
"code.gitea.io/gitea/modules/base"
14+
"code.gitea.io/gitea/modules/auth/httpauth"
1615
"code.gitea.io/gitea/modules/log"
1716
"code.gitea.io/gitea/modules/setting"
1817
"code.gitea.io/gitea/modules/timeutil"
@@ -54,17 +53,15 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
5453
return nil, nil
5554
}
5655

57-
baHead := req.Header.Get("Authorization")
58-
if len(baHead) == 0 {
56+
authHeader := req.Header.Get("Authorization")
57+
if authHeader == "" {
5958
return nil, nil
6059
}
61-
62-
auths := strings.SplitN(baHead, " ", 2)
63-
if len(auths) != 2 || (strings.ToLower(auths[0]) != "basic") {
60+
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
61+
if !ok || parsed.BasicAuth == nil {
6462
return nil, nil
6563
}
66-
67-
uname, passwd, _ := base.BasicAuthDecode(auths[1])
64+
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
6865

6966
// Check if username or password is a token
7067
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"

services/auth/oauth2.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
actions_model "code.gitea.io/gitea/models/actions"
1414
auth_model "code.gitea.io/gitea/models/auth"
1515
user_model "code.gitea.io/gitea/models/user"
16+
"code.gitea.io/gitea/modules/auth/httpauth"
1617
"code.gitea.io/gitea/modules/log"
1718
"code.gitea.io/gitea/modules/setting"
1819
"code.gitea.io/gitea/modules/timeutil"
@@ -97,9 +98,9 @@ func parseToken(req *http.Request) (string, bool) {
9798

9899
// check header token
99100
if auHead := req.Header.Get("Authorization"); auHead != "" {
100-
auths := strings.Fields(auHead)
101-
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
102-
return auths[1], true
101+
parsed, ok := httpauth.ParseAuthorizationHeader(auHead)
102+
if ok && parsed.BearerToken != nil {
103+
return parsed.BearerToken.Token, true
103104
}
104105
}
105106
return "", false

services/lfs/server.go

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
repo_model "code.gitea.io/gitea/models/repo"
2828
"code.gitea.io/gitea/models/unit"
2929
user_model "code.gitea.io/gitea/models/user"
30+
"code.gitea.io/gitea/modules/auth/httpauth"
3031
"code.gitea.io/gitea/modules/json"
3132
lfs_module "code.gitea.io/gitea/modules/lfs"
3233
"code.gitea.io/gitea/modules/log"
@@ -594,19 +595,11 @@ func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Rep
594595
if authorization == "" {
595596
return nil, errors.New("no token")
596597
}
597-
598-
parts := strings.SplitN(authorization, " ", 2)
599-
if len(parts) != 2 {
600-
return nil, errors.New("no token")
601-
}
602-
tokenSHA := parts[1]
603-
switch strings.ToLower(parts[0]) {
604-
case "bearer":
605-
fallthrough
606-
case "token":
607-
return handleLFSToken(ctx, tokenSHA, target, mode)
598+
parsed, ok := httpauth.ParseAuthorizationHeader(authorization)
599+
if !ok || parsed.BearerToken == nil {
600+
return nil, errors.New("token not found")
608601
}
609-
return nil, errors.New("token not found")
602+
return handleLFSToken(ctx, parsed.BearerToken.Token, target, mode)
610603
}
611604

612605
func requireAuth(ctx *context.Context) {

0 commit comments

Comments
 (0)