Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions internal/apiserver/handler/hf/handler_hf_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
40 changes: 40 additions & 0 deletions internal/apiserver/handler/hf/handler_hf_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/infra/utils/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading