Skip to content

Commit 2f77650

Browse files
committed
volumes: reserve hypeman.system/ tag namespace from API callers
The cache volume reaper selected eviction candidates by the hypeman.system/managed-by=build-cache tag, but CreateVolume and CreateVolumeFromArchive accepted arbitrary caller tags. A caller could tag a user volume as managed and have it evicted by the reaper, or inflate the managed set to force eviction of real cache volumes. Reject caller-supplied tags in the hypeman.system/ namespace on both public create paths, and require the reserved build-cache- ID prefix in addition to the tag when the manager and reaper identify cache volumes.
1 parent 482ea00 commit 2f77650

6 files changed

Lines changed: 134 additions & 5 deletions

File tree

‎cmd/api/api/volumes.go‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"sort"
78

89
"github.com/kernel/hypeman/lib/logger"
910
mw "github.com/kernel/hypeman/lib/middleware"
@@ -56,6 +57,13 @@ func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolume
5657
}
5758
}
5859

60+
if key := reservedTagKey(request.Body.Tags); key != "" {
61+
return oapi.CreateVolume400JSONResponse{
62+
Code: "invalid_request",
63+
Message: fmt.Sprintf("tag key %q is reserved for internal use", key),
64+
}, nil
65+
}
66+
5967
domainReq := volumes.CreateVolumeRequest{
6068
Name: request.Body.Name,
6169
SizeGb: request.Body.SizeGb,
@@ -116,6 +124,13 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C
116124
}
117125
}
118126

