Skip to content

Commit a5afccb

Browse files
authored
fix(filesystem): improve path traversal detection checks (#1152)
* fix(filesystem): improve path traversal detection by centralizing trusted root checks * fix(filesystem): remove duplicate code * fix(filesystem): return absPath on path traversal error for improved error handling * refactor(filesystem): rename InTrustedRoot to InBasePath for clarity and update references
1 parent 6c61c3c commit a5afccb

File tree

5 files changed

+104
-52
lines changed

5 files changed

+104
-52
lines changed

internal/docker/compose.go

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"time"
1717

1818
"github.com/kimdre/doco-cd/internal/encryption"
19+
"github.com/kimdre/doco-cd/internal/filesystem"
1920
"github.com/kimdre/doco-cd/internal/utils/module"
2021

2122
"github.com/kimdre/doco-cd/internal/docker/swarm"
@@ -630,26 +631,6 @@ func getPaths(changedFiles []gitInternal.ChangedFile, basePath string) []string
630631
return slice.Unique(absPaths)
631632
}
632633

633-
// checkPathAffected checks if a changed file is affected by a used file.
634-
func checkPathAffected(changed string, used string) bool {
635-
used = filepath.Clean(used)
636-
changed = filepath.Clean(changed)
637-
638-
rel, err := filepath.Rel(used, changed)
639-
if err != nil {
640-
// It' share same reporoot, so it should not happen
641-
slog.Debug("checkPathAffected ",
642-
slog.String("used", used),
643-
slog.String("changed", changed),
644-
slog.Any("error", err),
645-
)
646-
647-
return false
648-
}
649-
650-
return !strings.HasPrefix(rel, "..")
651-
}
652-
653634
// HasChangedConfigs checks if any files used in docker compose `configs:` definitions have changed using the Git status.
654635
func HasChangedConfigs(paths []string, project *types.Project) ([]string, error) {
655636
configToServicesMap := make(map[string][]string)
@@ -669,7 +650,7 @@ func HasChangedConfigs(paths []string, project *types.Project) ([]string, error)
669650
}
670651

671652
for _, p := range paths {
672-
if checkPathAffected(p, c.File) {
653+
if filesystem.InBasePath(c.File, p) {
673654
changedServices = append(changedServices, configToServicesMap[name]...)
674655
}
675656
}
@@ -696,7 +677,7 @@ func HasChangedSecrets(paths []string, project *types.Project) ([]string, error)
696677
}
697678

