Skip to content

Commit 23d034e

Browse files
authored
Prevent downloaded files escape from outDir (#634)
Add additional file path check to make sure all the downloaded files will be under the outDir. Any attempt of escaping the outDir will fail the download. This is a breaking change though, note that the TestDownloadActionOutputs test has been updated to avoid break. Bug: b/460031498 Test: Unit test updated.
1 parent 4cdfee7 commit 23d034e

File tree

3 files changed

+185
-10
lines changed

3 files changed

+185
-10
lines changed

go/pkg/client/cas_download.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ func (c *Client) DownloadOutputs(ctx context.Context, outs map[string]*TreeOutpu
8585
downloads := make(map[digest.Digest]*TreeOutput)
8686
fullStats := &MovedBytesMetadata{}
8787
for _, out := range outs {
88-
path := filepath.Join(outDir, out.Path)
88+
path, err := getAbsPath(outDir, out.Path)
89+
if err != nil {
90+
return fullStats, err
91+
}
8992
if out.IsEmptyDirectory {
9093
if err := os.MkdirAll(path, c.DirMode); err != nil {
9194
return fullStats, err
@@ -784,12 +787,13 @@ func (c *Client) downloadBatch(ctx context.Context, batch []digest.Digest, reqs
784787
if r.output.IsExecutable {
785788
perm = c.ExecutableMode
786789
}
790+
path, err := getAbsPath(r.outDir, r.output.Path)
791+
if err == nil {
792+
err = os.WriteFile(path, bi.Data, perm)
793+
}
787794
// bytesMoved will be zero for error cases.
788795
// We only report it to the first client to prevent double accounting.
789-
r.wait <- &downloadResponse{
790-
stats: stats,
791-
err: os.WriteFile(filepath.Join(r.outDir, r.output.Path), bi.Data, perm),
792-
}
796+
r.wait <- &downloadResponse{stats, err}
793797
if i == 0 {
794798
// Prevent races by not writing to the original stats.
795799
newStats := &MovedBytesMetadata{}
@@ -816,7 +820,10 @@ func (c *Client) downloadSingle(ctx context.Context, dg digest.Digest, reqs map[
816820
}
817821
r := rs[0]
818822
rs = rs[1:]
819-
path := filepath.Join(r.outDir, r.output.Path)
823+
path, err := getAbsPath(r.outDir, r.output.Path)
824+
if err != nil {
825+
return err
826+
}
820827
contextmd.Infof(ctx, log.Level(3), "Downloading single file with digest %s to %s", r.output.Digest, path)
821828
stats, err := c.ReadBlobToFile(ctx, r.output.Digest, path)
822829
if err != nil {
@@ -833,6 +840,9 @@ func (c *Client) downloadSingle(ctx context.Context, dg digest.Digest, reqs map[
833840
if cp.output.IsExecutable {
834841
perm = c.ExecutableMode
835842
}
843+
if _, err := getAbsPath(cp.outDir, cp.output.Path); err != nil {
844+
return err
845+
}
836846
if err := copyFile(r.outDir, cp.outDir, r.output.Path, cp.output.Path, perm); err != nil {
837847
return err
838848
}
@@ -905,7 +915,11 @@ func (c *Client) downloadNonUnified(ctx context.Context, outDir string, outputs
905915
if out.IsExecutable {
906916
perm = c.ExecutableMode
907917
}
908-
if err := os.WriteFile(filepath.Join(outDir, out.Path), bi.Data, perm); err != nil {
918+
path, err := getAbsPath(outDir, out.Path)
919+
if err != nil {
920+
return err
921+
}
922+
if err := os.WriteFile(path, bi.Data, perm); err != nil {
909923
return err
910924
}
911925
statsMu.Lock()
@@ -918,7 +932,10 @@ func (c *Client) downloadNonUnified(ctx context.Context, outDir string, outputs
918932
}
919933
} else {
920934
out := outputs[batch[0]]
921-
path := filepath.Join(outDir, out.Path)
935+
path, err := getAbsPath(outDir, out.Path)
936+
if err != nil {
937+
return err
938+
}
922939
contextmd.Infof(ctx, log.Level(3), "Downloading single file with digest %s to %s", out.Digest, path)
923940
stats, err := c.ReadBlobToFile(ctx, out.Digest, path)
924941
if err != nil {

go/pkg/client/cas_test.go

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,7 +1106,7 @@ func TestDownloadActionOutputs(t *testing.T) {
11061106
treeADigest := fake.Put(treeABlob)
11071107
ar := &repb.ActionResult{
11081108
OutputFiles: []*repb.OutputFile{
1109-
&repb.OutputFile{Path: "../foo", Digest: fooDigest.ToProto()}},
1109+
&repb.OutputFile{Path: "foo/../foo", Digest: fooDigest.ToProto()}},
11101110
OutputFileSymlinks: []*repb.OutputSymlink{
11111111
&repb.OutputSymlink{Path: "x/bar", Target: "../dir/a/bar"}},
11121112
OutputDirectorySymlinks: []*repb.OutputSymlink{
@@ -1172,7 +1172,7 @@ func TestDownloadActionOutputs(t *testing.T) {
11721172
contents: []byte("bar"),
11731173
},
11741174
{
1175-
path: "foo",
1175+
path: "wd/foo",
11761176
contents: []byte("foo"),
11771177
fileDigest: &fooDigest,
11781178
},
@@ -2050,3 +2050,149 @@ func TestBatchDownloadBlobsBrokenCompression(t *testing.T) {
20502050
})
20512051
}
20522052
}
2053+
2054+
type escapedPathTestEnv struct {
2055+
ctx context.Context
2056+
c *client.Client
2057+
cache filemetadata.Cache
2058+
outDir string
2059+
escapedDigest digest.Digest
2060+
safeDigest digest.Digest
2061+
escapedPath string
2062+
safePath string
2063+
}
2064+
2065+
func createEscapedPathTestEnv(t *testing.T) escapedPathTestEnv {
2066+
t.Helper()
2067+
ctx := context.Background()
2068+
e, cleanup := fakes.NewTestEnv(t)
2069+
t.Cleanup(cleanup)
2070+
fake := e.Server.CAS
2071+
c := e.Client.GrpcClient
2072+
cache := filemetadata.NewSingleFlightCache()
2073+
2074+
escapedContent := []byte("escaped vulnerability test")
2075+
escapedDigest := fake.Put(escapedContent)
2076+
escapedPath := "../escaped_file.txt"
2077+
2078+
safeContent := []byte("safe content")
2079+
safeDigest := fake.Put(safeContent)
2080+
safePath := "safe_file.txt"
2081+
2082+
outDir := filepath.Join(t.TempDir(), "real_out_dir")
2083+
if err := os.MkdirAll(outDir, 0755); err != nil {
2084+
t.Fatalf("Failed to create outDir: %v", err)
2085+
}
2086+
2087+
// Verify that the hardcoded escapedPath indeed attempts to escape.
2088+
targetPath := filepath.Clean(filepath.Join(outDir, escapedPath))
2089+
if strings.HasPrefix(targetPath, outDir) {
2090+
t.Fatalf("Failed to construct an escaped path: %s is still within %s", targetPath, outDir)
2091+
}
2092+
2093+
return escapedPathTestEnv{
2094+
ctx: ctx,
2095+
c: c,
2096+
cache: cache,
2097+
outDir: outDir,
2098+
escapedDigest: escapedDigest,
2099+
safeDigest: safeDigest,
2100+
escapedPath: escapedPath,
2101+
safePath: safePath,
2102+
}
2103+
}
2104+
2105+
func TestEscapeDownloadOutputs(t *testing.T) {
2106+
t.Parallel()
2107+
env := createEscapedPathTestEnv(t)
2108+
2109+
outputs := map[string]*client.TreeOutput{
2110+
env.escapedPath: {
2111+
Digest: env.escapedDigest,
2112+
Path: env.escapedPath,
2113+
},
2114+
}
2115+
2116+
if _, err := env.c.DownloadOutputs(env.ctx, outputs, env.outDir, env.cache); err == nil {
2117+
t.Errorf("DownloadOutputs didn't fail when output path %v try to escape from outDir %v", env.escapedPath, env.outDir)
2118+
}
2119+
}
2120+
2121+
func TestEscapeDownloadFiles_Single(t *testing.T) {
2122+
t.Parallel()
2123+
env := createEscapedPathTestEnv(t)
2124+
2125+
outputs := map[digest.Digest]*client.TreeOutput{
2126+
env.escapedDigest: {
2127+
Digest: env.escapedDigest,
2128+
Path: env.escapedPath,
2129+
},
2130+
}
2131+
2132+
if _, err := env.c.DownloadFiles(env.ctx, env.outDir, outputs); err == nil {
2133+
t.Errorf("DownloadFiles didn't fail when output path try to escape from outDir %v", env.outDir)
2134+
}
2135+
}
2136+
2137+
func TestEscapeDownloadFiles_Batch(t *testing.T) {
2138+
t.Parallel()
2139+
env := createEscapedPathTestEnv(t)
2140+
2141+
outputs := map[digest.Digest]*client.TreeOutput{
2142+
env.escapedDigest: {
2143+
Digest: env.escapedDigest,
2144+
Path: env.escapedPath,
2145+
},
2146+
env.safeDigest: {
2147+
Digest: env.safeDigest,
2148+
Path: env.safePath,
2149+
},
2150+
}
2151+
client.UseBatchOps(true).Apply(env.c)
2152+
client.MaxBatchDigests(2).Apply(env.c)
2153+
env.c.RunBackgroundTasks(env.ctx)
2154+
2155+
if _, err := env.c.DownloadFiles(env.ctx, env.outDir, outputs); err == nil {
2156+
t.Errorf("DownloadFiles didn't fail when output path try to escape from outDir %v", env.outDir)
2157+
}
2158+
}
2159+
2160+
func TestEscapeDownloadNonUnified_Single(t *testing.T) {
2161+
t.Parallel()
2162+
env := createEscapedPathTestEnv(t)
2163+
2164+
outputs := map[digest.Digest]*client.TreeOutput{
2165+
env.escapedDigest: {
2166+
Digest: env.escapedDigest,
2167+
Path: env.escapedPath,
2168+
},
2169+
}
2170+
client.UnifiedDownloads(false).Apply(env.c)
2171+
if _, err := env.c.DownloadFiles(env.ctx, env.outDir, outputs); err == nil {
2172+
t.Errorf("DownloadFiles didn't fail when output path try to escape from outDir %v", env.outDir)
2173+
}
2174+
}
2175+
2176+
func TestEscapeDownloadNonUnified_Batch(t *testing.T) {
2177+
t.Parallel()
2178+
env := createEscapedPathTestEnv(t)
2179+
2180+
outputs := map[digest.Digest]*client.TreeOutput{
2181+
env.escapedDigest: {
2182+
Digest: env.escapedDigest,
2183+
Path: env.escapedPath,
2184+
},
2185+
env.safeDigest: {
2186+
Digest: env.safeDigest,
2187+
Path: env.safePath,
2188+
},
2189+
}
2190+
client.UnifiedDownloads(false).Apply(env.c)
2191+
client.UseBatchOps(true).Apply(env.c)
2192+
client.MaxBatchDigests(2).Apply(env.c)
2193+
env.c.RunBackgroundTasks(env.ctx)
2194+
2195+
if _, err := env.c.DownloadFiles(env.ctx, env.outDir, outputs); err == nil {
2196+
t.Errorf("DownloadFiles didn't fail when output path try to escape from outDir %v", env.outDir)
2197+
}
2198+
}

go/pkg/client/tree.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,18 @@ func getRelPath(base, path string) (string, error) {
130130
return rel, nil
131131
}
132132

133+
func getAbsPath(base, relPath string) (string, error) {
134+
base = filepath.Clean(base)
135+
if filepath.IsAbs(relPath) {
136+
return "", fmt.Errorf("input path %q must be relative to the base directory", relPath)
137+
}
138+
res := filepath.Clean(filepath.Join(base, relPath))
139+
if !strings.HasPrefix(res, base) {
140+
return "", fmt.Errorf("path %v is not under %v", relPath, base)
141+
}
142+
return res, nil
143+
}
144+
133145
// getTargetRelPath returns two versions of targetPath, the first is relative to execRoot
134146
// and the second is relative to the directory of symlinkRelPath.
135147
// symlinkRelPath must be relative to execRoot.

0 commit comments

Comments
 (0)