Skip to content

Fix http auth header parsing #34936

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 3, 2025
Merged
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
47 changes: 47 additions & 0 deletions modules/auth/httpauth/httpauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httpauth

import (
"encoding/base64"
"strings"

"code.gitea.io/gitea/modules/util"
)

type BasicAuth struct {
Username, Password string
}

type BearerToken struct {
Token string
}

type ParsedAuthorizationHeader struct {
BasicAuth *BasicAuth
BearerToken *BearerToken
}

func ParseAuthorizationHeader(header string) (ret ParsedAuthorizationHeader, _ bool) {
parts := strings.Fields(header)
if len(parts) != 2 {
return ret, false
}
if util.AsciiEqualFold(parts[0], "basic") {
s, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return ret, false
}
u, p, ok := strings.Cut(string(s), ":")
if !ok {
return ret, false
}
ret.BasicAuth = &BasicAuth{Username: u, Password: p}
return ret, true
} else if util.AsciiEqualFold(parts[0], "token") || util.AsciiEqualFold(parts[0], "bearer") {
ret.BearerToken = &BearerToken{Token: parts[1]}
return ret, true
}
return ret, false
}
43 changes: 43 additions & 0 deletions modules/auth/httpauth/httpauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package httpauth

import (
"encoding/base64"
"testing"

"github.com/stretchr/testify/assert"
)

func TestParseAuthorizationHeader(t *testing.T) {
type parsed = ParsedAuthorizationHeader
type basic = BasicAuth
type bearer = BearerToken
cases := []struct {
headerValue string
expected parsed
ok bool
}{
{"", parsed{}, false},
{"?", parsed{}, false},
{"foo", parsed{}, false},
{"any value", parsed{}, false},

{"Basic ?", parsed{}, false},
{"Basic " + base64.StdEncoding.EncodeToString([]byte("foo")), parsed{}, false},
{"Basic " + base64.StdEncoding.EncodeToString([]byte("foo:bar")), parsed{BasicAuth: &basic{"foo", "bar"}}, true},
{"basic " + base64.StdEncoding.EncodeToString([]byte("foo:bar")), parsed{BasicAuth: &basic{"foo", "bar"}}, true},

{"token value", parsed{BearerToken: &bearer{"value"}}, true},
{"Token value", parsed{BearerToken: &bearer{"value"}}, true},
{"bearer value", parsed{BearerToken: &bearer{"value"}}, true},
{"Bearer value", parsed{BearerToken: &bearer{"value"}}, true},
{"Bearer wrong value", parsed{}, false},
}
for _, c := range cases {
ret, ok := ParseAuthorizationHeader(c.headerValue)
assert.Equal(t, c.ok, ok, "header %q", c.headerValue)
assert.Equal(t, c.expected, ret, "header %q", c.headerValue)
}
}
16 changes: 0 additions & 16 deletions modules/base/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,10 @@ import (
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"hash"
"strconv"
"strings"
"time"

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

// BasicAuthDecode decode basic auth string
func BasicAuthDecode(encoded string) (string, string, error) {
s, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", err
}

if username, password, ok := strings.Cut(string(s), ":"); ok {
return username, password, nil
}
return "", "", errors.New("invalid basic authentication")
}

