From 4e4597dfc2e43fc0eb560be57b64739633036a8e Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:05:42 +0000 Subject: [PATCH] fix(worker): resolve staging directory symlinks Resolve allowed directories before comparing them with resolved files. Otherwise staging rejects valid files under macOS temporary paths. Cover aliased roots, sibling paths, and symlinks escaping the root. Assisted-by: Codex:gpt-6 --- core/services/worker/file_staging.go | 4 +++ core/services/worker/file_staging_test.go | 32 +++++++++++++++++++++++ docs/content/features/distributed-mode.md | 4 +++ 3 files changed, 40 insertions(+) create mode 100644 core/services/worker/file_staging_test.go 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