Skip to content

Commit 8b82233

Browse files
authored
fix(security): validate blob-mount source project and reject tokens missing iat (#23270)
Follow-up to #22900. tokenIssuedAfterProjectCreation only validated info.ProjectName. A cross-repository blob mount (POST /v2/<repo>/blobs/uploads/?from=<src>) also requires pull access to the source project, so a token issued before the source project was deleted and recreated with the same name would still be accepted. Validate the issued-at time against the blob-mount source project as well. Also fail closed when the token has no iat claim: claims.IssuedAt is a *NumericDate pointer and was dereferenced unconditionally, panicking the request handler for a token without iat instead of rejecting it. Signed-off-by: Vadim Bauer <vb@container-registry.com>
1 parent 382dd69 commit 8b82233

2 files changed

Lines changed: 66 additions & 12 deletions

File tree

src/server/middleware/security/v2_token.go

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,22 +79,40 @@ func (vt *v2Token) Generate(req *http.Request) security.Context {
7979
}
8080

8181
// tokenIssuedAfterProjectCreation prevents tokens from a deleted project
82-
// granting access to a new project recreated with the same name.
82+
// granting access to a new project recreated with the same name. It validates
83+
// every project the request is authorized against, including the source
84+
// project of a cross-repository blob mount.
8385
func tokenIssuedAfterProjectCreation(ctx context.Context, logger *log.Logger, claims *v2TokenClaims) bool {
84-
info := lib.GetArtifactInfo(ctx)
85-
if info.ProjectName == "" {
86-
return true
87-
}
88-
p, err := project_ctl.Ctl.GetByName(ctx, info.ProjectName)
89-
if err != nil {
90-
logger.Warningf("failed to get project %q for token validation: %v", info.ProjectName, err)
86+
// Fail closed: a token without an iat claim cannot be validated against the
87+
// project creation time, and claims.IssuedAt is a pointer that would panic
88+
// on dereference below.
89+
if claims.IssuedAt == nil {
90+
logger.Warningf("bearer token missing iat claim, rejecting")
9191
return false
9292
}
9393
iat := claims.IssuedAt.Time
94-
if iat.Add(common.JwtLeeway).Before(p.CreationTime) {
95-
logger.Warningf("bearer token issued at %v is before project %q creation time %v, rejecting",
96-
iat, info.ProjectName, p.CreationTime)
97-
return false
94+
95+
info := lib.GetArtifactInfo(ctx)
96+
// A blob mount (POST .../blobs/uploads/?from=<repo>) also requires pull
97+
// access to the source project, so its creation time must be checked too.
98+
names := []string{info.ProjectName}
99+
if info.BlobMountProjectName != "" && info.BlobMountProjectName != info.ProjectName {
100+
names = append(names, info.BlobMountProjectName)
101+
}
102+
for _, projectName := range names {
103+
if projectName == "" {
104+
continue
105+
}
106+
p, err := project_ctl.Ctl.GetByName(ctx, projectName)
107+
if err != nil {
108+
logger.Warningf("failed to get project %q for token validation: %v", projectName, err)
109+
return false
110+
}
111+
if iat.Add(common.JwtLeeway).Before(p.CreationTime) {
112+
logger.Warningf("bearer token issued at %v is before project %q creation time %v, rejecting",
113+
iat, projectName, p.CreationTime)
114+
return false
115+
}
98116
}
99117
return true
100118
}

src/server/middleware/security/v2_token_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,39 @@ func TestTokenIssuedAfterProjectCreation(t *testing.T) {
101101
})
102102
}
103103
}
104+
105+
func TestTokenIssuedAfterProjectCreation_NilIAT(t *testing.T) {
106+
logger := log.DefaultLogger()
107+
origCtl := project_ctl.Ctl
108+
defer func() { project_ctl.Ctl = origCtl }()
109+
project_ctl.Ctl = &projecttesting.Controller{}
110+
111+
ctx := lib.WithArtifactInfo(context.Background(), lib.ArtifactInfo{ProjectName: "myproject"})
112+
claims := &v2TokenClaims{} // no iat
113+
114+
assert.False(t, tokenIssuedAfterProjectCreation(ctx, logger, claims))
115+
}
116+
117+
func TestTokenIssuedAfterProjectCreation_BlobMountSource(t *testing.T) {
118+
logger := log.DefaultLogger()
119+
created := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
120+
iat := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) // after dest creation
121+
122+
// Destination created before iat (ok); source recreated after iat (must reject).
123+
dest := &proModels.Project{Name: "dest", CreationTime: created}
124+
src := &proModels.Project{Name: "src", CreationTime: iat.Add(24 * time.Hour)}
125+
126+
origCtl := project_ctl.Ctl
127+
defer func() { project_ctl.Ctl = origCtl }()
128+
mockCtl := &projecttesting.Controller{}
129+
project_ctl.Ctl = mockCtl
130+
mockCtl.On("GetByName", mock.Anything, "dest").Return(dest, nil)
131+
mockCtl.On("GetByName", mock.Anything, "src").Return(src, nil)
132+
133+
ctx := lib.WithArtifactInfo(context.Background(), lib.ArtifactInfo{
134+
ProjectName: "dest",
135+
BlobMountProjectName: "src",
136+
})
137+
138+
assert.False(t, tokenIssuedAfterProjectCreation(ctx, logger, makeClaimsWithIAT(iat)))
139+
}

0 commit comments

Comments
 (0)