From 5e360241053f6fcfb7f8b89373cba431adaf44ce Mon Sep 17 00:00:00 2001 From: John Olheiser Date: Wed, 26 Apr 2023 19:24:03 -0500 Subject: [PATCH] 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 Co-authored-by: Lunny Xiao --- modules/context/permission.go | 33 +++++++++++++++++++ routers/api/packages/api.go | 27 +++++++++++++++ routers/web/repo/attachment.go | 5 +++ routers/web/repo/http.go | 11 +++++-- services/auth/basic.go | 1 + services/lfs/locks.go | 20 +++++++++++ services/lfs/server.go | 15 +++++++++ tests/integration/api_packages_npm_test.go | 3 +- tests/integration/api_packages_nuget_test.go | 3 +- tests/integration/api_packages_pub_test.go | 3 +- .../integration/api_packages_vagrant_test.go | 3 +- 11 files changed, 117 insertions(+), 7 deletions(-) diff --git a/modules/context/permission.go b/modules/context/permission.go index 8cb5d09eb..cc53fb99e 100644 --- a/modules/context/permission.go +++ b/modules/context/permission.go @@ -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 + } + } +} diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 8bf5dbab3..d5acd3d26 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -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") diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index 9fb9cb00b..c6ea4e3cd 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -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()) diff --git a/routers/web/repo/http.go b/routers/web/repo/http.go index a01bb4f28..4e45a9b6e 100644 --- a/routers/web/repo/http.go +++ b/routers/web/repo/http.go @@ -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 } diff --git a/services/auth/basic.go b/services/auth/basic.go index dc0378090..36480568f 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -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) diff --git a/services/lfs/locks.go b/services/lfs/locks.go index d963d9ab5..1e5db6bd2 100644 --- a/services/lfs/locks.go +++ b/services/lfs/locks.go @@ -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") diff --git a/services/lfs/server.go b/services/lfs/server.go index 44de9ba74..4c69e4751 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -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 } diff --git a/tests/integration/api_packages_npm_test.go b/tests/integration/api_packages_npm_test.go index 28c14fb3b..78389b574 100644 --- a/tests/integration/api_packages_npm_test.go +++ b/tests/integration/api_packages_npm_test.go @@ -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" diff --git a/tests/integration/api_packages_nuget_test.go b/tests/integration/api_packages_nuget_test.go index a74d696f0..2240d2a5d 100644 --- a/tests/integration/api_packages_nuget_test.go +++ b/tests/integration/api_packages_nuget_test.go @@ -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" diff --git a/tests/integration/api_packages_pub_test.go b/tests/integration/api_packages_pub_test.go index 4d4ce1240..5c1cc6052 100644 --- a/tests/integration/api_packages_pub_test.go +++ b/tests/integration/api_packages_pub_test.go @@ -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" diff --git a/tests/integration/api_packages_vagrant_test.go b/tests/integration/api_packages_vagrant_test.go index b4f04b0c8..b28bfca6f 100644 --- a/tests/integration/api_packages_vagrant_test.go +++ b/tests/integration/api_packages_vagrant_test.go @@ -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"