Skip to content
Open
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
34 changes: 26 additions & 8 deletions internal/commands/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"regexp"
"strconv"
"strings"
"time"

"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
Expand All @@ -29,6 +30,20 @@ const (
apiBaseURL = "https://api.github.com/repos/" + templatesRepo + "/contents"
)

// httpClient is used for all GitHub API/download requests so a stalled
// response can't hang the command indefinitely.
var httpClient = &http.Client{Timeout: 30 * time.Second}

// isSafeItemName reports whether a GitHub content item's name is safe to
// join into a local filesystem path. Defense-in-depth against a compromised
// or unexpected API response containing a path-traversal name.
func isSafeItemName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
return !strings.ContainsAny(name, "/\\")
}

// GitHubContent represents a file/directory from GitHub API
type GitHubContent struct {
Name string `json:"name"`
Expand Down Expand Up @@ -149,7 +164,7 @@ func buildContentsAPIURL(path, branch string) string {
// fetchGroupedTemplates fetches templates grouped by category (resources vs standalones)
func fetchGroupedTemplates(branch string) (resources []templateDescriptor, standalones []templateDescriptor, err error) {
// Fetch root contents
resp, err := http.Get(buildContentsAPIURL("", branch))
resp, err := httpClient.Get(buildContentsAPIURL("", branch))
if err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -196,7 +211,7 @@ func fetchGroupedTemplates(branch string) (resources []templateDescriptor, stand
// fetchFolderContents fetches the list of directories inside a folder
func fetchFolderContents(folderPath string, category templateCategory, branch string) ([]templateDescriptor, error) {
requestURL := buildContentsAPIURL(folderPath, branch)
resp, err := http.Get(requestURL)
resp, err := httpClient.Get(requestURL)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -236,7 +251,7 @@ func fetchFolderContents(folderPath string, category templateCategory, branch st

func fetchTemplateManifest(templatePath, branch string) (*templateManifest, error) {
requestURL := buildContentsAPIURL(templatePath, branch)
resp, err := http.Get(requestURL)
resp, err := httpClient.Get(requestURL)
if err != nil {
return nil, err
}
Expand All @@ -256,7 +271,7 @@ func fetchTemplateManifest(templatePath, branch string) (*templateManifest, erro
continue
}

manifestResp, err := http.Get(item.DownloadURL)
manifestResp, err := httpClient.Get(item.DownloadURL)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -308,7 +323,7 @@ func resolveTemplate(templateName, branch string) (templateDescriptor, error) {
}

func fetchTemplateList() ([]string, error) {
resp, err := http.Get(apiBaseURL)
resp, err := httpClient.Get(apiBaseURL)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -503,7 +518,7 @@ func cloneWithSparseCheckout(template, targetPath, branch string) error {
func cloneWithGitHubAPI(template, targetPath, branch string) error {
// First verify template exists
apiURL := buildContentsAPIURL(template, branch)
resp, err := http.Get(apiURL)
resp, err := httpClient.Get(apiURL)
if err != nil {
return fmt.Errorf("failed to connect to GitHub: %w", err)
}
Expand All @@ -527,7 +542,7 @@ func cloneWithGitHubAPI(template, targetPath, branch string) error {

func downloadDirectory(remotePath, localPath, branch string) error {
apiURL := buildContentsAPIURL(remotePath, branch)
resp, err := http.Get(apiURL)
resp, err := httpClient.Get(apiURL)
if err != nil {
return err
}
Expand All @@ -543,6 +558,9 @@ func downloadDirectory(remotePath, localPath, branch string) error {
}

for _, item := range contents {
if !isSafeItemName(item.Name) {
return fmt.Errorf("unsafe item name in template contents: %q", item.Name)
}
localItemPath := filepath.Join(localPath, item.Name)

if item.Type == "dir" {
Expand All @@ -563,7 +581,7 @@ func downloadDirectory(remotePath, localPath, branch string) error {
}

func downloadFile(url, localPath string) error {
resp, err := http.Get(url)
resp, err := httpClient.Get(url)
if err != nil {
return err
}
Expand Down
25 changes: 25 additions & 0 deletions internal/commands/clone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package commands

import "testing"

func TestIsSafeItemName(t *testing.T) {
safe := []string{"chat", "my-template", "my_template", "file.ts", "README.md"}
for _, name := range safe {
if !isSafeItemName(name) {
t.Errorf("expected %q to be considered safe", name)
}
}

unsafe := []string{"", ".", "..", "../escape", "a/b", "a\\b", "/etc/passwd"}
for _, name := range unsafe {
if isSafeItemName(name) {
t.Errorf("expected %q to be rejected", name)
}
}
}

func TestHTTPClientHasTimeout(t *testing.T) {
if httpClient.Timeout <= 0 {
t.Fatal("expected httpClient to have a positive timeout configured")
}
}