Problem
In POST /templates/tags (packages/api/internal/handlers/template_tags.go), the control plane handler initiates a database transaction via a.sqlcDB.WithTx(ctx) and executes client.GetTemplateWithBuildByTag before validating whether the authenticated user's team owns the requested template (if aliasInfo.TeamID != team.ID).
This ordering defect causes two distinct issues:
-
Tag Enumeration / Information Disclosure Oracle:
When a user sends POST /templates/tags targeting a template owned by a different team:
- If the requested
tag does not exist on that template: GetTemplateWithBuildByTag returns sql.ErrNoRows, which triggers ErrTemplateNotFound, responding with 404 Not Found ("Template '<target>' with tag '<tag>' not found").
- If the requested
tag exists on that template: the query succeeds, and only afterwards at line 115 is aliasInfo.TeamID != team.ID checked, responding with 403 Forbidden ("You don't have access to sandbox template '%s'").
An unauthorized tenant can therefore determine whether arbitrary private build tags (e.g., staging, v2.0.0-rc1, hotfix) exist on other teams' templates by probing the endpoint and observing the difference between HTTP 404 and HTTP 403.
-
Unnecessary Database Load and Transaction Allocation:
Every unauthorized or invalid tag request against another tenant's template allocates a Postgres transaction connection from the pool (WithTx), acquires read locks, executes queries, and then rolls back upon failure.
Root Cause
In packages/api/internal/handlers/template_tags.go:L75-L122, tag resolution and database transaction creation precede the team ownership authorization check:
// Current flow in template_tags.go:
aliasInfo, err := a.templateCache.ResolveAlias(ctx, templateID)
if err != nil { ... }
// BUG: Transaction started and DB queried BEFORE checking aliasInfo.TeamID == team.ID
txErr := a.sqlcDB.WithTx(ctx).Exec(func(queries *queries.Queries) error {
build, err := queries.GetTemplateWithBuildByTag(ctx, ...)
if err != nil {
return ErrTemplateNotFound // Returns 404 to unauthorized caller if tag missing
}
...
if aliasInfo.TeamID != team.ID {
return a.sendAPIStoreError(c, http.StatusForbidden, ...) // Returns 403 only if tag exists
}
})
In contrast, peer handlers such as DeleteTemplatesTags (template_tags.go:L139) and GetTemplatesTemplateIDTags (template_tags.go:L21) enforce if aliasInfo.TeamID != team.ID immediately after alias resolution:
| Handler |
Authorization Check Timing |
Tag Enumeration Vulnerable? |
GET /templates/{templateID}/tags |
Immediate after ResolveAlias |
No |
DELETE /templates/tags |
Immediate after ResolveAlias |
No |
POST /templates/tags (Current) |
Deferred after GetTemplateWithBuildByTag DB query |
Yes (404 vs 403 Oracle) |
POST /templates/tags (Expected) |
Immediate after ResolveAlias |
No (403 Forbidden) |
Reproduction Steps
- Create a template
template-A under team-1 with tag v1.0.0.
- Authenticate as an unrelated user
team-2.
- Send
POST /templates/tags targeting template-A with tag: "v9.9.9" (non-existent).
- Observed:
HTTP 404 Not Found ({"code": 404, "message": "Template 'template-A' with tag 'v9.9.9' not found"})
- Send
POST /templates/tags targeting template-A with tag: "v1.0.0" (existing tag).
- Observed:
HTTP 403 Forbidden ({"code": 403, "message": "You don't have access to sandbox template 'template-A'"})
- Expected: Both requests must return
HTTP 403 Forbidden without leaking metadata about tag existence.
Technical Context
- File affected:
packages/api/internal/handlers/template_tags.go
- Subsystem: Control Plane API / Templates & Tags
- Impact: Medium (Security/Privacy: cross-tenant metadata disclosure & unnecessary database transaction overhead)
Proposed Changes
| # |
Change |
File(s) Affected |
Complexity |
| 1 |
Move if aliasInfo.TeamID != team.ID check immediately after ResolveAlias before WithTx |
packages/api/internal/handlers/template_tags.go |
Trivial |
| 2 |
Add unit test TestPostTemplatesTags_RejectsOtherTeamTemplate verifying 403 Forbidden |
packages/api/internal/handlers/template_tags_test.go |
Low |
Problem
In
POST /templates/tags(packages/api/internal/handlers/template_tags.go), the control plane handler initiates a database transaction viaa.sqlcDB.WithTx(ctx)and executesclient.GetTemplateWithBuildByTagbefore validating whether the authenticated user's team owns the requested template (if aliasInfo.TeamID != team.ID).This ordering defect causes two distinct issues:
Tag Enumeration / Information Disclosure Oracle:
When a user sends
POST /templates/tagstargeting a template owned by a different team:tagdoes not exist on that template:GetTemplateWithBuildByTagreturnssql.ErrNoRows, which triggersErrTemplateNotFound, responding with404 Not Found("Template '<target>' with tag '<tag>' not found").tagexists on that template: the query succeeds, and only afterwards at line 115 isaliasInfo.TeamID != team.IDchecked, responding with403 Forbidden("You don't have access to sandbox template '%s'").An unauthorized tenant can therefore determine whether arbitrary private build tags (e.g.,
staging,v2.0.0-rc1,hotfix) exist on other teams' templates by probing the endpoint and observing the difference between HTTP404and HTTP403.Unnecessary Database Load and Transaction Allocation:
Every unauthorized or invalid tag request against another tenant's template allocates a Postgres transaction connection from the pool (
WithTx), acquires read locks, executes queries, and then rolls back upon failure.Root Cause
In
packages/api/internal/handlers/template_tags.go:L75-L122, tag resolution and database transaction creation precede the team ownership authorization check:In contrast, peer handlers such as
DeleteTemplatesTags(template_tags.go:L139) andGetTemplatesTemplateIDTags(template_tags.go:L21) enforceif aliasInfo.TeamID != team.IDimmediately after alias resolution:GET /templates/{templateID}/tagsResolveAliasDELETE /templates/tagsResolveAliasPOST /templates/tags(Current)GetTemplateWithBuildByTagDB queryPOST /templates/tags(Expected)ResolveAliasReproduction Steps
template-Aunderteam-1with tagv1.0.0.team-2.POST /templates/tagstargetingtemplate-Awithtag: "v9.9.9"(non-existent).HTTP 404 Not Found({"code": 404, "message": "Template 'template-A' with tag 'v9.9.9' not found"})POST /templates/tagstargetingtemplate-Awithtag: "v1.0.0"(existing tag).HTTP 403 Forbidden({"code": 403, "message": "You don't have access to sandbox template 'template-A'"})HTTP 403 Forbiddenwithout leaking metadata about tag existence.Technical Context
packages/api/internal/handlers/template_tags.goProposed Changes
if aliasInfo.TeamID != team.IDcheck immediately afterResolveAliasbeforeWithTxpackages/api/internal/handlers/template_tags.goTestPostTemplatesTags_RejectsOtherTeamTemplateverifying 403 Forbiddenpackages/api/internal/handlers/template_tags_test.go