Describe the bug
A Score file's containers[].files[].source field is read via os.ReadFile in internal/compose/convert.go:313 with no sandboxing on the resolved path. Absolute paths pass through unchanged; relative paths are joined to the Score file's directory using filepath.Join, which does not constrain .. segments. A Score file can therefore read any host file the running user can access (e.g. source: /etc/passwd or source: ../../../some/host/file), with the contents landing in <mountsDir>/files/ and bind-mounted into the container.
To Reproduce
- In an empty directory, run
score-compose init.
- Create a
score.yaml (shown below) declaring a container with a files entry pointing at a host file outside the project directory.
- Run
score-compose generate score.yaml.
- Observe that
.score-compose/mounts/files/leak-files-leaked contains the contents of /etc/hostname, and the generated compose.yaml bind-mounts it into the container.
score.yaml:
apiVersion: score.dev/v1b1
metadata:
name: leak
containers:
web:
image: alpine
files:
"/leaked":
source: "/etc/hostname"
A relative source: "../../../etc/hostname" demonstrates the same behavior filepath.Join does not reject the escape.
Expected behavior
A source path that resolves outside the Score file's directory should be rejected with a clear error, the same way internal/provisioners/core.go:208 already rejects non-local provisioner-generated paths using filepath.IsLocal.
Screenshots
N/A.
Desktop
- OS: any (the affected code path is platform-independent Go)
- Version: reproducible on
main (current HEAD)
Additional context
The codebase already uses the correct sandboxing primitive in one place but not the other:
internal/provisioners/core.go:208 rejects non-local paths from provisioner outputs with filepath.IsLocal.
internal/compose/convert.go:313 does not apply the same check to user-supplied files[].source paths.
Threat model:
- Local single-developer use: not affected the developer authors their own Score file.
- CI workflows running
score-compose generate on Score files from PR forks: contributor-supplied Score files can exfiltrate any file the runner can read into the mounts directory, which is often surfaced as a build artifact.
- Embedded / multi-tenant use: same exposure.
Proposed fix in internal/compose/convert.go:
sourcePath := *file.Source
if !filepath.IsLocal(sourcePath) {
return nil, fmt.Errorf("containers.%s.files[%s].source: must be a relative path within the Score file's directory", containerName, target)
}
if state.Workloads[workloadName].File != nil {
sourcePath = filepath.Join(filepath.Dir(*state.Workloads[workloadName].File), sourcePath)
}
content, err = os.ReadFile(sourcePath)
filepath.IsLocal (Go 1.20+) rejects absolute paths, .. escapes, and volume-prefixed paths in a single call.
Discussed briefly with @mathieu-benoit on Slack, who suggested filing this as a public issue + PR rather than a security advisory.
Thanks!
Describe the bug
A Score file's
containers[].files[].sourcefield is read viaos.ReadFileininternal/compose/convert.go:313with no sandboxing on the resolved path. Absolute paths pass through unchanged; relative paths are joined to the Score file's directory usingfilepath.Join, which does not constrain..segments. A Score file can therefore read any host file the running user can access (e.g.source: /etc/passwdorsource: ../../../some/host/file), with the contents landing in<mountsDir>/files/and bind-mounted into the container.To Reproduce
score-compose init.score.yaml(shown below) declaring a container with afilesentry pointing at a host file outside the project directory.score-compose generate score.yaml..score-compose/mounts/files/leak-files-leakedcontains the contents of/etc/hostname, and the generatedcompose.yamlbind-mounts it into the container.score.yaml:A relative
source: "../../../etc/hostname"demonstrates the same behaviorfilepath.Joindoes not reject the escape.Expected behavior
A
sourcepath that resolves outside the Score file's directory should be rejected with a clear error, the same wayinternal/provisioners/core.go:208already rejects non-local provisioner-generated paths usingfilepath.IsLocal.Screenshots
N/A.
Desktop
main(current HEAD)Additional context
The codebase already uses the correct sandboxing primitive in one place but not the other:
internal/provisioners/core.go:208rejects non-local paths from provisioner outputs withfilepath.IsLocal.internal/compose/convert.go:313does not apply the same check to user-suppliedfiles[].sourcepaths.Threat model:
score-compose generateon Score files from PR forks: contributor-supplied Score files can exfiltrate any file the runner can read into the mounts directory, which is often surfaced as a build artifact.Proposed fix in
internal/compose/convert.go:filepath.IsLocal(Go 1.20+) rejects absolute paths,..escapes, and volume-prefixed paths in a single call.Discussed briefly with @mathieu-benoit on Slack, who suggested filing this as a public issue + PR rather than a security advisory.
Thanks!