fix: support cache-mode and make low-trust runs cache read-only (#1412)

Low-trust runs like `pull_request_target` on the default branch could poison caches that `push` runs restore. As on GitHub, they now only restore unless `cache-mode` allows saving.

This and the Gitea change can be deployed in any order.

Refs:
https://gitea.com/gitea/actionslib/pulls/33
https://github.com/go-gitea/gitea/pull/40343

_Written by Claude (Opus 5.5)._
Reviewed-on: https://gitea.com/gitea/runner/pulls/1412
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwind authored and bircni committed 2026-11-13 09:33:32 +00:00
1 parent c2046d597c
commit 4f59e3e418
8 files changed
+127 -26

No files matched your search

+29 -4
View File
@@ -62,6 +62,7 @@ type JobCredential struct {
Ref string `json:"ref"`
BaseRef string `json:"base_ref"`
DefaultRef string `json:"default_ref"`
CacheMode string `json:"cache_mode"` // empty allows everything
// Results is the instance whose artifact service this server forwards for the job, and
// InsecureTLS how the runner reaches it; see results.go.
@@ -218,10 +219,10 @@ func StartHandler(opts Options) (*Handler, error) {
}
router := http.NewServeMux()
router.HandleFunc("GET "+apiPath+"/cache", h.bearerAuth(h.find))
router.HandleFunc("POST "+apiPath+"/caches", h.bearerAuth(h.reserve))
router.HandleFunc("PATCH "+apiPath+"/caches/{id}", h.bearerAuth(h.upload))
router.HandleFunc("POST "+apiPath+"/caches/{id}", h.bearerAuth(h.commit))
router.HandleFunc("GET "+apiPath+"/cache", h.cacheModeAuth(false, h.find))
router.HandleFunc("POST "+apiPath+"/caches", h.cacheModeAuth(true, h.reserve))
router.HandleFunc("PATCH "+apiPath+"/caches/{id}", h.cacheModeAuth(true, h.upload))
router.HandleFunc("POST "+apiPath+"/caches/{id}", h.cacheModeAuth(true, h.commit))
router.HandleFunc("POST "+apiPath+"/clean", h.bearerAuth(h.clean))
// Artifact GET is signed via query-string HMAC because @actions/cache
// does not attach Authorization when downloading archiveLocation.
@@ -956,6 +957,30 @@ func (h *Handler) bearerAuth(handler http.HandlerFunc) http.HandlerFunc {
}
}
// cacheModeAuth is bearerAuth refusing what the job's cache-mode forbids, in the shapes the toolkit reports as a warning.
func (h *Handler) cacheModeAuth(write bool, handler http.HandlerFunc) http.HandlerFunc {
return h.bearerAuth(func(w http.ResponseWriter, r *http.Request) {
mode := credFromContext(r.Context()).CacheMode
access, forbidden := "read", "write-only"
if write {
access, forbidden = "write", "read"
}
if mode != "none" && mode != forbidden {
handler(w, r)
return
}
message := fmt.Sprintf("cache %s denied: the job's cache-mode is %s", access, mode)
switch {
case !strings.HasPrefix(r.URL.Path, cacheServiceV2Path):
h.responseJSON(w, r, http.StatusForbidden, map[string]any{"message": message})
case write:
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false, "message": message})
default:
h.twirpError(w, r, twirpDenied, errors.New(message))
}
})
}
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
h.twirpError(w, r, twirpUnauthenticated, err)
+27 -6
View File
@@ -1003,7 +1003,7 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
}
}
func TestHandler_CrossRepoIsolation(t *testing.T) {
func TestHandler_CrossRepoAndCacheModeIsolation(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(Options{Dir: dir})
@@ -1012,6 +1012,8 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
handler.RegisterJob("token-b", JobCredential{Repo: "owner/repoB"})
handler.RegisterJob("token-a-pull", JobCredential{Repo: "owner/repoA", Ref: "refs/pull/1/head"})
handler.RegisterJob("token-a-read", JobCredential{Repo: "owner/repoA", CacheMode: "read"})
handler.RegisterJob("token-a-write-only", JobCredential{Repo: "owner/repoA", CacheMode: "write-only"})
base := handler.ExternalURL() + apiPath
key := "shared-key"
@@ -1041,12 +1043,31 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
resp.Body.Close()
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
resp, err = clientA.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
clientRead := &http.Client{Transport: &bearerTransport{token: "token-a-read"}}
for _, client := range []*http.Client{clientA, clientRead} {
resp, err = client.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
for _, client := range []*http.Client{clientB, {Transport: &bearerTransport{token: "token-a-pull"}}} {
requireDenied := func(prefix string, do func() (*http.Response, error)) {
resp, err := do()
require.NoError(t, err)
defer resp.Body.Close()
denied := map[string]string{}
require.NoError(t, json.UnmarshalRead(resp.Body, &denied))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.True(t, strings.HasPrefix(denied["message"], prefix), denied["message"])
}
requireDenied("cache write denied:", func() (*http.Response, error) {
return clientRead.Post(base+"/caches", "application/json", strings.NewReader(`{"key":"new-key","version":"`+version+`"}`))
})
requireDenied("cache read denied:", func() (*http.Response, error) {
return (&http.Client{Transport: &bearerTransport{token: "token-a-write-only"}}).Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
})
for _, client := range []*http.Client{clientB, {Transport: &bearerTransport{token: "token-a-pull"}}, clientRead} {
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader([]byte("poison")))
require.NoError(t, err)
req.Header.Set("Content-Range", "bytes 0-5/*")
+6 -3
View File
@@ -40,12 +40,13 @@ const (
twirpInternal = "internal"
twirpUnavailable = "unavailable"
twirpUnauthenticated = "unauthenticated"
twirpDenied = "permission_denied"
)
func (h *Handler) registerV2Routes(router *http.ServeMux) {
router.HandleFunc("POST "+cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
router.HandleFunc("POST "+cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
router.HandleFunc("POST "+cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
router.HandleFunc("POST "+cacheServiceV2Path+"/CreateCacheEntry", h.cacheModeAuth(true, h.v2CreateCacheEntry))
router.HandleFunc("POST "+cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.cacheModeAuth(true, h.v2FinalizeCacheEntryUpload))
router.HandleFunc("POST "+cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.cacheModeAuth(false, h.v2GetCacheEntryDownloadURL))
router.HandleFunc("PUT "+blobPath+"/{id}", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
}
@@ -267,6 +268,8 @@ func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string
status = http.StatusServiceUnavailable
case twirpUnauthenticated:
status = http.StatusUnauthorized
case twirpDenied:
status = http.StatusForbidden
}
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
}
+24
View File
@@ -227,6 +227,30 @@ func TestCacheServiceV2Lookups(t *testing.T) {
assert.NotEmpty(t, got["archiveLocation"])
})
t.Run("a read-only job only restores and a write-only one only saves", func(t *testing.T) {
handler.RegisterJob("read-only", JobCredential{Repo: testRepo, CacheMode: "read"})
readOnlyClient := &http.Client{Transport: &bearerTransport{token: "read-only"}}
assert.Equal(t, true, v2Call(t, handler, readOnlyClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})["ok"])
for _, method := range []string{"CreateCacheEntry", "FinalizeCacheEntryUpload"} {
got := v2Call(t, handler, readOnlyClient, method, map[string]any{"key": "deps-new", "version": "v1", "size_bytes": 1})
assert.Equal(t, false, got["ok"], method)
assert.True(t, strings.HasPrefix(got["message"].(string), "cache write denied:"), method)
}
handler.RegisterJob("write-only", JobCredential{Repo: testRepo, CacheMode: "write-only"})
writeOnlyClient := &http.Client{Transport: &bearerTransport{token: "write-only"}}
assert.Equal(t, true, v2Call(t, handler, writeOnlyClient, "CreateCacheEntry", map[string]any{"key": "deps-write-only", "version": "v1"})["ok"])
resp, err := writeOnlyClient.Post(handler.ExternalURL()+cacheServiceV2Path+"/GetCacheEntryDownloadURL", "application/json", strings.NewReader(`{"key":"deps-abc","version":"v1"}`))
require.NoError(t, err)
defer resp.Body.Close()
got := map[string]string{}
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, twirpDenied, got["code"])
assert.True(t, strings.HasPrefix(got["msg"], "cache read denied:"), got["msg"])
})
t.Run("another repository sees nothing", func(t *testing.T) {
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}