From a5447965e7808d656a5e5d9326a9ca0231148f85 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 5 Oct 2026 19:30:49 -0400 Subject: feat: sym-link containment, batch count prompt for crafting --- internal/admin/api_files.go | 53 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) (limited to 'internal/admin/api_files.go') diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index b1b3fff..9745b70 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -13,14 +13,61 @@ import ( ) // resolveDataPath resolves a request-supplied path inside the admin data -// directory, rejecting paths that would escape it via .. segments. The -// returned path is always filepath.Join(dataDir, cleaned relative path). +// directory, rejecting paths that would escape it via .. segments or a symlink. +// The returned path is always filepath.Join(dataDir, cleaned relative path). func resolveDataPath(dataDir, relPath string) (string, error) { clean := filepath.Clean(strings.TrimPrefix(strings.TrimPrefix(relPath, "/"), "data/")) if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", fmt.Errorf("path escapes the data directory: %s", relPath) } - return filepath.Join(dataDir, clean), nil + full := filepath.Join(dataDir, clean) + // Defence in depth: a symlink inside data/ could redirect a read or write + // outside it, so resolve the longest existing prefix and re-check + // containment. The root is resolved too, since the data dir itself may be + // a symlink (e.g. a mounted volume). + resolved, err := evalExisting(full) + if err != nil { + return "", fmt.Errorf("resolve path %s: %w", relPath, err) + } + root := dataDir + if r, err := filepath.EvalSymlinks(dataDir); err == nil { + root = r + } + if !withinDir(root, resolved) { + return "", fmt.Errorf("path escapes the data directory via a symlink: %s", relPath) + } + return full, nil +} + +// evalExisting resolves symlinks on the longest existing prefix of path and +// re-appends the not-yet-existing suffix, so a create target is still checked. +func evalExisting(path string) (string, error) { + p := path + var suffix []string + for { + resolved, err := filepath.EvalSymlinks(p) + if err == nil { + for i := len(suffix) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, suffix[i]) + } + return resolved, nil + } + if !os.IsNotExist(err) { + return "", err + } + parent := filepath.Dir(p) + if parent == p { + return path, nil + } + suffix = append(suffix, filepath.Base(p)) + p = parent + } +} + +// withinDir reports whether path lies inside dir. +func withinDir(dir, path string) bool { + rel, err := filepath.Rel(dir, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } // canonicalDataRelPath normalizes a client-supplied data-relative path so that -- cgit v1.2.3