127+
if key := reservedTagKey(request.Params.Tags); key != "" {
128+
return oapi.CreateVolumeFromArchive400JSONResponse{
129+
Code: "invalid_request",
130+
Message: fmt.Sprintf("tag key %q is reserved for internal use", key),
131+
}, nil
132+
}
133+
119134
// Create the volume from archive - stream directly without buffering
120135
domainReq := volumes.CreateVolumeFromArchiveRequest{
121136
Name: request.Params.Name,
@@ -230,3 +245,22 @@ func volumeToOAPI(vol volumes.Volume) oapi.Volume {
230245

231246
return oapiVol
232247
}
248+
249+
// reservedTagKey returns the first caller-supplied tag key in the reserved
250+
// internal namespace, or "" when all keys are usable by API callers.
251+
func reservedTagKey(resourceTags *oapi.Tags) string {
252+
if resourceTags == nil {
253+
return ""
254+
}
255+
keys := make([]string, 0, len(*resourceTags))
256+
for key := range *resourceTags {
257+
keys = append(keys, key)
258+
}
259+
sort.Strings(keys)
260+
for _, key := range keys {
261+
if volumes.ReservedTagNamespace(key) != "" {
262+
return key
263+
}
264+
}
265+
return ""
266+
}

‎cmd/api/api/volumes_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,46 @@ func TestDeleteVolume_ByName(t *testing.T) {
7979
assert.True(t, ok, "expected 204 response")
8080
}
8181

82+
func TestCreateVolume_ReservedTagNamespace(t *testing.T) {
83+
t.Parallel()
84+
svc := newTestService(t)
85+
86+
resourceTags := oapi.Tags{"hypeman.system/managed-by": "build-cache"}
87+
resp, err := svc.CreateVolume(ctx(), oapi.CreateVolumeRequestObject{
88+
Body: &oapi.CreateVolumeRequest{
89+
Name: "spoofed-cache",
90+
SizeGb: 1,
91+
Tags: &resourceTags,
92+
},
93+
})
94+
require.NoError(t, err)
95+
bad, ok := resp.(oapi.CreateVolume400JSONResponse)
96+
require.True(t, ok, "expected 400 for reserved tag key")
97+
assert.Contains(t, bad.Message, "reserved for internal use")
98+
99+
vols, err := svc.VolumeManager.ListVolumes(ctx())
100+
require.NoError(t, err)
101+
assert.Empty(t, vols, "spoofed volume should not be created")
102+
}
103+
104+
func TestCreateVolumeFromArchive_ReservedTagNamespace(t *testing.T) {
105+
t.Parallel()
106+
svc := newTestService(t)
107+
108+
resourceTags := oapi.Tags{"hypeman.system/managed-by": "build-cache"}
109+
resp, err := svc.CreateVolumeFromArchive(ctx(), oapi.CreateVolumeFromArchiveRequestObject{
110+
Params: oapi.CreateVolumeFromArchiveParams{
111+
Name: "spoofed-cache",
112+
SizeGb: 1,
113+
Tags: &resourceTags,
114+
},
115+
})
116+
require.NoError(t, err)
117+
bad, ok := resp.(oapi.CreateVolumeFromArchive400JSONResponse)
118+
require.True(t, ok, "expected 400 for reserved tag key")
119+
assert.Contains(t, bad.Message, "reserved for internal use")
120+
}
121+
82122
func TestCreateVolume_ReservedIDPrefix(t *testing.T) {
83123
t.Parallel()
84124
svc := newTestService(t)

‎lib/builds/cache_volume.go‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"path/filepath"
1313
"sort"
14+
"strings"
1415
"sync"
1516
"time"
1617

@@ -56,8 +57,9 @@ const (
5657
// created by the build cache manager. The tag distinguishes cache
5758
// volumes from caller-created volumes that happen to share the ID
5859
// prefix: the manager only reuses tagged volumes and the reaper only
59-
// evicts tagged volumes.
60-
cacheVolumeManagedTagKey = "hypeman.system/managed-by"
60+
// evicts tagged volumes. The key lives in a namespace public volume
61+
// APIs reserve, so callers cannot spoof it.
62+
cacheVolumeManagedTagKey = volumes.SystemTagNamespace + "managed-by"
6163
cacheVolumeManagedTagValue = "build-cache"
6264

6365
// cacheVolumeSweepInterval is how often the reaper evaluates eviction.
@@ -175,10 +177,14 @@ func (c *cacheVolumeManager) ensureCacheVolume(ctx context.Context, scope string
175177
return volID, nil
176178
}
177179

178-
// isManagedCacheVolume reports whether a volume carries the internal tag
179-
// marking it as created by the build cache manager.
180+
// isManagedCacheVolume reports whether a volume is a build cache volume:
181+
// it carries the internal managed-by tag and the reserved cache volume ID
182+
// prefix. The prefix check is defense in depth — public APIs already reject
183+
// both — so a volume created outside the public API cannot be mistaken for
184+
// a cache volume by tag alone.
180185
func isManagedCacheVolume(vol *volumes.Volume) bool {
181-
return vol.Tags[cacheVolumeManagedTagKey] == cacheVolumeManagedTagValue
186+
return strings.HasPrefix(vol.Id, cacheVolumeIDPrefix) &&
187+
vol.Tags[cacheVolumeManagedTagKey] == cacheVolumeManagedTagValue
182188
}
183189

184190
// touchLastUsed records that a cache volume was used now.

‎lib/builds/cache_volume_test.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,32 @@ func TestReap_IgnoresUnmanagedPrefixedVolume(t *testing.T) {
150150
assert.NoError(t, err, "unmanaged volume must never be evicted")
151151
}
152152

153+
func TestReap_IgnoresTaggedVolumeWithoutPrefix(t *testing.T) {
154+
mgr, volumeMgr, now, tempDir := setupCacheVolumeManager(t, CacheVolumeConfig{
155+
Enabled: true,
156+
SizeGB: 10,
157+
IdleTTL: time.Hour,
158+
})
159+
defer os.RemoveAll(tempDir)
160+
161+
// A volume carrying the managed-by tag but an arbitrary ID — e.g. one
162+
// created before the tag namespace was reserved, or outside the public
163+
// API — must survive the reaper even when idle past the TTL.
164+
userVol := &volumes.Volume{
165+
Id: "user-tagged-volume",
166+
Name: "user-data",
167+
SizeGb: 10,
168+
CreatedAt: now.Add(-72 * time.Hour),
169+
Tags: tags.Tags{cacheVolumeManagedTagKey: cacheVolumeManagedTagValue},
170+
}
171+
volumeMgr.volumes[userVol.Id] = userVol
172+
173+
mgr.reap(context.Background())
174+
175+
_, err := volumeMgr.GetVolume(context.Background(), userVol.Id)
176+
assert.NoError(t, err, "tagged volume without the reserved ID prefix must never be evicted")
177+
}
178+
153179
func TestLockScope_SerializesSameScope(t *testing.T) {
154180
mgr, _, _, tempDir := setupCacheVolumeManager(t, CacheVolumeConfig{Enabled: true})
155181
defer os.RemoveAll(tempDir)

‎lib/volumes/reserved.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,18 @@ func ReservedVolumeIDPrefix(id string) string {
2525
}
2626
return ""
2727
}
28+
29+
// SystemTagNamespace is the tag key namespace reserved for internal,
30+
// server-managed metadata (e.g. the tag marking build cache volumes).
31+
const SystemTagNamespace = "hypeman.system/"
32+
33+
// ReservedTagNamespace returns the reserved internal tag namespace key
34+
// starts with, or "" when key is usable by API callers. Public APIs must
35+
// reject caller-supplied tags in this namespace so a caller cannot
36+
// impersonate an internally managed volume.
37+
func ReservedTagNamespace(key string) string {
38+
if strings.HasPrefix(key, SystemTagNamespace) {
39+
return SystemTagNamespace
40+
}
41+
return ""
42+
}

‎lib/volumes/reserved_test.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,11 @@ func TestReservedVolumeIDPrefix(t *testing.T) {
1414
assert.Empty(t, ReservedVolumeIDPrefix("my-data"))
1515
assert.Empty(t, ReservedVolumeIDPrefix("build-caches"))
1616
}
17+
18+
func TestReservedTagNamespace(t *testing.T) {
19+
assert.Equal(t, SystemTagNamespace, ReservedTagNamespace("hypeman.system/managed-by"))
20+
assert.Equal(t, SystemTagNamespace, ReservedTagNamespace("hypeman.system/anything"))
21+
assert.Empty(t, ReservedTagNamespace("managed-by"))
22+
assert.Empty(t, ReservedTagNamespace("hypeman.system"))
23+
assert.Empty(t, ReservedTagNamespace("team"))
24+
}

0 commit comments

Comments
 (0)