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
This commit is contained in:
localai-org-maint-bot committed 2026-09-08 01:05:42 +00:00
1 parent 2756eaeab0
commit 4e4597dfc2
3 files changed
+40

No files matched your search

+4
View File
@@ -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
}
+32
View File
@@ -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),
)
})
@@ -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