Skip to content

Commit ce10ec2

Browse files
Gustedforgejo-backport-action
authored and
forgejo-backport-action
committed
[SEC] Ensure propagation of API scopes for Conan and Container authentication
- The Conan and Container packages use a different type of authentication. It first authenticates via the regular way (api tokens or user:password, handled via `auth.Basic`) and then generates a JWT token that is used by the package software (such as Docker) to do the action they wanted to do. This JWT token didn't properly propagate the API scopes that the token was generated for, and thus could lead to a 'scope escalation' within the Conan and Container packages, read access to write access. - Store the API scope in the JWT token, so it can be propagated on subsequent calls that uses that JWT token. - Integration test added. - Resolves go-gitea#5128 (cherry picked from commit 5a871f6)
1 parent 619fe48 commit ce10ec2

File tree

8 files changed

+151
-12
lines changed

8 files changed

+151
-12
lines changed

release-notes/5149.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The scope of application tokens is not verified when writing containers or Conan packages. This is of no consequence when the user associated with the application token does not have write access to packages. If the user has write access to packages, such a token can be used to write containers and Conan packages.

routers/api/packages/conan/auth.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func (a *Auth) Name() string {
2222

2323
// Verify extracts the user from the Bearer token
2424
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
25-
uid, err := packages.ParseAuthorizationToken(req)
25+
uid, scope, err := packages.ParseAuthorizationToken(req)
2626
if err != nil {
2727
log.Trace("ParseAuthorizationToken: %v", err)
2828
return nil, err
@@ -32,6 +32,12 @@ func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataS
3232
return nil, nil
3333
}
3434

35+
// Propagate scope of the authorization token.
36+
if scope != "" {
37+
store.GetData()["IsApiToken"] = true
38+
store.GetData()["ApiTokenScope"] = scope
39+
}
40+
3541
u, err := user_model.GetUserByID(req.Context(), uid)
3642
if err != nil {
3743
log.Error("GetUserByID: %v", err)

routers/api/packages/conan/conan.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"time"
1313

14+
auth_model "code.gitea.io/gitea/models/auth"
1415
"code.gitea.io/gitea/models/db"
1516
packages_model "code.gitea.io/gitea/models/packages"
1617
conan_model "code.gitea.io/gitea/models/packages/conan"
@@ -117,7 +118,10 @@ func Authenticate(ctx *context.Context) {
117118
return
118119
}
119120

120-
token, err := packages_service.CreateAuthorizationToken(ctx.Doer)
121+
// If there's an API scope, ensure it propagates.
122+
scope, _ := ctx.Data.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope)
123+
124+
token, err := packages_service.CreateAuthorizationToken(ctx.Doer, scope)
121125
if err != nil {
122126
apiError(ctx, http.StatusInternalServerError, err)
123127
return

routers/api/packages/container/auth.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func (a *Auth) Name() string {
2323
// Verify extracts the user from the Bearer token
2424
// If it's an anonymous session a ghost user is returned
2525
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
26-
uid, err := packages.ParseAuthorizationToken(req)
26+
uid, scope, err := packages.ParseAuthorizationToken(req)
2727
if err != nil {
2828
log.Trace("ParseAuthorizationToken: %v", err)
2929
return nil, err
@@ -33,6 +33,12 @@ func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataS
3333
return nil, nil
3434
}
3535

36+
// Propagate scope of the authorization token.
37+
if scope != "" {
38+
store.GetData()["IsApiToken"] = true
39+
store.GetData()["ApiTokenScope"] = scope
40+
}
41+
3642
u, err := user_model.GetPossibleUserByID(req.Context(), uid)
3743
if err != nil {
3844
log.Error("GetPossibleUserByID: %v", err)

routers/api/packages/container/container.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"strconv"
1515
"strings"
1616

17+
auth_model "code.gitea.io/gitea/models/auth"
1718
packages_model "code.gitea.io/gitea/models/packages"
1819
container_model "code.gitea.io/gitea/models/packages/container"
1920
user_model "code.gitea.io/gitea/models/user"
@@ -154,7 +155,10 @@ func Authenticate(ctx *context.Context) {
154155
u = user_model.NewGhostUser()
155156
}
156157

157-
token, err := packages_service.CreateAuthorizationToken(u)
158+
// If there's an API scope, ensure it propagates.
159+
scope, _ := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
160+
161+
token, err := packages_service.CreateAuthorizationToken(u, scope)
158162
if err != nil {
159163
apiError(ctx, http.StatusInternalServerError, err)
160164
return

services/packages/auth.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"time"
1111

12+
auth_model "code.gitea.io/gitea/models/auth"
1213
user_model "code.gitea.io/gitea/models/user"
1314
"code.gitea.io/gitea/modules/log"
1415
"code.gitea.io/gitea/modules/setting"
@@ -19,9 +20,10 @@ import (
1920
type packageClaims struct {
2021
jwt.RegisteredClaims
2122
UserID int64
23+
Scope auth_model.AccessTokenScope
2224
}
2325

24-
func CreateAuthorizationToken(u *user_model.User) (string, error) {
26+
func CreateAuthorizationToken(u *user_model.User, scope auth_model.AccessTokenScope) (string, error) {
2527
now := time.Now()
2628

2729
claims := packageClaims{
@@ -30,6 +32,7 @@ func CreateAuthorizationToken(u *user_model.User) (string, error) {
3032
NotBefore: jwt.NewNumericDate(now),
3133
},
3234
UserID: u.ID,
35+
Scope: scope,
3336
}
3437
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
3538

@@ -41,16 +44,16 @@ func CreateAuthorizationToken(u *user_model.User) (string, error) {
4144
return tokenString, nil
4245
}
4346

44-
func ParseAuthorizationToken(req *http.Request) (int64, error) {
47+
func ParseAuthorizationToken(req *http.Request) (int64, auth_model.AccessTokenScope, error) {
4548
h := req.Header.Get("Authorization")
4649
if h == "" {
47-
return 0, nil
50+
return 0, "", nil
4851
}
4952

5053
parts := strings.SplitN(h, " ", 2)
5154
if len(parts) != 2 {
5255
log.Error("split token failed: %s", h)
53-
return 0, fmt.Errorf("split token failed")
56+
return 0, "", fmt.Errorf("split token failed")
5457
}
5558

5659
token, err := jwt.ParseWithClaims(parts[1], &packageClaims{}, func(t *jwt.Token) (any, error) {
@@ -60,13 +63,13 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
6063
return setting.GetGeneralTokenSigningSecret(), nil
6164
})
6265
if err != nil {
63-
return 0, err
66+
return 0, "", err
6467
}
6568

6669
c, ok := token.Claims.(*packageClaims)
6770
if !token.Valid || !ok {
68-
return 0, fmt.Errorf("invalid token claim")
71+
return 0, "", fmt.Errorf("invalid token claim")
6972
}
7073

71-
return c.UserID, nil
74+
return c.UserID, c.Scope, nil
7275
}

tests/integration/api_packages_conan_test.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"testing"
1212
"time"
1313

14+
auth_model "code.gitea.io/gitea/models/auth"
1415
"code.gitea.io/gitea/models/db"
1516
"code.gitea.io/gitea/models/packages"
1617
conan_model "code.gitea.io/gitea/models/packages/conan"
@@ -224,6 +225,45 @@ func TestPackageConan(t *testing.T) {
224225
assert.Equal(t, "revisions", resp.Header().Get("X-Conan-Server-Capabilities"))
225226
})
226227

228+
t.Run("Token Scope Authentication", func(t *testing.T) {
229+
defer tests.PrintCurrentTest(t)()
230+
231+
session := loginUser(t, user.Name)
232+
233+
testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) {
234+
t.Helper()
235+
236+
token := getTokenForLoggedInUser(t, session, scope)
237+
238+
req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/users/authenticate", url)).
239+
AddTokenAuth(token)
240+
resp := MakeRequest(t, req, http.StatusOK)
241+
242+
body := resp.Body.String()
243+
assert.NotEmpty(t, body)
244+
245+
recipeURL := fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", url, "TestScope", version1, "testing", channel1)
246+
247+
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/upload_urls", recipeURL), map[string]int64{
248+
conanfileName: 64,
249+
"removed.txt": 0,
250+
}).AddTokenAuth(token)
251+
MakeRequest(t, req, expectedStatusCode)
252+
}
253+
254+
t.Run("Read permission", func(t *testing.T) {
255+
defer tests.PrintCurrentTest(t)()
256+
257+
testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized)
258+
})
259+
260+
t.Run("Write permission", func(t *testing.T) {
261+
defer tests.PrintCurrentTest(t)()
262+
263+
testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusOK)
264+
})
265+
})
266+
227267
token := ""
228268

229269
t.Run("Authenticate", func(t *testing.T) {
@@ -481,6 +521,43 @@ func TestPackageConan(t *testing.T) {
481521

482522
token := ""
483523

524+
t.Run("Token Scope Authentication", func(t *testing.T) {
525+
defer tests.PrintCurrentTest(t)()
526+
527+
session := loginUser(t, user.Name)
528+
529+
testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) {
530+
t.Helper()
531+
532+
token := getTokenForLoggedInUser(t, session, scope)
533+
534+
req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/authenticate", url)).
535+
AddTokenAuth(token)
536+
resp := MakeRequest(t, req, http.StatusOK)
537+
538+
body := resp.Body.String()
539+
assert.NotEmpty(t, body)
540+
541+
recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", url, "TestScope", version1, "testing", channel1, revision1)
542+
543+
req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", recipeURL, conanfileName), strings.NewReader("Doesn't need to be valid")).
544+
AddTokenAuth("Bearer " + body)
545+
MakeRequest(t, req, expectedStatusCode)
546+
}
547+
548+
t.Run("Read permission", func(t *testing.T) {
549+
defer tests.PrintCurrentTest(t)()
550+
551+
testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized)
552+
})
553+
554+
t.Run("Write permission", func(t *testing.T) {
555+
defer tests.PrintCurrentTest(t)()
556+
557+
testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusCreated)
558+
})
559+
})
560+
484561
t.Run("Authenticate", func(t *testing.T) {
485562
defer tests.PrintCurrentTest(t)()
486563

@@ -512,7 +589,7 @@ func TestPackageConan(t *testing.T) {
512589

513590
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConan)
514591
require.NoError(t, err)
515-
assert.Len(t, pvs, 2)
592+
assert.Len(t, pvs, 3)
516593
})
517594
})
518595

tests/integration/api_packages_container_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func TestPackageContainer(t *testing.T) {
7878
indexManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageIndex + `","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"` + manifestDigest + `","platform":{"os":"linux","architecture":"arm","variant":"v7"}},{"mediaType":"` + oci.MediaTypeImageManifest + `","digest":"` + untaggedManifestDigest + `","platform":{"os":"linux","architecture":"arm64","variant":"v8"}}]}`
7979

8080
anonymousToken := ""
81+
readUserToken := ""
8182
userToken := ""
8283

8384
t.Run("Authenticate", func(t *testing.T) {
@@ -140,6 +141,30 @@ func TestPackageContainer(t *testing.T) {
140141
req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
141142
AddTokenAuth(userToken)
142143
MakeRequest(t, req, http.StatusOK)
144+
145+
// Token that should enforce the read scope.
146+
t.Run("Read scope", func(t *testing.T) {
147+
defer tests.PrintCurrentTest(t)()
148+
149+
session := loginUser(t, user.Name)
150+
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage)
151+
152+
req := NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL))
153+
req.SetBasicAuth(user.Name, token)
154+
155+
resp := MakeRequest(t, req, http.StatusOK)
156+
157+
tokenResponse := &TokenResponse{}
158+
DecodeJSON(t, resp, &tokenResponse)
159+
160+
assert.NotEmpty(t, tokenResponse.Token)
161+
162+
readUserToken = fmt.Sprintf("Bearer %s", tokenResponse.Token)
163+
164+
req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
165+
AddTokenAuth(readUserToken)
166+
MakeRequest(t, req, http.StatusOK)
167+
})
143168
})
144169
})
145170

