New Sign in

fierj

Public

Tiny personal git forge

897797d654e54cabc557bae5dad581023e4ffe17
diff --git a/git.go b/git.go
index 0ff67d7..dc9fdc4 100644
--- a/git.go
+++ b/git.go
@@ -143,6 +143,51 @@ type Git struct {
 
 var _ VCS = (*Git)(nil)
 
+// isValidRef checks that a branch, tag, or ref name contains only safe characters
+// and won't be interpreted as a flag or cause path traversal.
+func isValidRef(ref string) bool {
+	if ref == "" || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") {
+		return false
+	}
+	for _, r := range ref {
+		if r <= ' ' || r == 0x7f || r == '~' || r == '^' || r == ':' || r == '\\' {
+			return false
+		}
+	}
+	for _, part := range strings.Split(ref, "/") {
+		if part == "" || part == "." || part == ".." || strings.HasPrefix(part, ".") {
+			return false
+		}
+	}
+	return true
+}
+
+// isValidPath checks that a path within a repository is safe.
+func isValidPath(p string) bool {
+	if strings.HasPrefix(p, "/") || strings.Contains(p, "..") {
+		return false
+	}
+	for _, r := range p {
+		if r <= ' ' || r == 0x7f || r == ':' || r == '\\' {
+			return false
+		}
+	}
+	return true
+}
+
+// isValidHash checks that a string looks like a git commit hash.
+func isValidHash(h string) bool {
+	if len(h) < 7 || len(h) > 40 {
+		return false
+	}
+	for _, r := range h {
+		if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
+			return false
+		}
+	}
+	return true
+}
+
 func (g *Git) cmd(args ...string) (string, error) {
 	cmd := exec.Command("git", args...)
 	cmd.Dir = path.Join(g.Dir, g.Name+".git")
@@ -256,6 +301,9 @@ func (g *Git) IsPrivate() bool {
 }
 
 func (g *Git) List(ref, path string) ([]TreeEntry, error) {
+	if !isValidRef(ref) || (path != "" && !isValidPath(path)) {
+		return nil, fmt.Errorf("invalid ref or path")
+	}
 	args := []string{"ls-tree", "-l", ref}
 	if path != "" {
 		args = append(args, "--", path+"/")
@@ -294,12 +342,18 @@ func (g *Git) List(ref, path string) ([]TreeEntry, error) {
 }
 
 func (g *Git) Blob(ref, path string) (string, error) {
+	if !isValidRef(ref) || !isValidPath(path) {
+		return "", fmt.Errorf("invalid ref or path")
+	}
 	return g.cmd("show", ref+":"+path)
 }
 
 func (g *Git) Log(ref string, n int) ([]Commit, error) {
+	if !isValidRef(ref) {
+		return nil, fmt.Errorf("invalid ref")
+	}
 	format := "%H%n%an%n%aI%n%s%n---"
-	out, err := g.cmd("log", fmt.Sprintf("-%d", n), "--format="+format, ref)
+	out, err := g.cmd("log", fmt.Sprintf("-%d", n), "--format="+format, "--", ref)
 	if err != nil {
 		return nil, err
 	}
@@ -321,17 +375,20 @@ func (g *Git) Log(ref string, n int) ([]Commit, error) {
 }
 
 func (g *Git) Diff(hash string) (string, error) {
-	return g.cmd("diff-tree", "-p", hash)
+	if !isValidHash(hash) {
+		return "", fmt.Errorf("invalid hash")
+	}
+	return g.cmd("diff-tree", "-p", "--", hash)
 }
 
 func (g *Git) DefaultBranch() string {
 	out, err := g.cmd("symbolic-ref", "--short", "HEAD")
 	if err == nil {
 		ref := strings.TrimSpace(out)
-		// Verify the default branch actually exists (HEAD might point to
-		// a not-yet-created branch in an empty/dangling-HEAD repo).
-		if _, err2 := g.cmd("rev-parse", "--verify", ref); err2 == nil {
-			return ref
+		if isValidRef(ref) {
+			if _, err2 := g.cmd("rev-parse", "--verify", "--", ref); err2 == nil {
+				return ref
+			}
 		}
 	}
 	// Fall back to the first real branch, or "main" as last resort.
@@ -379,7 +436,10 @@ func (g *Git) Tags() []string {
 }
 
 func (g *Git) CommitCount(ref string) int {
-	out, err := g.cmd("rev-list", "--count", ref)
+	if !isValidRef(ref) {
+		return 0
+	}
+	out, err := g.cmd("rev-list", "--count", "--", ref)
 	if err != nil {
 		return 0
 	}
@@ -389,6 +449,9 @@ func (g *Git) CommitCount(ref string) int {
 }
 
 func (g *Git) Readme(ref, dir string) string {
+	if !isValidRef(ref) || (dir != "" && !isValidPath(dir)) {
+		return ""
+	}
 	names := []string{"README.md", "readme.md", "README", "README.txt"}
 	for _, name := range names {
 		p := name
@@ -418,7 +481,10 @@ func listBranches(repoPath string) []string {
 }
 
 func (g *Git) LastCommit(ref, path string) *Commit {
-	out, err := g.cmd("log", "-1", "--format=%H%n%an%n%ar%n%s", ref, "--", path)
+	if !isValidRef(ref) || (path != "" && !isValidPath(path)) {
+		return nil
+	}
+	out, err := g.cmd("log", "-1", "--format=%H%n%an%n%ar%n%s", "--", ref, "--", path)
 	if err != nil {
 		return nil
 	}
diff --git a/patch.go b/patch.go
index 254ada4..b4e85f3 100644
--- a/patch.go
+++ b/patch.go
@@ -151,7 +151,9 @@ func loadPatch(repoPath, patchID string) (*Patch, error) {
 // patchDiff returns the diff for a patch (either branch-based or file-based).
 func patchDiff(repoPath string, p *Patch) (string, error) {
 	if p.Branch != "" {
-		// Diff between default branch and patch branch
+		if !isValidRef(p.Branch) {
+			return "", fmt.Errorf("invalid branch name")
+		}
 		defBranch := defaultBranch(repoPath)
 		cmd := exec.Command("git", "diff", defBranch+"..."+p.Branch)
 		cmd.Dir = repoPath
@@ -238,6 +240,9 @@ func writePatch(repoPath string, p *Patch) error {
 // fastForwardMerge updates target to point to branch if target is an ancestor
 // of branch (pure fast-forward). Works in bare repositories.
 func fastForwardMerge(repoPath, target, branch string) error {
+	if !isValidRef(target) || !isValidRef(branch) {
+		return fmt.Errorf("invalid branch name")
+	}
 	// Check that target is an ancestor of branch.
 	cmd := exec.Command("git", "merge-base", "--is-ancestor", "refs/heads/"+target, "refs/heads/"+branch)
 	cmd.Dir = repoPath