fix: Confine created API token scopes to the calling token's own

CreateAPIToken capped a new token's role at the creator's role but never
capped its scopes against the calling credential's scopes, and POST
/api/tokens required only settings:write. A token holding settings:write
alone could therefore mint a token holding keys:write or secrets:write,
reaching every SSH private key and vault secret in the instance.

createToken now refuses (403 scope_confinement) when the calling
credential is itself a token and any requested scope is not satisfied by
that token's own scopes, via services.ScopeSatisfied so servers:write
still permits granting servers:read. Cookie sessions are unaffected,
since their authority is the user's role. Also correct the createToken
doc comment, which claimed the scope cap already existed.

Also document why Hint stores 5 hex characters of the token secret.
This commit is contained in:
2026-08-13 08:31:07 +00:00
parent f6988b0f1e
commit 965419b2b8
2 changed files with 65 additions and 1 deletions
+27 -1
View File
@@ -56,7 +56,7 @@ func listTokenScopes(c *gin.Context) {
// createToken godoc
//
// @Summary Create an API token
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role and scopes cannot exceed the creator's own.
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role cannot exceed the creator's own; when the request is itself token-authenticated, its scopes cannot exceed the calling token's scopes either.
// @Tags tokens
// @Accept json
// @Produce json
@@ -82,6 +82,32 @@ func createToken(c *gin.Context) {
return
}
// A token-authenticated request may only mint a token whose scopes are a
// subset of its own. Role is capped against the creating *user* below (in
// services.CreateAPIToken), but a role cap alone does not confine scopes —
// without this, a CI token holding only settings:write could mint a token
// holding keys:write and secrets:write, since minting only ever required
// settings:write and never checked what the caller itself could reach. A
// cookie session skips this: its authority is the user's role, not a
// scope list.
if auth.IsToken(c) {
callerScopes := auth.Scopes(c)
var excess []string
for _, s := range body.Scopes {
if !services.ScopeSatisfied(callerScopes, s) {
excess = append(excess, s)
}
}
if len(excess) > 0 {
c.JSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("requested scopes exceed the calling token's own scopes: %v", excess),
"code": "scope_confinement",
"excess_scopes": excess,
})
return
}
}
tok, plaintext, err := services.CreateAPIToken(
auth.InstanceID(c), auth.UserID(c),
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),