Skip to content

Commit 69bcb96

Browse files
committed
Enforce compose files from OCI artifact all get into the same target (cache) folder
Signed-off-by: Guillaume Lours <[email protected]> Signed-off-by: Nicolas De Loof <[email protected]>
1 parent 9b4fcce commit 69bcb96

File tree

2 files changed

+170
-21
lines changed

2 files changed

+170
-21
lines changed

pkg/remote/oci.go

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,32 @@ const (
3939
OciPrefix = "oci://"
4040
)
4141

42+
// validatePathInBase ensures a file path is contained within the base directory,
43+
// as OCI artifacts resources must all live within the same folder.
44+
func validatePathInBase(base, unsafePath string) error {
45+
// Reject paths with path separators regardless of OS
46+
if strings.ContainsAny(unsafePath, "\\/") {
47+
return fmt.Errorf("invalid OCI artifact")
48+
}
49+
50+
// Join the base with the untrusted path
51+
targetPath := filepath.Join(base, unsafePath)
52+
53+
// Get the directory of the target path
54+
targetDir := filepath.Dir(targetPath)
55+
56+
// Clean both paths to resolve any .. or . components
57+
cleanBase := filepath.Clean(base)
58+
cleanTargetDir := filepath.Clean(targetDir)
59+
60+
// Check if the target directory is the same as base directory
61+
if cleanTargetDir != cleanBase {
62+
return fmt.Errorf("invalid OCI artifact")
63+
}
64+
65+
return nil
66+
}
67+
4268
func ociRemoteLoaderEnabled() (bool, error) {
4369
if v := os.Getenv(OCI_REMOTE_ENABLED); v != "" {
4470
enabled, err := strconv.ParseBool(v)
@@ -158,12 +184,6 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, man
158184
if err != nil {
159185
return err
160186
}
161-
composeFile := filepath.Join(local, "compose.yaml")
162-
f, err := os.Create(composeFile)
163-
if err != nil {
164-
return err
165-
}
166-
defer f.Close() //nolint:errcheck
167187
if (manifest.ArtifactType != "" && manifest.ArtifactType != oci.ComposeProjectArtifactType) ||
168188
(manifest.ArtifactType == "" && manifest.Config.MediaType != oci.ComposeEmptyConfigMediaType) {
169189
return fmt.Errorf("%s is not a compose project OCI artifact, but %s", ref.String(), manifest.ArtifactType)
@@ -182,15 +202,7 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, man
182202

183203
switch layer.MediaType {
184204
case oci.ComposeYAMLMediaType:
185-
target := f
186-
_, extends := layer.Annotations["com.docker.compose.extends"]
187-
if extends {
188-
target, err = os.Create(filepath.Join(local, layer.Annotations["com.docker.compose.file"]))
189-
if err != nil {
190-
return err
191-
}
192-
}
193-
if err := writeComposeFile(layer, i, target, content); err != nil {
205+
if err := writeComposeFile(layer, i, local, content); err != nil {
194206
return err
195207
}
196208
case oci.ComposeEnvFileMediaType:
@@ -203,14 +215,25 @@ func (g ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, man
203215
return nil
204216
}
205217

206-
func writeComposeFile(layer spec.Descriptor, i int, f *os.File, content []byte) error {
218+
func writeComposeFile(layer spec.Descriptor, i int, local string, content []byte) error {
219+
file := "compose.yaml"
220+
if extends, ok := layer.Annotations["com.docker.compose.extends"]; ok {
221+
if err := validatePathInBase(local, extends); err != nil {
222+
return err
223+
}
224+
}
225+
f, err := os.Create(filepath.Join(local, file))
226+
if err != nil {
227+
return err
228+
}
229+
defer func() { _ = f.Close() }()
207230
if _, ok := layer.Annotations["com.docker.compose.file"]; i > 0 && ok {
208231
_, err := f.Write([]byte("\n---\n"))
209232
if err != nil {
210233
return err
211234
}
212235
}
213-
_, err := f.Write(content)
236+
_, err = f.Write(content)
214237
return err
215238
}
216239

@@ -219,15 +242,16 @@ func writeEnvFile(layer spec.Descriptor, local string, content []byte) error {
219242
if !ok {
220243
return fmt.Errorf("missing annotation com.docker.compose.envfile in layer %q", layer.Digest)
221244
}
222-
otherFile, err := os.Create(filepath.Join(local, envfilePath))
223-
if err != nil {
245+
if err := validatePathInBase(local, envfilePath); err != nil {
224246
return err
225247
}
226-
_, err = otherFile.Write(content)
248+
otherFile, err := os.Create(filepath.Join(local, envfilePath))
227249
if err != nil {
228250
return err
229251
}
230-
return nil
252+
defer func() { _ = otherFile.Close() }()
253+
_, err = otherFile.Write(content)
254+
return err
231255
}
232256

233257
var _ loader.ResourceLoader = ociRemoteLoader{}

pkg/remote/oci_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package remote
18+
19+
import (
20+
"path/filepath"
21+
"testing"
22+
)
23+
24+
func TestValidatePathInBase(t *testing.T) {
25+
base := "/tmp/cache/compose"
26+
27+
tests := []struct {
28+
name string
29+
unsafePath string
30+
wantErr bool
31+
}{
32+
{
33+
name: "valid simple filename",
34+
unsafePath: "compose.yaml",
35+
wantErr: false,
36+
},
37+
{
38+
name: "valid hashed filename",
39+
unsafePath: "f8f9ede3d201ec37d5a5e3a77bbadab79af26035e53135e19571f50d541d390c.yaml",
40+
wantErr: false,
41+
},
42+
{
43+
name: "valid env file",
44+
unsafePath: ".env",
45+
wantErr: false,
46+
},
47+
{
48+
name: "valid env file with suffix",
49+
unsafePath: ".env.prod",
50+
wantErr: false,
51+
},
52+
{
53+
name: "unix path traversal",
54+
unsafePath: "../../../etc/passwd",
55+
wantErr: true,
56+
},
57+
{
58+
name: "windows path traversal",
59+
unsafePath: "..\\..\\..\\windows\\system32\\config\\sam",
60+
wantErr: true,
61+
},
62+
{
63+
name: "subdirectory unix",
64+
unsafePath: "config/base.yaml",
65+
wantErr: true,
66+
},
67+
{
68+
name: "subdirectory windows",
69+
unsafePath: "config\\base.yaml",
70+
wantErr: true,
71+
},
72+
{
73+
name: "absolute unix path",
74+
unsafePath: "/etc/passwd",
75+
wantErr: true,
76+
},
77+
{
78+
name: "absolute windows path",
79+
unsafePath: "C:\\windows\\system32\\config\\sam",
80+
wantErr: true,
81+
},
82+
{
83+
name: "parent reference only",
84+
unsafePath: "..",
85+
wantErr: true,
86+
},
87+
{
88+
name: "current directory reference",
89+
unsafePath: "./file.yaml",
90+
wantErr: false, // ./ resolves to base dir
91+
},
92+
{
93+
name: "mixed separators",
94+
unsafePath: "config/sub\\file.yaml",
95+
wantErr: true,
96+
},
97+
{
98+
name: "filename with spaces",
99+
unsafePath: "my file.yaml",
100+
wantErr: false,
101+
},
102+
{
103+
name: "filename with special chars",
104+
unsafePath: "file-name_v1.2.3.yaml",
105+
wantErr: false,
106+
},
107+
{
108+
name: "single parent then back",
109+
unsafePath: "../compose/file.yaml",
110+
wantErr: false, // Resolves back to base dir, which is fine
111+
},
112+
}
113+
114+
for _, tt := range tests {
115+
t.Run(tt.name, func(t *testing.T) {
116+
err := validatePathInBase(base, tt.unsafePath)
117+
if (err != nil) != tt.wantErr {
118+
targetPath := filepath.Join(base, tt.unsafePath)
119+
targetDir := filepath.Dir(targetPath)
120+
t.Errorf("validatePathInBase(%q, %q) error = %v, wantErr %v\ntargetDir=%q base=%q",
121+
base, tt.unsafePath, err, tt.wantErr, targetDir, base)
122+
}
123+
})
124+
}
125+
}

0 commit comments

Comments
 (0)