diff --git a/internal/apiserver/handler/hf/handler_hf_list.go b/internal/apiserver/handler/hf/handler_hf_list.go index f83b9f49..809be333 100644 --- a/internal/apiserver/handler/hf/handler_hf_list.go +++ b/internal/apiserver/handler/hf/handler_hf_list.go @@ -124,26 +124,49 @@ func isSafePathComponent(component string) bool { return !strings.ContainsAny(component, `/\\`) } +// resolveContainedPath resolves a safe path component below root and rejects +// paths that escape root, including through symbolic links. +func resolveContainedPath(root, component string) (string, error) { + if !isSafePathComponent(component) { + return "", fmt.Errorf("invalid path component %q", component) + } + + absRoot, err := filepath.Abs(root) + if err != nil { + return "", err + } + resolvedRoot, err := filepath.EvalSymlinks(absRoot) + if err != nil { + return "", err + } + + absPath, err := filepath.Abs(filepath.Join(root, component)) + if err != nil { + return "", err + } + resolvedPath, err := filepath.EvalSymlinks(absPath) + if err != nil { + return "", err + } + + rel, err := filepath.Rel(resolvedRoot, resolvedPath) + if err != nil { + return "", err + } + if filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("path %q escapes root %q", component, root) + } + return resolvedPath, nil +} + // discoverRepos walks the base directory and returns all valid repository entries, // applying namespace-level filters (author, skipping non-model prefixes). func discoverRepos(baseDir string, isModel bool, author string) []repoEntry { if author != "" { - if !isSafePathComponent(author) { - return nil - } - - nsPath := filepath.Join(baseDir, author) - absBaseDir, err := filepath.Abs(baseDir) - if err != nil { - return nil - } - absNsPath, err := filepath.Abs(nsPath) + nsPath, err := resolveContainedPath(baseDir, author) if err != nil { return nil } - if !strings.HasPrefix(absNsPath, absBaseDir+string(os.PathSeparator)) && absNsPath != absBaseDir { - return nil - } return discoverReposInNamespace(nsPath, author) } diff --git a/internal/apiserver/handler/hf/handler_hf_list_test.go b/internal/apiserver/handler/hf/handler_hf_list_test.go index dffa4236..934861c7 100644 --- a/internal/apiserver/handler/hf/handler_hf_list_test.go +++ b/internal/apiserver/handler/hf/handler_hf_list_test.go @@ -18,6 +18,8 @@ import ( "encoding/json" "io" "net/http" + "os" + "path/filepath" "strings" "testing" ) @@ -45,6 +47,44 @@ func TestIsSafePathComponent(t *testing.T) { } } +func TestResolveContainedPath(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "namespace"), 0o755); err != nil { + t.Fatal(err) + } + + resolved, err := resolveContainedPath(root, "namespace") + if err != nil { + t.Fatalf("resolveContainedPath() error = %v", err) + } + want, err := filepath.EvalSymlinks(filepath.Join(root, "namespace")) + if err != nil { + t.Fatal(err) + } + if resolved != want { + t.Fatalf("resolveContainedPath() = %q, want %q", resolved, want) + } + + for _, relativePath := range []string{"../outside", "namespace/../../outside"} { + if _, err := resolveContainedPath(root, relativePath); err == nil { + t.Errorf("resolveContainedPath(%q) succeeded, want containment error", relativePath) + } + } +} + +func TestResolveContainedPathRejectsExternalSymlink(t *testing.T) { + root := t.TempDir() + external := t.TempDir() + link := filepath.Join(root, "namespace") + if err := os.Symlink(external, link); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + + if _, err := resolveContainedPath(root, "namespace"); err == nil { + t.Fatal("resolveContainedPath() followed external symlink") + } +} + func TestHandleListModelsEmpty(t *testing.T) { server, _ := setupTestServer(t) endpoint := server.URL diff --git a/internal/infra/utils/token.go b/internal/infra/utils/token.go index e473cafe..ea55c08a 100644 --- a/internal/infra/utils/token.go +++ b/internal/infra/utils/token.go @@ -65,7 +65,15 @@ func GenerateUserToken() (raw, hash string, err error) { return GenerateToken(TokenPrefix) } +// Sha256Hex returns a lookup digest for an API or robot token. +// +// The input is not a user password. Tokens are generated from 24 random bytes +// (192 bits of entropy) and this fast hash is used only for exact-match +// database lookups. Basic Auth uses its password field to transport these +// tokens in the token authentication protocol. User passwords are hashed with +// bcrypt in internal/infra/crypto and must not be passed to this function. func Sha256Hex(s string) string { + // codeql[go/weak-sensitive-data-hashing] sum := sha256.Sum256([]byte(s)) return fmt.Sprintf("%x", sum) }