Skip to content

Commit 89e1c4b

Browse files
authored
fix(security): reject bearer tokens issued before project creation (#22900)
Prevents authorization bypass when a project is deleted and recreated with the same name. Compares token issued-at (iat) against the current project's creation_time; tokens older than the project are rejected. Signed-off-by: Vadim Bauer <vb@container-registry.com>
1 parent 7ee035b commit 89e1c4b

File tree

2 files changed

+94
-0
lines changed

2 files changed

+94
-0
lines changed

src/server/middleware/security/v2_token.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package security
1616

1717
import (
18+
"context"
1819
"net/http"
1920
"strings"
2021

@@ -25,7 +26,9 @@ import (
2526
"github.com/goharbor/harbor/src/common"
2627
"github.com/goharbor/harbor/src/common/security"
2728
"github.com/goharbor/harbor/src/common/security/v2token"
29+
project_ctl "github.com/goharbor/harbor/src/controller/project"
2830
svc_token "github.com/goharbor/harbor/src/core/service/token"
31+
"github.com/goharbor/harbor/src/lib"
2932
"github.com/goharbor/harbor/src/lib/log"
3033
"github.com/goharbor/harbor/src/pkg/token"
3134
v2 "github.com/goharbor/harbor/src/pkg/token/claims/v2"
@@ -69,5 +72,29 @@ func (vt *v2Token) Generate(req *http.Request) security.Context {
6972
logger.Warningf("invalid token claims.")
7073
return nil
7174
}
75+
if !tokenIssuedAfterProjectCreation(req.Context(), logger, claims) {
76+
return nil
77+
}
7278
return v2token.New(req.Context(), claims.Subject, claims.Access)
7379
}
80+
81+
// tokenIssuedAfterProjectCreation prevents tokens from a deleted project
82+
// granting access to a new project recreated with the same name.
83+
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)
91+
return false
92+
}
93+
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
98+
}
99+
return true
100+
}

src/server/middleware/security/v2_token_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
package security
22

33
import (
4+
"context"
45
"fmt"
56
"net/http"
67
"testing"
8+
"time"
79

810
registry_token "github.com/docker/distribution/registry/auth/token"
11+
"github.com/golang-jwt/jwt/v5"
912
"github.com/stretchr/testify/assert"
1013
"github.com/stretchr/testify/require"
1114

15+
project_ctl "github.com/goharbor/harbor/src/controller/project"
1216
"github.com/goharbor/harbor/src/core/service/token"
17+
"github.com/goharbor/harbor/src/lib"
1318
"github.com/goharbor/harbor/src/lib/config"
19+
"github.com/goharbor/harbor/src/lib/log"
1420
"github.com/goharbor/harbor/src/lib/orm"
21+
proModels "github.com/goharbor/harbor/src/pkg/project/models"
22+
v2 "github.com/goharbor/harbor/src/pkg/token/claims/v2"
23+
projecttesting "github.com/goharbor/harbor/src/testing/controller/project"
24+
"github.com/goharbor/harbor/src/testing/mock"
1525
)
1626

1727
func TestGenerate(t *testing.T) {
@@ -34,3 +44,60 @@ func TestGenerate(t *testing.T) {
3444
req4.Header.Set("Authorization", fmt.Sprintf("Bearer %s", mt2.Token))
3545
assert.NotNil(t, vt.Generate(req4))
3646
}
47+
48+
func makeClaimsWithIAT(iat time.Time) *v2TokenClaims {
49+
return &v2TokenClaims{
50+
Claims: v2.Claims{
51+
RegisteredClaims: jwt.RegisteredClaims{
52+
IssuedAt: jwt.NewNumericDate(iat),
53+
},
54+
},
55+
}
56+
}
57+
58+
func TestTokenIssuedAfterProjectCreation(t *testing.T) {
59+
logger := log.DefaultLogger()
60+
projectCreated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
61+
before := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
62+
after := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
63+
64+
proj := &proModels.Project{Name: "myproject", CreationTime: projectCreated}
65+
66+
tests := []struct {
67+
name string
68+
projectName string
69+
iat time.Time
70+
project *proModels.Project
71+
projErr error
72+
allowed bool
73+
}{
74+
{"after creation - allowed", "myproject", after, proj, nil, true},
75+
{"before creation - rejected", "myproject", before, proj, nil, false},
76+
{"exact creation time - allowed", "myproject", projectCreated, proj, nil, true},
77+
{"within leeway window - allowed", "myproject", projectCreated.Add(-30 * time.Second), proj, nil, true},
78+
{"just outside leeway - rejected", "myproject", projectCreated.Add(-61 * time.Second), proj, nil, false},
79+
{"no project in context - skipped", "", after, nil, nil, true},
80+
{"project lookup error - rejected", "myproject", after, nil, fmt.Errorf("not found"), false},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
origCtl := project_ctl.Ctl
86+
defer func() { project_ctl.Ctl = origCtl }()
87+
88+
mockCtl := &projecttesting.Controller{}
89+
project_ctl.Ctl = mockCtl
90+
if tt.project != nil || tt.projErr != nil {
91+
mock.OnAnything(mockCtl, "GetByName").Return(tt.project, tt.projErr)
92+
}
93+
94+
ctx := context.Background()
95+
if tt.projectName != "" {
96+
ctx = lib.WithArtifactInfo(ctx, lib.ArtifactInfo{ProjectName: tt.projectName})
97+
}
98+
99+
result := tokenIssuedAfterProjectCreation(ctx, logger, makeClaimsWithIAT(tt.iat))
100+
assert.Equal(t, tt.allowed, result)
101+
})
102+
}
103+
}

0 commit comments

Comments
 (0)