698679
for _, p := range paths {
699-
if checkPathAffected(p, s.File) {
680+
if filesystem.InBasePath(s.File, p) {
700681
changedServices = append(changedServices, secretsToServicesMap[name]...)
701682
}
702683
}
@@ -714,7 +695,7 @@ func HasChangedBindMounts(paths []string, project *types.Project) ([]string, err
714695
for _, v := range s.Volumes {
715696
if v.Type == "bind" && v.Source != "" {
716697
for _, p := range paths {
717-
if checkPathAffected(p, v.Source) {
698+
if filesystem.InBasePath(v.Source, p) {
718699
changedServices = append(changedServices, s.Name)
719700
break out
720701
}
@@ -734,7 +715,7 @@ func HasChangedEnvFiles(paths []string, project *types.Project) ([]string, error
734715
out:
735716
for _, envFile := range s.EnvFiles {
736717
for _, p := range paths {
737-
if checkPathAffected(p, envFile.Path) {
718+
if filesystem.InBasePath(envFile.Path, p) {
738719
changedServices = append(changedServices, s.Name)
739720
break out
740721
}
@@ -794,7 +775,7 @@ func HasChangedBuildFiles(paths []string, project *types.Project) ([]string, err
794775
}
795776

796777
for _, p := range paths {
797-
if checkPathAffected(p, ctxFile) {
778+
if filesystem.InBasePath(ctxFile, p) {
798779
changedServices = append(changedServices, s.Name)
799780
break out
800781
}
@@ -1090,7 +1071,7 @@ func DecryptProjectFiles(repoPath string, p *types.Project) ([]string, error) {
10901071
if info.IsDir() {
10911072
decryptedFiles, err = encryption.DecryptFilesInDirectory(repoPath, v.Source)
10921073
if err != nil {
1093-
if errors.Is(err, encryption.ErrPathTraversal) {
1074+
if errors.Is(err, filesystem.ErrPathTraversal) {
10941075
continue
10951076
}
10961077

internal/docker/compose_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,7 +1569,7 @@ func TestGetProjects(t *testing.T) {
15691569
t.Logf("Found %d projects", len(projects))
15701570
}
15711571

1572-
func Test_checkPathAffected(t *testing.T) {
1572+
func TestCheckPathAffected(t *testing.T) {
15731573
const repoRoot = "/data/reporoot"
15741574

15751575
tests := []struct {
@@ -1678,7 +1678,7 @@ func Test_checkPathAffected(t *testing.T) {
16781678
}
16791679
for _, tt := range tests {
16801680
t.Run(tt.name, func(t *testing.T) {
1681-
got := checkPathAffected(tt.changed, tt.used)
1681+
got := filesystem.InBasePath(tt.used, tt.changed)
16821682
if tt.want != got {
16831683
t.Errorf("checkPathAffected(used=%q, changed=%q) = %v, want %v", tt.used, tt.changed, got, tt.want)
16841684
}

internal/encryption/decrypt.go

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,7 @@ var IgnoreDirs = []string{
2727
"node_modules",
2828
}
2929

30-
var (
31-
ErrSopsKeyNotSet = errors.New("SOPS secret key is not set")
32-
ErrPathTraversal = errors.New("path traversal detected")
33-
)
30+
var ErrSopsKeyNotSet = errors.New("SOPS secret key is not set")
3431

3532
func GetFileFormat(path string) string {
3633
var format string
@@ -66,25 +63,10 @@ func DecryptContent(content []byte, format string) ([]byte, error) {
6663
return decrypt.Data(content, format)
6764
}
6865

69-
// Add a check to ensure files/directories are within the repository root.
70-
func isWithinRepoRoot(repoPath, targetPath string) bool {
71-
absRepoPath, err := filepath.Abs(repoPath)
72-
if err != nil {
73-
return false
74-
}
75-
76-
absTargetPath, err := filepath.Abs(targetPath)
77-
if err != nil {
78-
return false
79-
}
80-
81-
return strings.HasPrefix(absTargetPath, absRepoPath)
82-
}
83-
8466
// DecryptFilesInDirectory walks through the specified directory and decrypts all SOPS-encrypted files.
8567
func DecryptFilesInDirectory(repoPath, dirPath string) ([]string, error) {
86-
if !isWithinRepoRoot(repoPath, dirPath) {
87-
return nil, fmt.Errorf("%w: %s is outside the repository root %s", ErrPathTraversal, dirPath, repoPath)
68+
if !filesystem.InBasePath(repoPath, dirPath) {
69+
return nil, fmt.Errorf("%w: %s is outside the repository root %s", filesystem.ErrPathTraversal, dirPath, repoPath)
8870
}
8971

9072
var decryptedFiles []string
@@ -138,7 +120,7 @@ func DecryptFilesInDirectory(repoPath, dirPath string) ([]string, error) {
138120

139121
// Recursively walk the symlink target
140122
_, err = DecryptFilesInDirectory(repoPath, absTarget)
141-
if errors.Is(err, ErrPathTraversal) {
123+
if errors.Is(err, filesystem.ErrPathTraversal) {
142124
return nil
143125
}
144126

internal/filesystem/filesystem.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,27 @@ func VerifyAndSanitizePath(path, trustedRoot string) (string, error) {
3131

3232
trustedRoot = filepath.Clean(trustedRoot) + string(os.PathSeparator)
3333

34-
if !strings.HasPrefix(absPath, trustedRoot) {
35-
return absPath, ErrPathTraversal
34+
if !InBasePath(trustedRoot, absPath) {
35+
return absPath, fmt.Errorf("%w: %s is outside of trusted root %s", ErrPathTraversal, absPath, trustedRoot)
3636
}
3737

3838
return absPath, nil
3939
}
4040

41+
// InBasePath checks if the given path is within the base path,
42+
// preventing path traversal attacks.
43+
func InBasePath(basePath, path string) bool {
44+
basePath = filepath.Clean(basePath)
45+
path = filepath.Clean(path)
46+
47+
rel, err := filepath.Rel(basePath, path)
48+
if err != nil {
49+
return false
50+
}
51+
52+
return !strings.HasPrefix(rel, "..")
53+
}
54+
4155
// IsSocket checks if the given path is a socket file.
4256
func IsSocket(path string) bool {
4357
info, err := os.Stat(path)

internal/filesystem/filesystem_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,78 @@ func TestVerifyAndSanitizePath(t *testing.T) {
6262
})
6363
}
6464
}
65+
66+
func TestInBasePath(t *testing.T) {
67+
t.Parallel()
68+
69+
testCases := []struct {
70+
name string
71+
path string
72+
trustedRoot string
73+
expected bool
74+
}{
75+
{
76+
name: "Same path",
77+
path: "/valid/path",
78+
trustedRoot: "/valid/path",
79+
expected: true,
80+
},
81+
{
82+
name: "Path within trusted root",
83+
path: "/valid/path",
84+
trustedRoot: "/valid",
85+
expected: true,
86+
},
87+
{
88+
name: "Path outside trusted root",
89+
path: "/invalid/path",
90+
trustedRoot: "/valid",
91+
expected: false,
92+
},
93+
{
94+
name: "Absolute Path with traversal",
95+
path: "/valid/../../invalid/path",
96+
trustedRoot: "/valid",
97+
expected: false,
98+
},
99+
{
100+
name: "Relative path",
101+
path: "valid/path",
102+
trustedRoot: "valid",
103+
expected: true,
104+
},
105+
{
106+
name: "Relative path with traversal",
107+
path: "valid/../../invalid/path",
108+
trustedRoot: "valid",
109+
expected: false,
110+
},
111+
{
112+
name: "Relative path outside trusted root",
113+
path: "invalid/path",
114+
trustedRoot: "valid",
115+
expected: false,
116+
},
117+
{
118+
name: "Path in base outside trusted root",
119+
path: "/base/invalid/path",
120+
trustedRoot: "/base/valid",
121+
expected: false,
122+
},
123+
{
124+
name: "Path in base in trusted root",
125+
path: "/base/valid/path",
126+
trustedRoot: "/base/valid",
127+
expected: true,
128+
},
129+
}
130+
for _, tc := range testCases {
131+
t.Run(tc.name, func(t *testing.T) {
132+
result := InBasePath(tc.trustedRoot, tc.path)
133+
134+
if result != tc.expected {
135+
t.Fatalf("expected %v, got %v", tc.expected, result)
136+
}
137+
})
138+
}
139+
}

0 commit comments

Comments
 (0)