@@ -163,6 +188,10 @@ func TestPackageContainer(t *testing.T) {
163188
AddTokenAuth(anonymousToken)
164189
MakeRequest(t, req, http.StatusUnauthorized)
165190

191+
req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)).
192+
AddTokenAuth(readUserToken)
193+
MakeRequest(t, req, http.StatusUnauthorized)
194+
166195
req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, unknownDigest), bytes.NewReader(blobContent)).
167196
AddTokenAuth(userToken)
168197
MakeRequest(t, req, http.StatusBadRequest)
@@ -318,6 +347,11 @@ func TestPackageContainer(t *testing.T) {
318347
SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
319348
MakeRequest(t, req, http.StatusUnauthorized)
320349

350+
req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
351+
AddTokenAuth(readUserToken).
352+
SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
353+
MakeRequest(t, req, http.StatusUnauthorized)
354+
321355
req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
322356
AddTokenAuth(userToken).
323357
SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
@@ -521,6 +555,10 @@ func TestPackageContainer(t *testing.T) {
521555
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
522556
AddTokenAuth(anonymousToken)
523557
MakeRequest(t, req, http.StatusOK)
558+
559+
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
560+
AddTokenAuth(readUserToken)
561+
MakeRequest(t, req, http.StatusOK)
524562
})
525563

526564
t.Run("GetBlob", func(t *testing.T) {

0 commit comments

Comments
 (0)