From 5e360241053f6fcfb7f8b89373cba431adaf44ce Mon Sep 17 00:00:00 2001
From: John Olheiser <john.olheiser@gmail.com>
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 <john.olheiser@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
---
 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 8cb5d09eb9..cc53fb99ed 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 8bf5dbab35..d5acd3d261 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 9fb9cb00bf..c6ea4e3cdb 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 a01bb4f28e..4e45a9b6e2 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 dc03780905..36480568ff 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 d963d9ab57..1e5db6bd20 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 44de9ba74f..4c69e47512 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 28c14fb3b8..78389b5740 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 a74d696f03..2240d2a5d4 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 4d4ce12402..5c1cc6052f 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 b4f04b0c89..b28bfca6f0 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"