CVE-2023-22726 (Go, published 2023-01-20T22:15Z)
Description: value: act is a project which allows for local running of github actions. The artifact server that stores artifacts from Github Action runs does not sanitize path inputs. This allows an attacker to download and overwrite arbitrary files on the host from a Github Action. This issue may lead to privilege escalation. The /upload endpoint is vulnerable to path traversal as filepath is user controlled, and ultimately flows into os.Mkdir and os.Open. The /artifact endpoint is vulnerable to path traversal as the path is variable is user controlled, and the specified file is ultimately returned by the server. This has been addressed in version 0.2.40. Users are advised to upgrade. Users unable to upgrade may, during implementation of Open and OpenAtEnd for FS, ensure to use ValidPath() to check against path traversal or clean the user-provided paths manually.
Severity: nan (CVSS v3 base score: 8.8)
Weakness: CWE-22 \u2014 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
Repository: https://github.com/nektos/act
Files changed:
.vscode/settings.json
pkg/artifacts/server.go
pkg/artifacts/server_test.go
pkg/artifacts/testdata/GHSL-2023-004/artifacts.yml
Commit message: fix: update artifact server to address GHSL-2023-004 (#1565)



Vulnerable code:
	"path"
type MkdirFS interface {
	fs.FS
	MkdirAll(path string, perm fs.FileMode) error
	Open(name string) (fs.File, error)
	OpenAtEnd(name string) (fs.File, error)
type MkdirFsImpl struct {
	dir string
	fs.FS
func (fsys MkdirFsImpl) MkdirAll(path string, perm fs.FileMode) error {
	return os.MkdirAll(fsys.dir+"/"+path, perm)
func (fsys MkdirFsImpl) Open(name string) (fs.File, error) {
	return os.OpenFile(fsys.dir+"/"+name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
func (fsys MkdirFsImpl) OpenAtEnd(name string) (fs.File, error) {
	file, err := os.OpenFile(fsys.dir+"/"+name, os.O_CREATE|os.O_RDWR, 0644)

func uploads(router *httprouter.Router, fsys MkdirFS) {
		filePath := fmt.Sprintf("%s/%s", runID, itemPath)
		err := fsys.MkdirAll(path.Dir(filePath), os.ModePerm)
		if err != nil {
			panic(err)
		}

		file, err := func() (fs.File, error) {
				return fsys.OpenAtEnd(filePath)
			return fsys.Open(filePath)
func downloads(router *httprouter.Router, fsys fs.FS) {
		entries, err := fs.ReadDir(fsys, runID)
		dirPath := fmt.Sprintf("%s/%s", container, itemPath)
		err := fs.WalkDir(fsys, dirPath, func(path string, entry fs.DirEntry, err error) error {
				rel, err := filepath.Rel(dirPath, path)
					Path:            fmt.Sprintf("%s/%s", itemPath, rel),
		file, err := fsys.Open(path)
			file, err = fsys.Open(path + gzipExtension)
	fs := os.DirFS(artifactPath)
	uploads(router, MkdirFsImpl{artifactPath, fs})
	downloads(router, fs)
	"io/fs"
type MapFsImpl struct {
	fstest.MapFS
func (fsys MapFsImpl) MkdirAll(path string, perm fs.FileMode) error {
	// mocked no-op
	return nil
type WritableFile struct {
	fs.File
	fsys fstest.MapFS
	path string
func (file WritableFile) Write(data []byte) (int, error) {
	file.fsys[file.path].Data = data
	return len(data), nil
func (fsys MapFsImpl) Open(path string) (fs.File, error) {
	var file = fstest.MapFile{
		Data: []byte("content2"),
	fsys.MapFS[path] = &file
	result, err := fsys.MapFS.Open(path)
	return WritableFile{result, fsys.MapFS, path}, err
func (fsys MapFsImpl) OpenAtEnd(path string) (fs.File, error) {
	var file = fstest.MapFile{
		Data: []byte("content2"),
	fsys.MapFS[path] = &file
	result, err := fsys.MapFS.Open(path)
	return WritableFile{result, fsys.MapFS, path}, err
	uploads(router, MapFsImpl{memfs})
	uploads(router, MapFsImpl{memfs})
	assert.Equal("content", string(memfs["1/some/file"].Data))
	uploads(router, MapFsImpl{memfs})
		"1/file.txt": {
	downloads(router, memfs)
		"1/some/file": {
	downloads(router, memfs)
	assert.Equal("some/file/.", response.Value[0].Path)
		"1/some/file": {
	downloads(router, memfs)

Fixed code:
  "go.testTimeout": "300s",
type WritableFile interface {
	io.WriteCloser
type WriteFS interface {
	OpenWritable(name string) (WritableFile, error)
	OpenAppendable(name string) (WritableFile, error)
type readWriteFSImpl struct {
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
	return os.Open(name)
func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
	if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
		return nil, err
	}
	return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
}

func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
	if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
		return nil, err
	}
	file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0644)
func safeResolve(baseDir string, relPath string) string {
	return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
}

func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
		safeRunPath := safeResolve(baseDir, runID)
		safePath := safeResolve(safeRunPath, itemPath)
		file, err := func() (WritableFile, error) {
				return fsys.OpenAppendable(safePath)
			return fsys.OpenWritable(safePath)
func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
		safePath := safeResolve(baseDir, runID)

		entries, err := fs.ReadDir(fsys, safePath)
		safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
		err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
				rel, err := filepath.Rel(safePath, path)
					Path:            filepath.Join(itemPath, rel),
		safePath := safeResolve(baseDir, path)

		file, err := fsys.Open(safePath)
			file, err = fsys.Open(safePath + gzipExtension)
	fsys := readWriteFSImpl{}
	uploads(router, artifactPath, fsys)
	downloads(router, artifactPath, fsys)
type writableMapFile struct {
	fstest.MapFile
func (f *writableMapFile) Write(data []byte) (int, error) {
	f.Data = data
	return len(data), nil
func (f *writableMapFile) Close() error {
	return nil
type writeMapFS struct {
	fstest.MapFS
func (fsys writeMapFS) OpenWritable(name string) (WritableFile, error) {
	var file = &writableMapFile{
		MapFile: fstest.MapFile{
			Data: []byte("content2"),
		},
	fsys.MapFS[name] = &file.MapFile
	return file, nil
func (fsys writeMapFS) OpenAppendable(name string) (WritableFile, error) {
	var file = &writableMapFile{
		MapFile: fstest.MapFile{
			Data: []byte("content2"),
		},
	fsys.MapFS[name] = &file.MapFile
	return file, nil
	uploads(router, "artifact/server/path", writeMapFS{memfs})
	uploads(router, "artifact/server/path", writeMapFS{memfs})
	assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
	uploads(router, "artifact/server/path", writeMapFS{memfs})
		"artifact/server/path/1/file.txt": {
	downloads(router, "artifact/server/path", memfs)
		"artifact/server/path/1/some/file": {
	downloads(router, "artifact/server/path", memfs)
	assert.Equal("some/file", response.Value[0].Path)
		"artifact/server/path/1/some/file": {
	downloads(router, "artifact/server/path", memfs)
		{"testdata", "GHSL-2023-004", "push", "", platforms, ""},

func TestMkdirFsImplSafeResolve(t *testing.T) {
	assert := assert.New(t)

	baseDir := "/foo/bar"

	tests := map[string]struct {
		input string
		want  string
	}{
		"simple":         {input: "baz", want: "/foo/bar/baz"},
		"nested":         {input: "baz/blue", want: "/foo/bar/baz/blue"},
		"dots in middle": {input: "baz/../../blue", want: "/foo/bar/blue"},
		"leading dots":   {input: "../../parent", want: "/foo/bar/parent"},
		"root path":      {input: "/root", want: "/foo/bar/root"},
		"root":           {input: "/", want: "/foo/bar"},
		"empty":          {input: "", want: "/foo/bar"},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {
			assert.Equal(tc.want, safeResolve(baseDir, tc.input))
		})
	}
}

func TestDownloadArtifactFileUnsafePath(t *testing.T) {
	assert := assert.New(t)

	var memfs = fstest.MapFS(map[string]*fstest.MapFile{
		"artifact/server/path/some/file": {
			Data: []byte("content"),
		},
	})

	router := httprouter.New()
	downloads(router, "artifact/server/path", memfs)

	req, _ := http.NewRequest("GET", "http://localhost/artifact/2/../../some/file", nil)
	rr := httptest.NewRecorder()

	router.ServeHTTP(rr, req)

	if status := rr.Code; status != http.StatusOK {
		assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
	}

	data := rr.Body.Bytes()

	assert.Equal("content", string(data))
}

func TestArtifactUploadBlobUnsafePath(t *testing.T) {
	assert := assert.New(t)

	var memfs = fstest.MapFS(map[string]*fstest.MapFile{})

	router := httprouter.New()
	uploads(router, "artifact/server/path", writeMapFS{memfs})

	req, _ := http.NewRequest("PUT", "http://localhost/upload/1?itemPath=../../some/file", strings.NewReader("content"))
	rr := httptest.NewRecorder()

	router.ServeHTTP(rr, req)

	if status := rr.Code; status != http.StatusOK {
		assert.Fail("Wrong status")
	}

	response := ResponseMessage{}
	err := json.Unmarshal(rr.Body.Bytes(), &response)
	if err != nil {
		panic(err)
	}

	assert.Equal("success", response.Message)
	assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
}

name: "GHSL-2023-0004"
on: push

jobs:
  test-artifacts:
    runs-on: ubuntu-latest
    steps:
      - run: echo "hello world" > test.txt
      - name: curl upload
        uses: wei/curl@v1
        with:
          args: -s --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
      - uses: actions/download-artifact@v2
        with:
          name: my-artifact
          path: test-artifacts
      - name: 'Verify Artifact #1'
        run: |
          file="test-artifacts/secret.txt"
          if [ ! -f $file ] ; then
            echo "Expected file does not exist"
            exit 1
          fi
          if [ "$(cat $file)" != "hello world" ] ; then
            echo "File contents of downloaded artifact are incorrect"
            exit 1
          fi
      - name: Verify download should work by clean extra dots
        uses: wei/curl@v1
        with:
          args: --path-as-is -s -o out.txt --fail ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
      - name: 'Verify download content'
        run: |
          file="out.txt"
          if [ ! -f $file ] ; then
            echo "Expected file does not exist"
            exit 1
          fi
          if [ "$(cat $file)" != "hello world" ] ; then
            echo "File contents of downloaded artifact are incorrect"
            exit 1
          fi