-
-
Notifications
You must be signed in to change notification settings - Fork 6.6k
Fix registration token API #36793
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix registration token API #36793
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -301,48 +301,56 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) { | |||||||||||||
| // if a token is not being used, reqToken will enforce other sign in methods | ||||||||||||||
| func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) { | ||||||||||||||
| return func(ctx *context.APIContext) { | ||||||||||||||
| // no scope required | ||||||||||||||
| if len(requiredScopeCategories) == 0 { | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Need OAuth2 token to be present. | ||||||||||||||
| scope, scopeExists := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) | ||||||||||||||
| if ctx.Data["IsApiToken"] != true || !scopeExists { | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // use the http method to determine the access level | ||||||||||||||
| requiredScopeLevel := auth_model.Read | ||||||||||||||
| if ctx.Req.Method == http.MethodPost || ctx.Req.Method == http.MethodPut || ctx.Req.Method == http.MethodPatch || ctx.Req.Method == http.MethodDelete { | ||||||||||||||
| requiredScopeLevel = auth_model.Write | ||||||||||||||
| } | ||||||||||||||
| checkTokenScopes(ctx, requiredScopeLevel, requiredScopeCategories...) | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // get the required scope for the given access level and category | ||||||||||||||
| requiredScopes := auth_model.GetRequiredScopes(requiredScopeLevel, requiredScopeCategories...) | ||||||||||||||
| allow, err := scope.HasScope(requiredScopes...) | ||||||||||||||
| if err != nil { | ||||||||||||||
| ctx.APIError(http.StatusForbidden, "checking scope failed: "+err.Error()) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
| func tokenRequiresScopesLevel(requiredScopeLevel auth_model.AccessTokenScopeLevel, requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) { | ||||||||||||||
| return func(ctx *context.APIContext) { | ||||||||||||||
| checkTokenScopes(ctx, requiredScopeLevel, requiredScopeCategories...) | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if !allow { | ||||||||||||||
| ctx.APIError(http.StatusForbidden, fmt.Sprintf("token does not have at least one of required scope(s), required=%v, token scope=%v", requiredScopes, scope)) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
| func checkTokenScopes(ctx *context.APIContext, requiredScopeLevel auth_model.AccessTokenScopeLevel, requiredScopeCategories ...auth_model.AccessTokenScopeCategory) { | ||||||||||||||
| // no scope required | ||||||||||||||
| if len(requiredScopeCategories) == 0 { | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| ctx.Data["requiredScopeCategories"] = requiredScopeCategories | ||||||||||||||
| // Need OAuth2 token to be present. | ||||||||||||||
| scope, scopeExists := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) | ||||||||||||||
| if ctx.Data["IsApiToken"] != true || !scopeExists { | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // check if scope only applies to public resources | ||||||||||||||
| publicOnly, err := scope.PublicOnly() | ||||||||||||||
| if err != nil { | ||||||||||||||
| ctx.APIError(http.StatusForbidden, "parsing public resource scope failed: "+err.Error()) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
| // get the required scope for the given access level and category | ||||||||||||||
| requiredScopes := auth_model.GetRequiredScopes(requiredScopeLevel, requiredScopeCategories...) | ||||||||||||||
| allow, err := scope.HasScope(requiredScopes...) | ||||||||||||||
| if err != nil { | ||||||||||||||
| ctx.APIError(http.StatusForbidden, "checking scope failed: "+err.Error()) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if !allow { | ||||||||||||||
| ctx.APIError(http.StatusForbidden, fmt.Sprintf("token does not have at least one of required scope(s), required=%v, token scope=%v", requiredScopes, scope)) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // assign to true so that those searching should only filter public repositories/users/organizations | ||||||||||||||
| ctx.PublicOnly = publicOnly | ||||||||||||||
| ctx.Data["requiredScopeCategories"] = requiredScopeCategories | ||||||||||||||
|
|
||||||||||||||
| // check if scope only applies to public resources | ||||||||||||||
| publicOnly, err := scope.PublicOnly() | ||||||||||||||
| if err != nil { | ||||||||||||||
| ctx.APIError(http.StatusForbidden, "parsing public resource scope failed: "+err.Error()) | ||||||||||||||
| return | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // assign to true so that those searching should only filter public repositories/users/organizations | ||||||||||||||
| ctx.PublicOnly = publicOnly | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Contexter middleware already checks token for user sign in process. | ||||||||||||||
|
|
@@ -897,6 +905,7 @@ func Routes() *web.Router { | |||||||||||||
| m *web.Router, | ||||||||||||||
| reqChecker func(ctx *context.APIContext), | ||||||||||||||
| act actions.API, | ||||||||||||||
| requiredScopeCategories ...auth_model.AccessTokenScopeCategory, | ||||||||||||||
| ) { | ||||||||||||||
| m.Group("/actions", func() { | ||||||||||||||
| m.Group("/secrets", func() { | ||||||||||||||
|
|
@@ -917,7 +926,7 @@ func Routes() *web.Router { | |||||||||||||
|
|
||||||||||||||
| m.Group("/runners", func() { | ||||||||||||||
| m.Get("", reqToken(), reqChecker, act.ListRunners) | ||||||||||||||
| m.Get("/registration-token", reqToken(), reqChecker, act.GetRegistrationToken) | ||||||||||||||
| m.Get("/registration-token", reqToken(), reqChecker, tokenRequiresScopesLevel(auth_model.Write, requiredScopeCategories...), act.GetRegistrationToken) | ||||||||||||||
|
||||||||||||||
| m.Get("/registration-token", reqToken(), reqChecker, tokenRequiresScopesLevel(auth_model.Write, requiredScopeCategories...), act.GetRegistrationToken) | |
| m.Get("/registration-token", reqToken(), reqChecker, tokenRequiresScopesLevel(auth_model.Write, requiredScopeCategories...), func(ctx *context.APIContext) { | |
| // For security and HTTP semantics reasons, registration token creation | |
| // must use POST. GET is not allowed to perform this state-changing action. | |
| ctx.Status(http.StatusMethodNotAllowed) | |
| }) |
Copilot
AI
Mar 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR description mentions removing the GET method as a resolution, but the route is still present (now with stricter scope enforcement). If the intended fix is to eliminate GET entirely, this should be removed/disabled (and related swagger/docs/tests updated) rather than kept.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,10 @@ func TestAPIActionsRunner(t *testing.T) { | |
| func testActionsRunnerAdmin(t *testing.T) { | ||
| defer tests.PrepareTestEnv(t)() | ||
| adminUsername := "user1" | ||
| readToken := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadAdmin) | ||
| readReq := NewRequest(t, "GET", "/api/v1/admin/runners/registration-token").AddTokenAuth(readToken) | ||
| MakeRequest(t, readReq, http.StatusForbidden) | ||
|
|
||
|
Comment on lines
+30
to
+33
|
||
| token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) | ||
| req := NewRequest(t, "POST", "/api/v1/admin/actions/runners/registration-token").AddTokenAuth(token) | ||
| tokenResp := MakeRequest(t, req, http.StatusOK) | ||
|
|
@@ -76,6 +80,10 @@ func testActionsRunnerAdmin(t *testing.T) { | |
| func testActionsRunnerUser(t *testing.T) { | ||
| defer tests.PrepareTestEnv(t)() | ||
| userUsername := "user1" | ||
| readToken := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadUser) | ||
| readReq := NewRequest(t, "GET", "/api/v1/user/actions/runners/registration-token").AddTokenAuth(readToken) | ||
| MakeRequest(t, readReq, http.StatusForbidden) | ||
|
|
||
| token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteUser) | ||
| req := NewRequest(t, "POST", "/api/v1/user/actions/runners/registration-token").AddTokenAuth(token) | ||
| tokenResp := MakeRequest(t, req, http.StatusOK) | ||
|
|
@@ -202,6 +210,13 @@ func testActionsRunnerOwner(t *testing.T) { | |
| MakeRequest(t, req, http.StatusForbidden) | ||
| }) | ||
|
|
||
| t.Run("GetRegistrationTokenReadScopeForbidden", func(t *testing.T) { | ||
| userUsername := "user2" | ||
| token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization) | ||
| req := NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runners/registration-token").AddTokenAuth(token) | ||
| MakeRequest(t, req, http.StatusForbidden) | ||
| }) | ||
|
|
||
| t.Run("GetRepoScopeForbidden", func(t *testing.T) { | ||
| userUsername := "user2" | ||
| token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository) | ||
|
|
@@ -306,6 +321,13 @@ func testActionsRunnerRepo(t *testing.T) { | |
| MakeRequest(t, req, http.StatusForbidden) | ||
| }) | ||
|
|
||
| t.Run("GetRegistrationTokenReadScopeForbidden", func(t *testing.T) { | ||
| userUsername := "user2" | ||
| token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository) | ||
| req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/actions/runners/registration-token").AddTokenAuth(token) | ||
| MakeRequest(t, req, http.StatusForbidden) | ||
| }) | ||
|
|
||
| t.Run("GetOrganizationScopeForbidden", func(t *testing.T) { | ||
| userUsername := "user2" | ||
| token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
addActionsRoutesnow takesrequiredScopeCategoriesas a variadic argument, butcheckTokenScopesbecomes a no-op when the slice is empty. That makes it easy for a future call site to omit the category and accidentally reintroduce the original scope-bypass onGET .../registration-token. Consider making this a required (non-variadic) parameter foraddActionsRoutes, or defensively failing fast when it’s empty for endpoints that must enforce scopes.