1
0
Fork 0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2025-09-15 18:57:01 +00:00

Merge pull request 'Fix security issues with cache by proxying access' (#107) from Kwonunn/act:fix/cache-proxy into main

Reviewed-on: https://code.forgejo.org/forgejo/act/pulls/107
This commit is contained in:
Kwonunn 2025-03-21 14:19:38 +00:00
commit 776ccb8b21
8 changed files with 560 additions and 63 deletions

View file

@ -20,6 +20,7 @@ import (
"github.com/timshannon/bolthold"
"go.etcd.io/bbolt"
"github.com/nektos/act/pkg/cacheproxy"
"github.com/nektos/act/pkg/common"
)
@ -34,6 +35,7 @@ type Handler struct {
listener net.Listener
server *http.Server
logger logrus.FieldLogger
secret string
gcing atomic.Bool
gcAt time.Time
@ -41,8 +43,10 @@ type Handler struct {
outboundIP string
}
func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) {
h := &Handler{}
func StartHandler(dir, outboundIP string, port uint16, secret string, logger logrus.FieldLogger) (*Handler, error) {
h := &Handler{
secret: secret,
}
if logger == nil {
discard := logrus.New()
@ -155,7 +159,14 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
}
// GET /_apis/artifactcache/cache
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
func (h *Handler) find(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
rundata := runDataFromHeaders(r)
repo, err := h.validateMac(rundata)
if err != nil {
h.responseJSON(w, r, 403, err)
return
}
keys := strings.Split(r.URL.Query().Get("keys"), ",")
// cache keys are case insensitive
for i, key := range keys {
@ -170,7 +181,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
}
defer db.Close()
cache, err := findCache(db, keys, version)
cache, err := findCache(db, repo, keys, version)
if err != nil {
h.responseJSON(w, r, 500, err)
return
@ -188,15 +199,23 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
h.responseJSON(w, r, 204)
return
}
archiveLocation := fmt.Sprintf("%s/%s%s/artifacts/%d", r.Header.Get("Forgejo-Cache-Host"), r.Header.Get("Forgejo-Cache-RunId"), urlBase, cache.ID)
h.responseJSON(w, r, 200, map[string]any{
"result": "hit",
"archiveLocation": fmt.Sprintf("%s%s/artifacts/%d", h.ExternalURL(), urlBase, cache.ID),
"archiveLocation": archiveLocation,
"cacheKey": cache.Key,
})
}
// POST /_apis/artifactcache/caches
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
rundata := runDataFromHeaders(r)
repo, err := h.validateMac(rundata)
if err != nil {
h.responseJSON(w, r, 403, err)
return
}
api := &Request{}
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
h.responseJSON(w, r, 400, err)
@ -216,6 +235,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
now := time.Now().Unix()
cache.CreatedAt = now
cache.UsedAt = now
cache.Repo = repo
if err := insertCache(db, cache); err != nil {
h.responseJSON(w, r, 500, err)
return
@ -227,6 +247,13 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
// PATCH /_apis/artifactcache/caches/:id
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
rundata := runDataFromHeaders(r)
repo, err := h.validateMac(rundata)
if err != nil {
h.responseJSON(w, r, 403, err)
return
}
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
@ -249,11 +276,17 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
return
}
// Should not happen
if cache.Repo != repo {
h.responseJSON(w, r, 500, ErrValidation)
return
}
if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return
}
db.Close()
defer db.Close()
start, _, err := parseContentRange(r.Header.Get("Content-Range"))
if err != nil {
h.responseJSON(w, r, 400, err)
@ -262,12 +295,19 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
h.responseJSON(w, r, 500, err)
}
h.useCache(id)
h.useCache(db, cache)
h.responseJSON(w, r, 200)
}
// POST /_apis/artifactcache/caches/:id
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
rundata := runDataFromHeaders(r)
repo, err := h.validateMac(rundata)
if err != nil {
h.responseJSON(w, r, 403, err)
return
}
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
@ -290,6 +330,12 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
return
}
// Should not happen
if cache.Repo != repo {
h.responseJSON(w, r, 500, ErrValidation)
return
}
if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return
@ -323,17 +369,53 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
// GET /_apis/artifactcache/artifacts/:id
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
rundata := runDataFromHeaders(r)
repo, err := h.validateMac(rundata)
if err != nil {
h.responseJSON(w, r, 403, err)
return
}
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
return
}
h.useCache(id)
cache := &Cache{}
db, err := h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
defer db.Close()
if err := db.Get(id, cache); err != nil {
if errors.Is(err, bolthold.ErrNotFound) {
h.responseJSON(w, r, 404, fmt.Errorf("cache %d: not reserved", id))
return
}
h.responseJSON(w, r, 500, err)
return
}
// Should not happen
if cache.Repo != repo {
h.responseJSON(w, r, 500, ErrValidation)
return
}
h.useCache(db, cache)
h.storage.Serve(w, r, id)
}
// POST /_apis/artifactcache/clean
func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
rundata := runDataFromHeaders(r)
_, err := h.validateMac(rundata)
if err != nil {
h.responseJSON(w, r, 403, err)
return
}
// TODO: don't support force deleting cache entries
// see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
@ -349,12 +431,13 @@ func (h *Handler) middleware(handler httprouter.Handle) httprouter.Handle {
}
// if not found, return (nil, nil) instead of an error.
func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error) {
func findCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
cache := &Cache{}
for _, prefix := range keys {
// if a key in the list matches exactly, don't return partial matches
if err := db.FindOne(cache,
bolthold.Where("Key").Eq(prefix).
bolthold.Where("Repo").Eq(repo).
And("Key").Eq(prefix).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
@ -369,7 +452,8 @@ func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error
continue
}
if err := db.FindOne(cache,
bolthold.Where("Key").RegExp(re).
bolthold.Where("Repo").Eq(repo).
And("Key").RegExp(re).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err != nil {
@ -394,16 +478,7 @@ func insertCache(db *bolthold.Store, cache *Cache) error {
return nil
}
func (h *Handler) useCache(id uint64) {
db, err := h.openDB()
if err != nil {
return
}
defer db.Close()
cache := &Cache{}
if err := db.Get(id, cache); err != nil {
return
}
func (h *Handler) useCache(db *bolthold.Store, cache *Cache) {
cache.UsedAt = time.Now().Unix()
_ = db.Update(cache.ID, cache)
}
@ -554,3 +629,12 @@ func parseContentRange(s string) (uint64, uint64, error) {
}
return start, stop, nil
}
func runDataFromHeaders(r *http.Request) cacheproxy.RunData {
return cacheproxy.RunData{
RepositoryFullName: r.Header.Get("Forgejo-Cache-Repo"),
RunNumber: r.Header.Get("Forgejo-Cache-RunNumber"),
Timestamp: r.Header.Get("Forgejo-Cache-Timestamp"),
RepositoryMAC: r.Header.Get("Forgejo-Cache-MAC"),
}
}

View file

@ -18,11 +18,35 @@ import (
"go.etcd.io/bbolt"
)
const cache_repo = "testuser/repo"
const cache_runnum = "1"
const cache_timestamp = "0"
const cache_mac = "c13854dd1ac599d1d61680cd93c26b77ba0ee10f374a3408bcaea82f38ca1865"
var handlerExternalUrl string
type AuthHeaderTransport struct {
T http.RoundTripper
}
func (t *AuthHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Forgejo-Cache-Repo", cache_repo)
req.Header.Set("Forgejo-Cache-RunNumber", cache_runnum)
req.Header.Set("Forgejo-Cache-Timestamp", cache_timestamp)
req.Header.Set("Forgejo-Cache-MAC", cache_mac)
req.Header.Set("Forgejo-Cache-Host", handlerExternalUrl)
return t.T.RoundTrip(req)
}
var httpClientTransport = AuthHeaderTransport{http.DefaultTransport}
var httpClient = http.Client{Transport: &httpClientTransport}
func TestHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, nil)
handler, err := StartHandler(dir, "", 0, "secret", nil)
require.NoError(t, err)
handlerExternalUrl = handler.ExternalURL()
base := fmt.Sprintf("%s%s", handler.ExternalURL(), urlBase)
defer func() {
@ -41,7 +65,7 @@ func TestHandler(t *testing.T) {
require.NoError(t, handler.Close())
assert.Nil(t, handler.server)
assert.Nil(t, handler.listener)
_, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil)
_, err := httpClient.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil)
assert.Error(t, err)
})
}()
@ -49,7 +73,7 @@ func TestHandler(t *testing.T) {
t.Run("get not exist", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
})
@ -64,7 +88,7 @@ func TestHandler(t *testing.T) {
})
t.Run("clean", func(t *testing.T) {
resp, err := http.Post(fmt.Sprintf("%s/clean", base), "", nil)
resp, err := httpClient.Post(fmt.Sprintf("%s/clean", base), "", nil)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
})
@ -72,7 +96,7 @@ func TestHandler(t *testing.T) {
t.Run("reserve with bad request", func(t *testing.T) {
body := []byte(`invalid json`)
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})
@ -90,7 +114,7 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
@ -104,7 +128,7 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
@ -121,7 +145,7 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})
@ -132,7 +156,7 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})
@ -151,7 +175,7 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
@ -167,12 +191,12 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := httpClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
@ -182,7 +206,7 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
@ -202,7 +226,7 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
@ -218,7 +242,7 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes xx-99/*")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
@ -226,7 +250,7 @@ func TestHandler(t *testing.T) {
t.Run("commit with bad id", func(t *testing.T) {
{
resp, err := http.Post(fmt.Sprintf("%s/caches/invalid_id", base), "", nil)
resp, err := httpClient.Post(fmt.Sprintf("%s/caches/invalid_id", base), "", nil)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
@ -234,7 +258,7 @@ func TestHandler(t *testing.T) {
t.Run("commit with not exist id", func(t *testing.T) {
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil)
resp, err := httpClient.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
@ -254,7 +278,7 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
@ -270,17 +294,17 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := httpClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := httpClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
@ -300,7 +324,7 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
@ -316,35 +340,62 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-59/*")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := httpClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 500, resp.StatusCode)
}
})
t.Run("get with bad id", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/artifacts/invalid_id", base))
resp, err := httpClient.Get(fmt.Sprintf("%s/artifacts/invalid_id", base))
require.NoError(t, err)
require.Equal(t, 400, resp.StatusCode)
})
t.Run("get with not exist id", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100))
resp, err := httpClient.Get(fmt.Sprintf("%s/artifacts/%d", base, 100))
require.NoError(t, err)
require.Equal(t, 404, resp.StatusCode)
})
t.Run("get with not exist id", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100))
resp, err := httpClient.Get(fmt.Sprintf("%s/artifacts/%d", base, 100))
require.NoError(t, err)
require.Equal(t, 404, resp.StatusCode)
})
t.Run("get with bad MAC", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46b4ee758284e26bb3045ad11d9d20"
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
uploadCacheNormally(t, base, key, version, content)
// Perform the request with the custom `httpClient` which will send correct MAC data
resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
// Perform the same request with incorrect MAC data
req, err := http.NewRequest("GET", fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), nil)
require.NoError(t, err)
req.Header.Set("Forgejo-Cache-Repo", cache_repo)
req.Header.Set("Forgejo-Cache-RunNumber", cache_runnum)
req.Header.Set("Forgejo-Cache-Timestamp", cache_timestamp)
req.Header.Set("Forgejo-Cache-MAC", "33f0e850ba0bdfd2f3e66ff79c1f8004b8226114e3b2e65c229222bb59df0f9d") // ! This is not the correct MAC
req.Header.Set("Forgejo-Cache-Host", handlerExternalUrl)
resp, err = http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
require.Equal(t, 403, resp.StatusCode)
})
t.Run("get with multiple keys", func(t *testing.T) {
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
key := strings.ToLower(t.Name())
@ -371,7 +422,7 @@ func TestHandler(t *testing.T) {
key + "_a",
}, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
@ -391,7 +442,7 @@ func TestHandler(t *testing.T) {
assert.Equal(t, "hit", got.Result)
assert.Equal(t, keys[except], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation)
contentResp, err := httpClient.Get(got.ArchiveLocation)
require.NoError(t, err)
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
@ -409,7 +460,7 @@ func TestHandler(t *testing.T) {
{
reqKey := key + "_aBc"
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
got := struct {
@ -448,7 +499,7 @@ func TestHandler(t *testing.T) {
key + "_a_b",
}, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
@ -466,7 +517,7 @@ func TestHandler(t *testing.T) {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation)
contentResp, err := httpClient.Get(got.ArchiveLocation)
require.NoError(t, err)
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
@ -500,7 +551,7 @@ func TestHandler(t *testing.T) {
key + "_a_b",
}, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
@ -519,7 +570,7 @@ func TestHandler(t *testing.T) {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation)
contentResp, err := httpClient.Get(got.ArchiveLocation)
require.NoError(t, err)
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
@ -537,7 +588,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
Size: int64(len(content)),
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
resp, err := httpClient.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
@ -553,18 +604,18 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := httpClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
var archiveLocation string
{
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
resp, err := httpClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
got := struct {
@ -578,7 +629,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
archiveLocation = got.ArchiveLocation
}
{
resp, err := http.Get(archiveLocation) //nolint:gosec
resp, err := httpClient.Get(archiveLocation)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
got, err := io.ReadAll(resp.Body)
@ -589,7 +640,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
func TestHandler_gcCache(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, nil)
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer func() {

53
act/artifactcache/mac.go Normal file
View file

@ -0,0 +1,53 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"time"
"github.com/nektos/act/pkg/cacheproxy"
)
var (
ErrValidation = errors.New("validation error")
)
func (h *Handler) validateMac(rundata cacheproxy.RunData) (string, error) {
// TODO: allow configurable max age
if !validateAge(rundata.Timestamp) {
return "", ErrValidation
}
expectedMAC := computeMac(h.secret, rundata.RepositoryFullName, rundata.RunNumber, rundata.Timestamp)
if hmac.Equal([]byte(expectedMAC), []byte(rundata.RepositoryMAC)) {
return rundata.RepositoryFullName, nil
}
return "", ErrValidation
}
func validateAge(ts string) bool {
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if tsInt > time.Now().Unix() {
return false
}
return true
}
func computeMac(secret, repo, run, ts string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(repo))
mac.Write([]byte(">"))
mac.Write([]byte(run))
mac.Write([]byte(">"))
mac.Write([]byte(ts))
return hex.EncodeToString(mac.Sum(nil))
}

View file

@ -0,0 +1,80 @@
package artifactcache
import (
"strconv"
"testing"
"time"
"github.com/nektos/act/pkg/cacheproxy"
"github.com/stretchr/testify/require"
)
func TestMac(t *testing.T) {
handler := &Handler{
secret: "secret for testing",
}
t.Run("validate correct mac", func(t *testing.T) {
name := "org/reponame"
run := "1"
ts := strconv.FormatInt(time.Now().Unix(), 10)
mac := computeMac(handler.secret, name, run, ts)
rundata := cacheproxy.RunData{
RepositoryFullName: name,
RunNumber: run,
Timestamp: ts,
RepositoryMAC: mac,
}
repoName, err := handler.validateMac(rundata)
require.NoError(t, err)
require.Equal(t, name, repoName)
})
t.Run("validate incorrect timestamp", func(t *testing.T) {
name := "org/reponame"
run := "1"
ts := "9223372036854775807" // This should last us for a while...
mac := computeMac(handler.secret, name, run, ts)
rundata := cacheproxy.RunData{
RepositoryFullName: name,
RunNumber: run,
Timestamp: ts,
RepositoryMAC: mac,
}
_, err := handler.validateMac(rundata)
require.Error(t, err)
})
t.Run("validate incorrect mac", func(t *testing.T) {
name := "org/reponame"
run := "1"
ts := strconv.FormatInt(time.Now().Unix(), 10)
rundata := cacheproxy.RunData{
RepositoryFullName: name,
RunNumber: run,
Timestamp: ts,
RepositoryMAC: "this is not the right mac :D",
}
repoName, err := handler.validateMac(rundata)
require.Error(t, err)
require.Equal(t, "", repoName)
})
t.Run("compute correct mac", func(t *testing.T) {
secret := "this is my cool secret string :3"
name := "org/reponame"
run := "42"
ts := "1337"
mac := computeMac(secret, name, run, ts)
expectedMac := "f666f06f917acb7186e152195b2a8c8d36d068ce683454be0878806e08e04f2b" // * Precomputed, anytime the computeMac function changes this needs to be recalculated
require.Equal(t, mac, expectedMac)
})
}

View file

@ -25,6 +25,7 @@ func (c *Request) ToCache() *Cache {
type Cache struct {
ID uint64 `json:"id" boltholdKey:"ID"`
Repo string `json:"repo" boltholdIndex:"Repo"`
Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"`

227
act/cacheproxy/handler.go Normal file
View file

@ -0,0 +1,227 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cacheproxy
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
"strconv"
"sync"
"time"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
"github.com/nektos/act/pkg/common"
)
const (
urlBase = "/_apis/artifactcache"
)
var (
urlRegex = regexp.MustCompile(`/(\w+)(/_apis/artifactcache/.+)`)
)
type Handler struct {
router *httprouter.Router
listener net.Listener
server *http.Server
logger logrus.FieldLogger
outboundIP string
cacheServerHost string
cacheSecret string
runs sync.Map
}
type RunData struct {
RepositoryFullName string
RunNumber string
Timestamp string
RepositoryMAC string
}
func (h *Handler) CreateRunData(fullName string, runNumber string, timestamp string) RunData {
mac := computeMac(h.cacheSecret, fullName, runNumber, timestamp)
return RunData{
RepositoryFullName: fullName,
RunNumber: runNumber,
Timestamp: timestamp,
RepositoryMAC: mac,
}
}
func StartHandler(targetHost string, outboundIP string, port uint16, cacheSecret string, logger logrus.FieldLogger) (*Handler, error) {
h := &Handler{}
if logger == nil {
discard := logrus.New()
discard.Out = io.Discard
logger = discard
}
logger = logger.WithField("module", "artifactcache")
h.logger = logger
h.cacheSecret = cacheSecret
if outboundIP != "" {
h.outboundIP = outboundIP
} else if ip := common.GetOutboundIP(); ip == nil {
return nil, fmt.Errorf("unable to determine outbound IP address")
} else {
h.outboundIP = ip.String()
}
h.cacheServerHost = targetHost
proxy, err := h.newReverseProxy(targetHost)
if err != nil {
return nil, fmt.Errorf("unable to set up proxy to target host")
}
router := httprouter.New()
router.HandlerFunc("GET", "/:runId"+urlBase+"/cache", proxyRequestHandler(proxy))
router.HandlerFunc("POST", "/:runId"+urlBase+"/caches", proxyRequestHandler(proxy))
router.HandlerFunc("PATCH", "/:runId"+urlBase+"/caches/:id", proxyRequestHandler(proxy))
router.HandlerFunc("POST", "/:runId"+urlBase+"/caches/:id", proxyRequestHandler(proxy))
router.HandlerFunc("GET", "/:runId"+urlBase+"/artifacts/:id", proxyRequestHandler(proxy))
router.HandlerFunc("POST", "/:runId"+urlBase+"/clean", proxyRequestHandler(proxy))
h.router = router
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
if err != nil {
return nil, err
}
server := &http.Server{
ReadHeaderTimeout: 2 * time.Second,
Handler: router,
}
go func() {
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
logger.Errorf("http serve: %v", err)
}
}()
h.listener = listener
h.server = server
return h, nil
}
func proxyRequestHandler(proxy *httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) {
return proxy.ServeHTTP
}
func (h *Handler) newReverseProxy(targetHost string) (*httputil.ReverseProxy, error) {
targetURL, err := url.Parse(targetHost)
if err != nil {
return nil, err
}
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
matches := urlRegex.FindStringSubmatch(r.In.URL.Path)
id := matches[1]
data, ok := h.runs.Load(id)
if !ok {
// The ID doesn't exist.
h.logger.Warn(fmt.Sprintf("Tried starting a cache proxy with id %s, which does not exist.", id))
return
}
var runData = data.(RunData)
uri := matches[2]
r.SetURL(targetURL)
r.Out.URL.Path = uri
r.Out.Header.Set("Forgejo-Cache-Repo", runData.RepositoryFullName)
r.Out.Header.Set("Forgejo-Cache-RunNumber", runData.RunNumber)
r.Out.Header.Set("Forgejo-Cache-RunId", id)
r.Out.Header.Set("Forgejo-Cache-Timestamp", runData.Timestamp)
r.Out.Header.Set("Forgejo-Cache-MAC", runData.RepositoryMAC)
r.Out.Header.Set("Forgejo-Cache-Host", h.ExternalURL())
},
}
return proxy, nil
}
func (h *Handler) ExternalURL() string {
// TODO: make the external url configurable if necessary
return fmt.Sprintf("http://%s", net.JoinHostPort(h.outboundIP, strconv.Itoa(h.listener.Addr().(*net.TCPAddr).Port)))
}
// Informs the proxy of a workflow run that can make cache requests.
// The RunData contains the information about the repository.
// The function returns the 32-bit random key which the run will use to identify itself.
func (h *Handler) AddRun(data RunData) (string, error) {
for retries := 0; retries < 3; retries++ {
keyBytes := make([]byte, 4)
_, err := rand.Read(keyBytes)
if err != nil {
return "", errors.New("Could not generate the run id")
}
key := hex.EncodeToString(keyBytes)
_, loaded := h.runs.LoadOrStore(key, data)
if !loaded {
// The key was unique and added successfully
return key, nil
}
}
return "", errors.New("Repeated collisions in generating run id")
}
func (h *Handler) RemoveRun(runID string) error {
_, existed := h.runs.LoadAndDelete(runID)
if !existed {
return errors.New("The run id was not known to the proxy")
}
return nil
}
func (h *Handler) Close() error {
if h == nil {
return nil
}
var retErr error
if h.server != nil {
err := h.server.Close()
if err != nil {
retErr = err
}
h.server = nil
}
if h.listener != nil {
err := h.listener.Close()
if !errors.Is(err, net.ErrClosed) {
retErr = err
}
h.listener = nil
}
return retErr
}
func computeMac(secret, repo, run, ts string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(repo))
mac.Write([]byte(">"))
mac.Write([]byte(run))
mac.Write([]byte(">"))
mac.Write([]byte(ts))
return hex.EncodeToString(mac.Sum(nil))
}

View file

@ -59,6 +59,7 @@ type Input struct {
logPrefixJobID bool
networkName string
useNewActionCache bool
secret string
}
func (i *Input) resolve(path string) string {

View file

@ -632,7 +632,7 @@ func newRunCommand(ctx context.Context, input *Input) func(*cobra.Command, []str
var cacheHandler *artifactcache.Handler
if !input.noCacheServer && envs[cacheURLKey] == "" {
var err error
cacheHandler, err = artifactcache.StartHandler(input.cacheServerPath, input.cacheServerAddr, input.cacheServerPort, common.Logger(ctx))
cacheHandler, err = artifactcache.StartHandler(input.cacheServerPath, input.cacheServerAddr, input.cacheServerPort, input.secret, common.Logger(ctx))
if err != nil {
return err
}