Skip to content

Commit d36d95d

Browse files
authored
Merge pull request from GHSA-6gcg-hp2x-q54h
* fix: do not allow symlinks from directory-type applications Signed-off-by: Michael Crenshaw <[email protected]> * chore: add new util file Signed-off-by: Michael Crenshaw <[email protected]> * chore: lint Signed-off-by: Michael Crenshaw <[email protected]> * chore: use t.TempDir for simpler tests Signed-off-by: Michael Crenshaw <[email protected]> * address comments Signed-off-by: Michael Crenshaw <[email protected]>
1 parent df79d7d commit d36d95d

File tree

4 files changed

+198
-9
lines changed

4 files changed

+198
-9
lines changed

reposerver/repository/repository.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"strings"
1919
"time"
2020

21+
"github.com/argoproj/argo-cd/v2/util/io/files"
22+
2123
"github.com/Masterminds/semver/v3"
2224
"github.com/TomOnTime/utfutil"
2325
"github.com/argoproj/gitops-engine/pkg/utils/kube"
@@ -810,7 +812,8 @@ func GenerateManifests(ctx context.Context, appPath, repoRoot, revision string,
810812
if directory = q.ApplicationSource.Directory; directory == nil {
811813
directory = &v1alpha1.ApplicationSourceDirectory{}
812814
}
813-
targetObjs, err = findManifests(appPath, repoRoot, env, *directory, q.EnabledSourceTypes)
815+
logCtx := log.WithField("application", q.AppName)
816+
targetObjs, err = findManifests(logCtx, appPath, repoRoot, env, *directory, q.EnabledSourceTypes)
814817
}
815818
if err != nil {
816819
return nil, err
@@ -1012,12 +1015,32 @@ func ksShow(appLabelKey, appPath string, ksonnetOpts *v1alpha1.ApplicationSource
10121015
var manifestFile = regexp.MustCompile(`^.*\.(yaml|yml|json|jsonnet)$`)
10131016

10141017
// findManifests looks at all yaml files in a directory and unmarshals them into a list of unstructured objects
1015-
func findManifests(appPath string, repoRoot string, env *v1alpha1.Env, directory v1alpha1.ApplicationSourceDirectory, enabledManifestGeneration map[string]bool) ([]*unstructured.Unstructured, error) {
1018+
func findManifests(logCtx *log.Entry, appPath string, repoRoot string, env *v1alpha1.Env, directory v1alpha1.ApplicationSourceDirectory, enabledManifestGeneration map[string]bool) ([]*unstructured.Unstructured, error) {
10161019
var objs []*unstructured.Unstructured
10171020
err := filepath.Walk(appPath, func(path string, f os.FileInfo, err error) error {
10181021
if err != nil {
10191022
return err
10201023
}
1024+
relPath, err := filepath.Rel(appPath, path)
1025+
if err != nil {
1026+
return fmt.Errorf("failed to get relative path of symlink: %w", err)
1027+
}
1028+
if files.IsSymlink(f) {
1029+
realPath, err := filepath.EvalSymlinks(path)
1030+
if err != nil {
1031+
logCtx.Debugf("error checking symlink realpath: %s", err)
1032+
if os.IsNotExist(err) {
1033+
log.Warnf("ignoring out-of-bounds symlink at %q: %s", relPath, err)
1034+
return nil
1035+
} else {
1036+
return fmt.Errorf("failed to evaluate symlink at %q: %w", relPath, err)
1037+
}
1038+
}
1039+
if !files.Inbound(realPath, appPath) {
1040+
logCtx.Warnf("illegal filepath in symlink: %s", realPath)
1041+
return fmt.Errorf("illegal filepath in symlink at %q", relPath)
1042+
}
1043+
}
10211044
if f.IsDir() {
10221045
if path != appPath && !directory.Recurse {
10231046
return filepath.SkipDir
@@ -1030,10 +1053,6 @@ func findManifests(appPath string, repoRoot string, env *v1alpha1.Env, directory
10301053
return nil
10311054
}
10321055

1033-
relPath, err := filepath.Rel(appPath, path)
1034-
if err != nil {
1035-
return err
1036-
}
10371056
if directory.Exclude != "" && glob.Match(directory.Exclude, relPath) {
10381057
return nil
10391058
}

reposerver/repository/repository_test.go

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"testing"
1717
"time"
1818

19+
log "github.com/sirupsen/logrus"
20+
1921
"github.com/ghodss/yaml"
2022
"github.com/stretchr/testify/assert"
2123
"github.com/stretchr/testify/mock"
@@ -149,6 +151,76 @@ func TestGenerateYamlManifestInDir(t *testing.T) {
149151
assert.Equal(t, 3, len(res2.Manifests))
150152
}
151153

154+
func Test_GenerateManifests_NoOutOfBoundsAccess(t *testing.T) {
155+
testCases := []struct {
156+
name string
157+
outOfBoundsFilename string
158+
outOfBoundsFileContents string
159+
mustNotContain string // Optional string that must not appear in error or manifest output. If empty, use outOfBoundsFileContents.
160+
}{
161+
{
162+
name: "out of bounds JSON file should not appear in error output",
163+
outOfBoundsFilename: "test.json",
164+
outOfBoundsFileContents: `{"some": "json"}`,
165+
},
166+
{
167+
name: "malformed JSON file contents should not appear in error output",
168+
outOfBoundsFilename: "test.json",
169+
outOfBoundsFileContents: "$",
170+
},
171+
{
172+
name: "out of bounds JSON manifest should not appear in manifest output",
173+
outOfBoundsFilename: "test.json",
174+
// JSON marshalling is deterministic. So if there's a leak, exactly this should appear in the manifests.
175+
outOfBoundsFileContents: `{"apiVersion":"v1","kind":"Secret","metadata":{"name":"test","namespace":"default"},"type":"Opaque"}`,
176+
},
177+
{
178+
name: "out of bounds YAML manifest should not appear in manifest output",
179+
outOfBoundsFilename: "test.yaml",
180+
outOfBoundsFileContents: "apiVersion: v1\nkind: Secret\nmetadata:\n name: test\n namespace: default\ntype: Opaque",
181+
mustNotContain: `{"apiVersion":"v1","kind":"Secret","metadata":{"name":"test","namespace":"default"},"type":"Opaque"}`,
182+
},
183+
}
184+
185+
for _, testCase := range testCases {
186+
testCaseCopy := testCase
187+
t.Run(testCaseCopy.name, func(t *testing.T) {
188+
t.Parallel()
189+
190+
outOfBoundsDir := t.TempDir()
191+
outOfBoundsFile := path.Join(outOfBoundsDir, testCaseCopy.outOfBoundsFilename)
192+
err := os.WriteFile(outOfBoundsFile, []byte(testCaseCopy.outOfBoundsFileContents), os.FileMode(0444))
193+
require.NoError(t, err)
194+
195+
repoDir := t.TempDir()
196+
err = os.Symlink(outOfBoundsFile, path.Join(repoDir, testCaseCopy.outOfBoundsFilename))
197+
require.NoError(t, err)
198+
199+
var mustNotContain = testCaseCopy.outOfBoundsFileContents
200+
if testCaseCopy.mustNotContain != "" {
201+
mustNotContain = testCaseCopy.mustNotContain
202+
}
203+
204+
q := apiclient.ManifestRequest{Repo: &argoappv1.Repository{}, ApplicationSource: &argoappv1.ApplicationSource{}}
205+
res, err := GenerateManifests(context.Background(), repoDir, "", "", &q, false, &git.NoopCredsStore{})
206+
require.Error(t, err)
207+
assert.NotContains(t, err.Error(), mustNotContain)
208+
assert.Contains(t, err.Error(), "illegal filepath")
209+
assert.Nil(t, res)
210+
})
211+
}
212+
}
213+
214+
func TestGenerateManifests_MissingSymlinkDestination(t *testing.T) {
215+
repoDir := t.TempDir()
216+
err := os.Symlink("/obviously/does/not/exist", path.Join(repoDir, "test.yaml"))
217+
require.NoError(t, err)
218+
219+
q := apiclient.ManifestRequest{Repo: &argoappv1.Repository{}, ApplicationSource: &argoappv1.ApplicationSource{}}
220+
_, err = GenerateManifests(context.Background(), repoDir, "", "", &q, false, &git.NoopCredsStore{})
221+
require.NoError(t, err)
222+
}
223+
152224
func TestGenerateManifests_K8SAPIResetCache(t *testing.T) {
153225
service := newService("../..")
154226

@@ -1641,7 +1713,7 @@ func TestFindResources(t *testing.T) {
16411713
for i := range testCases {
16421714
tc := testCases[i]
16431715
t.Run(tc.name, func(t *testing.T) {
1644-
objs, err := findManifests("testdata/app-include-exclude", ".", nil, argoappv1.ApplicationSourceDirectory{
1716+
objs, err := findManifests(&log.Entry{}, "testdata/app-include-exclude", ".", nil, argoappv1.ApplicationSourceDirectory{
16451717
Recurse: true,
16461718
Include: tc.include,
16471719
Exclude: tc.exclude,
@@ -1659,7 +1731,7 @@ func TestFindResources(t *testing.T) {
16591731
}
16601732

16611733
func TestFindManifests_Exclude(t *testing.T) {
1662-
objs, err := findManifests("testdata/app-include-exclude", ".", nil, argoappv1.ApplicationSourceDirectory{
1734+
objs, err := findManifests(&log.Entry{}, "testdata/app-include-exclude", ".", nil, argoappv1.ApplicationSourceDirectory{
16631735
Recurse: true,
16641736
Exclude: "subdir/deploymentSub.yaml",
16651737
}, map[string]bool{})
@@ -1672,7 +1744,7 @@ func TestFindManifests_Exclude(t *testing.T) {
16721744
}
16731745

16741746
func TestFindManifests_Exclude_NothingMatches(t *testing.T) {
1675-
objs, err := findManifests("testdata/app-include-exclude", ".", nil, argoappv1.ApplicationSourceDirectory{
1747+
objs, err := findManifests(&log.Entry{}, "testdata/app-include-exclude", ".", nil, argoappv1.ApplicationSourceDirectory{
16761748
Recurse: true,
16771749
Exclude: "nothing.yaml",
16781750
}, map[string]bool{})

util/io/files/util.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package files
2+
3+
import (
4+
"io/fs"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// Inbound will validate if the given candidate path is inside the
11+
// baseDir. This is useful to make sure that malicious candidates
12+
// are not targeting a file outside of baseDir boundaries.
13+
// Considerations:
14+
// - baseDir must be absolute path. Will return false otherwise
15+
// - candidate can be absolute or relative path
16+
// - candidate should not be symlink as only syntatic validation is
17+
// applied by this function
18+
func Inbound(candidate, baseDir string) bool {
19+
if !filepath.IsAbs(baseDir) {
20+
return false
21+
}
22+
var target string
23+
if filepath.IsAbs(candidate) {
24+
target = filepath.Clean(candidate)
25+
} else {
26+
target = filepath.Join(baseDir, candidate)
27+
}
28+
return strings.HasPrefix(target, filepath.Clean(baseDir)+string(os.PathSeparator))
29+
}
30+
31+
// IsSymlink return true if the given FileInfo relates to a
32+
// symlink file. Returns false otherwise.
33+
func IsSymlink(fi os.FileInfo) bool {
34+
return fi.Mode()&fs.ModeSymlink == fs.ModeSymlink
35+
}

util/io/files/util_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package files_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/argoproj/argo-cd/v2/util/io/files"
9+
)
10+
11+
func TestInbound(t *testing.T) {
12+
type testcase struct {
13+
name string
14+
candidate string
15+
basedir string
16+
expected bool
17+
}
18+
cases := []testcase{
19+
{
20+
name: "will return true if candidate is inbound",
21+
candidate: "/home/test/app/readme.md",
22+
basedir: "/home/test",
23+
expected: true,
24+
},
25+
{
26+
name: "will return false if candidate is not inbound",
27+
candidate: "/home/test/../readme.md",
28+
basedir: "/home/test",
29+
expected: false,
30+
},
31+
{
32+
name: "will return true if candidate is relative inbound",
33+
candidate: "./readme.md",
34+
basedir: "/home/test",
35+
expected: true,
36+
},
37+
{
38+
name: "will return false if candidate is relative outbound",
39+
candidate: "../readme.md",
40+
basedir: "/home/test",
41+
expected: false,
42+
},
43+
{
44+
name: "will return false if basedir is relative",
45+
candidate: "/home/test/app/readme.md",
46+
basedir: "./test",
47+
expected: false,
48+
},
49+
}
50+
for _, c := range cases {
51+
c := c
52+
t.Run(c.name, func(t *testing.T) {
53+
// given
54+
t.Parallel()
55+
56+
// when
57+
inbound := files.Inbound(c.candidate, c.basedir)
58+
59+
// then
60+
assert.Equal(t, c.expected, inbound)
61+
})
62+
}
63+
}

0 commit comments

Comments
 (0)