mirror of
https://github.com/go-gitea/gitea.git
synced 2025-07-07 13:20:28 +00:00
Fix http auth header parsing (#34936)
Using `strings.EqualFold` is wrong in many cases.
This commit is contained in:
parent
8cbec63cc7
commit
d6d643fe86
47
modules/auth/httpauth/httpauth.go
Normal file
47
modules/auth/httpauth/httpauth.go
Normal file
@ -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
modules/auth/httpauth/httpauth_test.go
Normal file
43
modules/auth/httpauth/httpauth_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -8,13 +8,10 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash"
|
"hash"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
@ -36,19 +33,6 @@ func ShortSha(sha1 string) string {
|
|||||||
return util.TruncateRunes(sha1, 10)
|
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
|
// VerifyTimeLimitCode verify time limit code
|
||||||
func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool {
|
func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool {
|
||||||
if len(code) <= 18 {
|
if len(code) <= 18 {
|
||||||
|
@ -26,25 +26,6 @@ func TestShortSha(t *testing.T) {
|
|||||||
assert.Equal(t, "veryverylo", ShortSha("veryverylong"))
|
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) {
|
func TestVerifyTimeLimitCode(t *testing.T) {
|
||||||
defer test.MockVariableValue(&setting.InstallLock, true)()
|
defer test.MockVariableValue(&setting.InstallLock, true)()
|
||||||
initGeneralSecret := func(secret string) {
|
initGeneralSecret := func(secret string) {
|
||||||
|
@ -110,3 +110,24 @@ func SplitTrimSpace(input, sep string) []string {
|
|||||||
}
|
}
|
||||||
return stringList
|
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
|
||||||
|
}
|
||||||
|
@ -4,18 +4,16 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/auth"
|
"code.gitea.io/gitea/models/auth"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
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/json"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
@ -108,9 +106,8 @@ func InfoOAuth(ctx *context.Context) {
|
|||||||
|
|
||||||
var accessTokenScope auth.AccessTokenScope
|
var accessTokenScope auth.AccessTokenScope
|
||||||
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
|
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
|
||||||
auths := strings.Fields(auHead)
|
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
|
||||||
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
|
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
|
||||||
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,18 +124,12 @@ func InfoOAuth(ctx *context.Context) {
|
|||||||
ctx.JSON(http.StatusOK, response)
|
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
|
// IntrospectOAuth introspects an oauth token
|
||||||
func IntrospectOAuth(ctx *context.Context) {
|
func IntrospectOAuth(ctx *context.Context) {
|
||||||
clientIDValid := false
|
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)
|
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
|
||||||
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
|
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
|
||||||
// this is likely a database error; log it and respond without details
|
// this is likely a database error; log it and respond without details
|
||||||
@ -465,16 +456,16 @@ func AccessTokenOAuth(ctx *context.Context) {
|
|||||||
form := *web.GetForm(ctx).(*forms.AccessTokenForm)
|
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 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 == "" {
|
if form.ClientID == "" || form.ClientSecret == "" {
|
||||||
authHeader := ctx.Req.Header.Get("Authorization")
|
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
|
||||||
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
|
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
|
||||||
clientID, clientSecret, err := base.BasicAuthDecode(authData)
|
if !ok || parsed.BasicAuth == nil {
|
||||||
if err != nil {
|
|
||||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||||
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
|
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
|
||||||
ErrorDescription: "cannot parse basic auth header",
|
ErrorDescription: "cannot parse basic auth header",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
|
||||||
// validate that any fields present in the form match the Basic auth header
|
// validate that any fields present in the form match the Basic auth header
|
||||||
if form.ClientID != "" && form.ClientID != clientID {
|
if form.ClientID != "" && form.ClientID != clientID {
|
||||||
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
|
||||||
|
@ -7,12 +7,11 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
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/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
@ -54,17 +53,15 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
baHead := req.Header.Get("Authorization")
|
authHeader := req.Header.Get("Authorization")
|
||||||
if len(baHead) == 0 {
|
if authHeader == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
|
||||||
auths := strings.SplitN(baHead, " ", 2)
|
if !ok || parsed.BasicAuth == nil {
|
||||||
if len(auths) != 2 || (strings.ToLower(auths[0]) != "basic") {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
|
||||||
uname, passwd, _ := base.BasicAuthDecode(auths[1])
|
|
||||||
|
|
||||||
// Check if username or password is a token
|
// Check if username or password is a token
|
||||||
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
|
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
|
||||||
|
@ -13,6 +13,7 @@ import (
|
|||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
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/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
@ -97,9 +98,9 @@ func parseToken(req *http.Request) (string, bool) {
|
|||||||
|
|
||||||
// check header token
|
// check header token
|
||||||
if auHead := req.Header.Get("Authorization"); auHead != "" {
|
if auHead := req.Header.Get("Authorization"); auHead != "" {
|
||||||
auths := strings.Fields(auHead)
|
parsed, ok := httpauth.ParseAuthorizationHeader(auHead)
|
||||||
if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
|
if ok && parsed.BearerToken != nil {
|
||||||
return auths[1], true
|
return parsed.BearerToken.Token, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
|
@ -27,6 +27,7 @@ import (
|
|||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unit"
|
"code.gitea.io/gitea/models/unit"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/auth/httpauth"
|
||||||
"code.gitea.io/gitea/modules/json"
|
"code.gitea.io/gitea/modules/json"
|
||||||
lfs_module "code.gitea.io/gitea/modules/lfs"
|
lfs_module "code.gitea.io/gitea/modules/lfs"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
@ -594,19 +595,11 @@ func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Rep
|
|||||||
if authorization == "" {
|
if authorization == "" {
|
||||||
return nil, errors.New("no token")
|
return nil, errors.New("no token")
|
||||||
}
|
}
|
||||||
|
parsed, ok := httpauth.ParseAuthorizationHeader(authorization)
|
||||||
parts := strings.SplitN(authorization, " ", 2)
|
if !ok || parsed.BearerToken == nil {
|
||||||
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)
|
|
||||||
}
|
|
||||||
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) {
|
func requireAuth(ctx *context.Context) {
|
||||||
|
Loading…
Reference in New Issue
Block a user