diff --git a/packages/api/internal/handlers/template_alias.go b/packages/api/internal/handlers/template_alias.go index 98eda3e2ed..a411f1e0b4 100644 --- a/packages/api/internal/handlers/template_alias.go +++ b/packages/api/internal/handlers/template_alias.go @@ -3,11 +3,14 @@ package handlers import ( "fmt" "net/http" + "strings" "github.com/gin-gonic/gin" "github.com/e2b-dev/infra/packages/api/internal/api" templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" + "github.com/e2b-dev/infra/packages/db/pkg/dberrors" + "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -23,7 +26,8 @@ func (a *APIStore) GetTemplatesAliasesAlias(c *gin.Context, alias string) { return } - identifier, _, err := id.ParseName(alias) + hasExplicitTag := strings.Contains(alias, id.TagSeparator) + identifier, tag, err := id.ParseName(alias) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid alias format: %s", err)) telemetry.ReportError(ctx, "invalid alias format", err) @@ -45,13 +49,41 @@ func (a *APIStore) GetTemplatesAliasesAlias(c *gin.Context, alias string) { return } - // Ownership verification (handles edge case where template was transferred) + // Ownership verification (handles edge case where template was transferred). + // Must run before the tag-existence probe below, otherwise non-owners could + // distinguish existing tags from missing ones on templates they no longer + // have access to via 404 vs 403 responses. if aliasInfo.TeamID != team.ID { a.sendAPIStoreError(c, http.StatusForbidden, "You don't have access to this template alias") return } + if hasExplicitTag { + tagValue := id.DefaultTag + if tag != nil { + tagValue = *tag + } + + _, err = a.sqlcDB.GetTemplateWithBuildByTag(ctx, queries.GetTemplateWithBuildByTagParams{ + TemplateID: aliasInfo.TemplateID, + Tag: &tagValue, + }) + if err != nil { + if dberrors.IsNotFoundError(err) { + a.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("tag '%s' does not exist for template '%s'", tagValue, identifier)) + telemetry.ReportError(ctx, "template tag not found", err, telemetry.WithTemplateID(aliasInfo.TemplateID)) + + return + } + + a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when checking template tag existence") + telemetry.ReportCriticalError(ctx, "error when checking template tag existence", err, telemetry.WithTemplateID(aliasInfo.TemplateID)) + + return + } + } + // Team is alias owner c.JSON( http.StatusOK, api.TemplateAliasResponse{ diff --git a/packages/api/internal/handlers/template_alias_test.go b/packages/api/internal/handlers/template_alias_test.go index 5be6462f59..3cba0f4206 100644 --- a/packages/api/internal/handlers/template_alias_test.go +++ b/packages/api/internal/handlers/template_alias_test.go @@ -1,12 +1,14 @@ package handlers import ( + "context" "fmt" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,9 +18,37 @@ import ( "github.com/e2b-dev/infra/packages/auth/pkg/types" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" "github.com/e2b-dev/infra/packages/db/pkg/testutils" + "github.com/e2b-dev/infra/packages/shared/pkg/id" redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis" ) +func queryTemplateAliasForTeam( + t *testing.T, + store *APIStore, + teamID uuid.UUID, + teamSlug string, + alias string, +) *apispec.GetTemplatesAliasesAliasResponse { + t.Helper() + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/templates/aliases/%s", alias), nil) + auth.SetTeamInfoForTest(t, c, &types.Team{ + Team: &authqueries.Team{ + ID: teamID, + Slug: teamSlug, + }, + }) + + store.GetTemplatesAliasesAlias(c, alias) + + res, err := apispec.ParseGetTemplatesAliasesAliasResponse(w.Result()) + require.NoError(t, err) + + return res +} + func TestQueryNotExistingTemplateAlias(t *testing.T) { t.Parallel() @@ -138,3 +168,166 @@ func TestQueryExistingTemplateAliasAsNotOwnerTeam(t *testing.T) { // This results in 404 (not found) instead of 403 (forbidden) with the new exact match behavior require.Equal(t, http.StatusNotFound, res.StatusCode()) } + +// TestTaggedTemplateAliasTransferredTemplate verifies that when an alias still +// resolves to a template whose ownership has moved to a different team, the +// ownership check returns 403 *before* the tag-existence probe runs. Otherwise +// a non-owner could distinguish existing tags from missing ones on a template +// they no longer have access to via the 404 vs 403 response. +func TestTaggedTemplateAliasTransferredTemplate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setTags func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string) + tag string + }{ + { + name: "tag exists on transferred template", + setTags: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string) { + t.Helper() + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "dev") + }, + tag: "dev", + }, + { + name: "tag missing on transferred template", + setTags: func(_ *testing.T, _ context.Context, _ *testutils.Database, _ string) {}, + tag: "dev", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + requesterTeamID := testutils.CreateTestTeam(t, testDB) + requesterTeamSlug := testutils.GetTeamSlug(t, ctx, testDB, requesterTeamID) + newOwnerTeamID := testutils.CreateTestTeam(t, testDB) + + // Template owned by the new team but alias still lives in the requester's + // namespace, mirroring the post-transfer state the in-code comment guards. + templateID := testutils.CreateTestTemplate(t, testDB, newOwnerTeamID) + alias := testutils.CreateTestTemplateAliasWithNamespace(t, testDB, templateID, &requesterTeamSlug) + + tt.setTags(t, ctx, testDB, templateID) + + store := &APIStore{ + sqlcDB: testDB.SqlcClient, + authDB: testDB.AuthDb, + templateCache: templatecache.NewTemplateCache(testDB.SqlcClient, redis), + } + + res := queryTemplateAliasForTeam(t, store, requesterTeamID, requesterTeamSlug, id.WithTag(alias, tt.tag)) + + require.Equal(t, http.StatusForbidden, res.StatusCode(), + "requester must get 403 regardless of tag presence on a foreign-owned template") + }) + } +} + +func TestQueryTaggedTemplateAlias(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupAlias func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string + wantStatus int + }{ + { + name: "existing alias with ready tag returns ok", + setupAlias: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string { + t.Helper() + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "dev") + + return id.WithTag(alias, "dev") + }, + wantStatus: http.StatusOK, + }, + { + name: "existing alias with missing tag returns not found", + setupAlias: func(_ *testing.T, _ context.Context, _ *testutils.Database, _ string, alias string) string { + return id.WithTag(alias, "missing") + }, + wantStatus: http.StatusNotFound, + }, + { + name: "existing alias with explicit default tag returns ok", + setupAlias: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string { + t.Helper() + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, id.DefaultTag) + + return id.WithTag(alias, id.DefaultTag) + }, + wantStatus: http.StatusOK, + }, + { + name: "existing alias with missing explicit default tag returns not found", + setupAlias: func(_ *testing.T, _ context.Context, _ *testutils.Database, _ string, alias string) string { + return id.WithTag(alias, id.DefaultTag) + }, + wantStatus: http.StatusNotFound, + }, + { + name: "existing alias with non-ready tag returns not found", + setupAlias: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string { + t.Helper() + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "waiting") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "dev") + + return id.WithTag(alias, "dev") + }, + wantStatus: http.StatusNotFound, + }, + { + name: "existing alias with ready build id suffix returns ok", + setupAlias: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string { + t.Helper() + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, id.DefaultTag) + + return id.WithTag(alias, buildID.String()) + }, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, testDB) + teamSlug := testutils.GetTeamSlug(t, ctx, testDB, teamID) + templateID := testutils.CreateTestTemplate(t, testDB, teamID) + alias := testutils.CreateTestTemplateAliasWithNamespace(t, testDB, templateID, &teamSlug) + + store := &APIStore{ + sqlcDB: testDB.SqlcClient, + authDB: testDB.AuthDb, + templateCache: templatecache.NewTemplateCache(testDB.SqlcClient, redis), + } + + taggedAlias := tt.setupAlias(t, ctx, testDB, templateID, alias) + res := queryTemplateAliasForTeam(t, store, teamID, teamSlug, taggedAlias) + + require.Equal(t, tt.wantStatus, res.StatusCode()) + if tt.wantStatus != http.StatusOK { + return + } + + require.NotNil(t, res.JSON200) + assert.Equal(t, templateID, res.JSON200.TemplateID) + }) + } +}