// VerifyTimeLimitCode verify time limit code
func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool {
if len(code) <= 18 {
Expand Down
19 changes: 0 additions & 19 deletions modules/base/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,6 @@ func TestShortSha(t *testing.T) {
assert.Equal(t, "veryverylo", ShortSha("veryverylong"))
}

func TestBasicAuthDecode(t *testing.T) {
_, _, err := BasicAuthDecode("?")
assert.Equal(t, "illegal base64 data at input byte 0", err.Error())

user, pass, err := BasicAuthDecode("Zm9vOmJhcg==")
assert.NoError(t, err)
assert.Equal(t, "foo", user)
assert.Equal(t, "bar", pass)

_, _, err = BasicAuthDecode("aW52YWxpZA==")
assert.Error(t, err)

_, _, err = BasicAuthDecode("invalid")
assert.Error(t, err)

_, _, err = BasicAuthDecode("YWxpY2U=") // "alice", no colon
assert.Error(t, err)
}

func TestVerifyTimeLimitCode(t *testing.T) {
defer test.MockVariableValue(&setting.InstallLock, true)()
initGeneralSecret := func(secret string) {
Expand Down
21 changes: 21 additions & 0 deletions modules/util/string.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,24 @@ func SplitTrimSpace(input, sep string) []string {
}
return stringList
}

func asciiLower(b byte) byte {
if 'A' <= b && b <= 'Z' {
return b + ('a' - 'A')
}
return b
}

// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go
// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold]
func AsciiEqualFold(s, t string) bool { //nolint:revive // PascalCase
if len(s) != len(t) {
return false
}
for i := 0; i < len(s); i++ {
if asciiLower(s[i]) != asciiLower(t[i]) {
return false
}
}
return true
}
29 changes: 10 additions & 19 deletions routers/web/auth/oauth2_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@
package auth

import (
"errors"
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"

"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
Expand Down Expand Up @@ -108,9 +106,8 @@ func InfoOAuth(ctx *context.Context) {

var accessTokenScope auth.AccessTokenScope
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
auths := strings.Fields(auHead)
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1])
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
}
}

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

func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
authHeader := ctx.Req.Header.Get("Authorization")
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
return base.BasicAuthDecode(authData)
}
return "", "", errors.New("invalid basic authentication")
}

// IntrospectOAuth introspects an oauth token
func IntrospectOAuth(ctx *context.Context) {
clientIDValid := false
if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
authHeader := ctx.Req.Header.Get("Authorization")
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
// this is likely a database error; log it and respond without details
Expand Down Expand Up @@ -465,16 +456,16 @@ func AccessTokenOAuth(ctx *context.Context) {
form := *web.GetForm(ctx).(*forms.AccessTokenForm)
// 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
if form.ClientID == "" || form.ClientSecret == "" {
authHeader := ctx.Req.Header.Get("Authorization")
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
clientID, clientSecret, err := base.BasicAuthDecode(authData)
if err != nil {
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BasicAuth == nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot parse basic auth header",
})
return
}
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// validate that any fields present in the form match the Basic auth header
if form.ClientID != "" && form.ClientID != clientID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
Expand Down
15 changes: 6 additions & 9 deletions services/auth/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ package auth
import (
"errors"
"net/http"
"strings"

actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
Expand Down Expand Up @@ -54,17 +53,15 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
return nil, nil
}

baHead := req.Header.Get("Authorization")
if len(baHead) == 0 {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return nil, nil
}

auths := strings.SplitN(baHead, " ", 2)
if len(auths) != 2 || (strings.ToLower(auths[0]) != "basic") {
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BasicAuth == nil {
return nil, nil
}

uname, passwd, _ := base.BasicAuthDecode(auths[1])
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password

// Check if username or password is a token
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
Expand Down
7 changes: 4 additions & 3 deletions services/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
Expand Down Expand Up @@ -97,9 +98,9 @@ func parseToken(req *http.Request) (string, bool) {

// check header token
if auHead := req.Header.Get("Authorization"); auHead != "" {
auths := strings.Fields(auHead)
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
return auths[1], true
parsed, ok := httpauth.ParseAuthorizationHeader(auHead)
if ok && parsed.BearerToken != nil {
return parsed.BearerToken.Token, true
}
}
return "", false
Expand Down
17 changes: 5 additions & 12 deletions services/lfs/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/json"
lfs_module "code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log"
Expand Down Expand Up @@ -594,19 +595,11 @@ func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Rep
if authorization == "" {
return nil, errors.New("no token")
}

parts := strings.SplitN(authorization, " ", 2)
if len(parts) != 2 {
return nil, errors.New("no token")
}
tokenSHA := parts[1]
switch strings.ToLower(parts[0]) {
case "bearer":
fallthrough
case "token":
return handleLFSToken(ctx, tokenSHA, target, mode)
parsed, ok := httpauth.ParseAuthorizationHeader(authorization)
if !ok || parsed.BearerToken == nil {
return nil, errors.New("token not found")
}
return nil, errors.New("token not found")
return handleLFSToken(ctx, parsed.BearerToken.Token, target, mode)
}

func requireAuth(ctx *context.Context) {
Expand Down