socle

validate.go

package main

import (
	"fmt"
	"strings"
)

// Validators for the names that arrive from the outside world:
// repo names (from config), refs (from URLs), and tree paths (from URLs).
// Every route handler and every git invocation funnels through these, and they fail closed:
// anything not positively allowed is rejected with errInvalid.
//
// Injection through argv is already impossible (git is exec'd with argument vectors, never a shell),
// so what these guard against is git misparsing an argument:
// options (leading dash), rev-parse operators (.. @{} ^ ~ :), and path traversal.

// validateRepoName checks a configured repo name (which is also its URL prefix).
// Names are slash-separated segments of [A-Za-z0-9._-], with no segment
// empty, starting with a dot or a dash, or ending in ".git"
// (a leading dot covers rejecting ".", "..", and ".git" themselves;
// a ".git" suffix is rejected because URLs treat a trailing ".git" as an alias for the plain name,
// and a configured name ending that way would make the alias ambiguous).
func validateRepoName(name string) error {
	if name == "" {
		return fmt.Errorf("%w: repo name is empty", errInvalid)
	}
	for _, seg := range strings.Split(name, "/") {
		if seg == "" {
			return fmt.Errorf("%w: repo name %q: empty path segment", errInvalid, name)
		}
		if seg[0] == '.' || seg[0] == '-' {
			return fmt.Errorf("%w: repo name %q: segment %q starts with %q", errInvalid, name, seg, seg[0:1])
		}
		if strings.HasSuffix(seg, ".git") {
			return fmt.Errorf("%w: repo name %q: segment %q ends in .git (reserved as a URL alias for the plain name)", errInvalid, name, seg)
		}
		for _, c := range seg {
			if !nameChar(c) {
				return fmt.Errorf("%w: repo name %q: character %q not allowed (name segments are [A-Za-z0-9._-])", errInvalid, name, c)
			}
		}
	}
	return nil
}

func nameChar(c rune) bool {
	return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
		c == '.' || c == '_' || c == '-'
}

// validateRef checks a ref name before it goes anywhere near a git invocation.
// The allowed shape is deliberately narrower than git's own ref rules:
// slash-separated segments of [A-Za-z0-9._-], no segment empty, starting with a dot, or ending in ".lock",
// no ".." anywhere, no leading dash, and at most 255 bytes.
// This admits branch names, tag names, "HEAD", and full hex shas,
// while excluding every rev-parse operator (ranges, reflog @{} syntax, ^ and ~ suffixes, :paths).
func validateRef(ref string) error {
	if ref == "" {
		return fmt.Errorf("%w: ref is empty", errInvalid)
	}
	if len(ref) > 255 {
		return fmt.Errorf("%w: ref is unreasonably long (%d bytes)", errInvalid, len(ref))
	}
	if ref[0] == '-' {
		return fmt.Errorf("%w: ref %q starts with a dash", errInvalid, ref)
	}
	if strings.Contains(ref, "..") {
		return fmt.Errorf("%w: ref %q contains %q", errInvalid, ref, "..")
	}
	for _, seg := range strings.Split(ref, "/") {
		if seg == "" {
			return fmt.Errorf("%w: ref %q: empty path segment", errInvalid, ref)
		}
		if seg[0] == '.' {
			return fmt.Errorf("%w: ref %q: segment %q starts with a dot", errInvalid, ref, seg)
		}
		if strings.HasSuffix(seg, ".lock") {
			return fmt.Errorf("%w: ref %q: segment %q ends in .lock", errInvalid, ref, seg)
		}
		for _, c := range seg {
			if !nameChar(c) {
				return fmt.Errorf("%w: ref %q: character %q not allowed", errInvalid, ref, c)
			}
		}
	}
	return nil
}

// validateTreePath checks a path within a repo's tree.  The empty string is valid and means the root.
// Unlike names and refs, the character set stays open (committed filenames are arbitrary:
// spaces, unicode, whatever), because a tree path is only ever used after a ":" in a <sha>:<path> argument,
// where git applies no option parsing or rev syntax.
// What's rejected: traversal dots, empty segments, leading or trailing slashes, and control bytes
// (never legitimate in a URL-supplied filename, and they'd make logs lie).
func validateTreePath(path string) error {
	if path == "" {
		return nil
	}
	for _, c := range path {
		if c < 0x20 || c == 0x7f {
			return fmt.Errorf("%w: path contains control byte 0x%02x", errInvalid, c)
		}
	}
	for _, seg := range strings.Split(path, "/") {
		if seg == "" {
			return fmt.Errorf("%w: path %q: empty segment", errInvalid, path)
		}
		if seg == "." || seg == ".." {
			return fmt.Errorf("%w: path %q: %q segment", errInvalid, path, seg)
		}
	}
	return nil
}

validate.go · 4.3 KiB · 717173c127