validate.go
package main
import (
"fmt"
"net/url"
"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.
// validateBaseURL checks (and normalizes) the configured canonical base URL:
// an absolute http(s) URL naming only a host. One trailing slash is tolerated and trimmed;
// a path is refused rather than trimmed, because socle's URL space is rooted at "/"
// and a path prefix would promise routing it can't deliver.
// Returns the normalized form, scheme://host with no trailing slash.
func validateBaseURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "http" && u.Scheme != "https" || u.Host == "" {
return "", fmt.Errorf("baseurl %q: must be an absolute URL with scheme http or https", raw)
}
if u.Path != "" && u.Path != "/" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
return "", fmt.Errorf("baseurl %q: must name only a host, with no path, query, fragment, or userinfo", raw)
}
return u.Scheme + "://" + u.Host, nil
}
// 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 · 5.2 KiB · 199e3974a0