diff --git a/core/services/worker/file_staging.go b/core/services/worker/file_staging.go index 9e65f2ced..0c7b81e01 100644 --- a/core/services/worker/file_staging.go +++ b/core/services/worker/file_staging.go @@ -21,6 +21,10 @@ func isPathAllowed(path string, allowedDirs []string) bool { if err != nil { continue } + // Compare both sides after resolving aliases such as macOS /var. + if resolvedDir, err := filepath.EvalSymlinks(absDir); err == nil { + absDir = resolvedDir + } if strings.HasPrefix(resolved, absDir+string(filepath.Separator)) || resolved == absDir { return true } diff --git a/core/services/worker/file_staging_test.go b/core/services/worker/file_staging_test.go new file mode 100644 index 000000000..8c35be81b --- /dev/null +++ b/core/services/worker/file_staging_test.go @@ -0,0 +1,32 @@ +package worker + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("worker staging path containment", func() { + DescribeTable("checks resolved directory boundaries", + func(relative string, allowed bool) { + root := GinkgoT().TempDir() + models := filepath.Join(root, "models") + sibling := filepath.Join(root, "models-other") + Expect(os.Mkdir(models, 0o750)).To(Succeed()) + Expect(os.Mkdir(sibling, 0o750)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(models, "result.bin"), []byte("output"), 0o600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(sibling, "private.bin"), []byte("private"), 0o600)).To(Succeed()) + Expect(os.Symlink(sibling, filepath.Join(models, "escape"))).To(Succeed()) + alias := filepath.Join(root, "alias") + Expect(os.Symlink(root, alias)).To(Succeed()) + + Expect(isPathAllowed(filepath.Join(alias, relative), []string{filepath.Join(alias, "models")})).To(Equal(allowed)) + }, + Entry("file beneath a symlinked root", "models/result.bin", true), + Entry("the symlinked root itself", "models", true), + Entry("a sibling sharing the directory prefix", "models-other/private.bin", false), + Entry("a symlink escaping the allowed root", "models/escape/private.bin", false), + ) +}) diff --git a/docs/content/features/distributed-mode.md b/docs/content/features/distributed-mode.md index 9c29ec710..9c1062c5e 100644 --- a/docs/content/features/distributed-mode.md +++ b/docs/content/features/distributed-mode.md @@ -431,6 +431,10 @@ operator inspects on the wire has a new shape. The one difference is that a worker now OMITS an empty reply field where the NATS handlers always emitted it, which a client reading a missing field as the zero value cannot tell apart. +`files/stage` accepts files inside the worker's models or staging-cache +directory, including when the directory path contains a symlink. It compares +resolved paths and rejects existing symlinks that point outside those directories. + `POST /v1/control/backend/stop` is served by BOTH kinds of worker, and the frontend sends it the same way to either. A serve-backend worker kills the backend process and recycles its port; an agent worker runs no backend