Require repo scope for PATs for private repos and basic authentication (#24362)

> The scoped token PR just checked all API routes but in fact, some web
routes like `LFS`, git `HTTP`, container, and attachments supports basic
auth. This PR added scoped token check for them.

---------

Signed-off-by: jolheiser <john.olheiser@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
John Olheiser 2023-04-26 19:24:03 -05:00 committed by GitHub
parent 8f57aa014b
commit 5e36024105
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 117 additions and 7 deletions

View file

@ -4,6 +4,10 @@
package context
import (
"net/http"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/log"
)
@ -106,3 +110,32 @@ func RequireRepoReaderOr(unitTypes ...unit.Type) func(ctx *Context) {
ctx.NotFound(ctx.Req.URL.RequestURI(), nil)
}
}
// RequireRepoScopedToken check whether personal access token has repo scope
func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository) {
if !ctx.IsBasicAuth || ctx.Data["IsApiToken"] != true {
return
}
var err error
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if ok { // it's a personal access token but not oauth2 token
var scopeMatched bool
scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeRepo)
if err != nil {
ctx.ServerError("HasScope", err)
return
}
if !scopeMatched && !repo.IsPrivate {
scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopePublicRepo)
if err != nil {
ctx.ServerError("HasScope", err)
return
}
}
if !scopeMatched {
ctx.Error(http.StatusForbidden)
return
}
}
}

View file

@ -9,6 +9,7 @@ import (
"regexp"
"strings"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
@ -36,6 +37,32 @@ import (
func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) {
return func(ctx *context.Context) {
if ctx.Data["IsApiToken"] == true {
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if ok { // it's a personal access token but not oauth2 token
scopeMatched := false
var err error
if accessMode == perm.AccessModeRead {
scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeReadPackage)
if err != nil {
ctx.Error(http.StatusInternalServerError, "HasScope", err.Error())
return
}
} else if accessMode == perm.AccessModeWrite {
scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeWritePackage)
if err != nil {
ctx.Error(http.StatusInternalServerError, "HasScope", err.Error())
return
}
}
if !scopeMatched {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`)
ctx.Error(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin")
return
}
}
}
if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`)
ctx.Error(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin")

View file

@ -110,6 +110,11 @@ func ServeAttachment(ctx *context.Context, uuid string) {
return
}
} else { // If we have the repository we check access
context.CheckRepoScopedToken(ctx, repository)
if ctx.Written() {
return
}
perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer)
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err.Error())

View file

@ -19,7 +19,7 @@ import (
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/auth"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
@ -152,13 +152,18 @@ func httpBase(ctx *context.Context) (h *serviceHandler) {
return
}
context.CheckRepoScopedToken(ctx, repo)
if ctx.Written() {
return
}
if ctx.IsBasicAuth && ctx.Data["IsApiToken"] != true && ctx.Data["IsActionsToken"] != true {
_, err = auth.GetTwoFactorByUID(ctx.Doer.ID)
_, err = auth_model.GetTwoFactorByUID(ctx.Doer.ID)
if err == nil {
// TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented
ctx.PlainText(http.StatusUnauthorized, "Users with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password. Please create and use a personal access token on the user settings page")
return
} else if !auth.IsErrTwoFactorNotEnrolled(err) {
} else if !auth_model.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("IsErrTwoFactorNotEnrolled", err)
return
}

View file

@ -102,6 +102,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
}
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = token.Scope
return u, nil
} else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) {
log.Error("GetAccessTokenBySha: %v", err)

View file

@ -58,6 +58,11 @@ func GetListLockHandler(ctx *context.Context) {
}
repository.MustOwner(ctx)
context.CheckRepoScopedToken(ctx, repository)
if ctx.Written() {
return
}
authenticated := authenticate(ctx, repository, rv.Authorization, true, false)
if !authenticated {
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
@ -145,6 +150,11 @@ func PostLockHandler(ctx *context.Context) {
}
repository.MustOwner(ctx)
context.CheckRepoScopedToken(ctx, repository)
if ctx.Written() {
return
}
authenticated := authenticate(ctx, repository, authorization, true, true)
if !authenticated {
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
@ -212,6 +222,11 @@ func VerifyLockHandler(ctx *context.Context) {
}
repository.MustOwner(ctx)
context.CheckRepoScopedToken(ctx, repository)
if ctx.Written() {
return
}
authenticated := authenticate(ctx, repository, authorization, true, true)
if !authenticated {
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
@ -278,6 +293,11 @@ func UnLockHandler(ctx *context.Context) {
}
repository.MustOwner(ctx)
context.CheckRepoScopedToken(ctx, repository)
if ctx.Written() {
return
}
authenticated := authenticate(ctx, repository, authorization, true, true)
if !authenticated {
ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")

View file

@ -86,6 +86,11 @@ func DownloadHandler(ctx *context.Context) {
return
}
repository := getAuthenticatedRepository(ctx, rc, true)
if repository == nil {
return
}
// Support resume download using Range header
var fromByte, toByte int64
toByte = meta.Size - 1
@ -360,6 +365,11 @@ func VerifyHandler(ctx *context.Context) {
return
}
repository := getAuthenticatedRepository(ctx, rc, true)
if repository == nil {
return
}
contentStore := lfs_module.NewContentStore()
ok, err := contentStore.Verify(meta.Pointer)
@ -423,6 +433,11 @@ func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requir
return nil
}
context.CheckRepoScopedToken(ctx, repository)
if ctx.Written() {
return nil
}
return repository
}

View file

@ -11,6 +11,7 @@ import (
"strings"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/models/unittest"
@ -27,7 +28,7 @@ func TestPackageNpm(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := fmt.Sprintf("Bearer %s", getTokenForLoggedInUser(t, loginUser(t, user.Name)))
token := fmt.Sprintf("Bearer %s", getTokenForLoggedInUser(t, loginUser(t, user.Name), auth_model.AccessTokenScopePackage))
packageName := "@scope/test-package"
packageVersion := "1.0.1-pre"

View file

@ -16,6 +16,7 @@ import (
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/models/unittest"
@ -74,7 +75,7 @@ func TestPackageNuGet(t *testing.T) {
}
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, user.Name)
token := getUserToken(t, user.Name, auth_model.AccessTokenScopePackage)
packageName := "test.package"
packageVersion := "1.0.3"

View file

@ -15,6 +15,7 @@ import (
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/models/unittest"
@ -30,7 +31,7 @@ func TestPackagePub(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := "Bearer " + getUserToken(t, user.Name)
token := "Bearer " + getUserToken(t, user.Name, auth_model.AccessTokenScopePackage)
packageName := "test_package"
packageVersion := "1.0.1"

View file

@ -12,6 +12,7 @@ import (
"strings"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/models/unittest"
@ -27,7 +28,7 @@ func TestPackageVagrant(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := "Bearer " + getUserToken(t, user.Name)
token := "Bearer " + getUserToken(t, user.Name, auth_model.AccessTokenScopePackage)
packageName := "test_package"
packageVersion := "1.0.1"