Go · 4125 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package protocol
4
5 import (
6 "errors"
7 "fmt"
8 "regexp"
9 "strings"
10 )
11
12 // ParsedSSHCommand is the validated shape of `SSH_ORIGINAL_COMMAND`. The
13 // dispatcher resolves Owner+Repo against the DB and feeds Service to
14 // the same exec helper used by the HTTP transport.
15 type ParsedSSHCommand struct {
16 Service Service // UploadPack or ReceivePack
17 Owner string // already lowercase, validated via the same shape rules as RepoFS
18 Repo string // already lowercase, validated via repo-name rules
19 }
20
21 // sshCmdRE captures the only acceptable input shape:
22 //
23 // git-upload-pack 'owner/repo(.git)?'
24 // git-upload-pack owner/repo(.git)?
25 // git-receive-pack ...
26 //
27 // Single quotes around the path are optional but MUST MATCH — `a/b'`
28 // (one trailing quote, no leading) is rejected. The path group has
29 // two alternatives: a single-quoted captured form OR an unquoted form
30 // that may contain neither quotes nor whitespace. Anything else —
31 // extra args, embedded shell metacharacters, escaped quotes — is a
32 // hard reject. We never invoke a shell so we don't need escapes.
33 var sshCmdRE = regexp.MustCompile(`^(git-upload-pack|git-receive-pack)\s+(?:'([^']+)'|([^'\s]+))$`)
34
35 // ErrUnknownSSHCommand is returned when SSH_ORIGINAL_COMMAND doesn't
36 // match the strict whitelist. Callers should surface a friendly
37 // "shithub does not allow shell access" message and exit non-zero.
38 var ErrUnknownSSHCommand = errors.New("ssh: unsupported command")
39
40 // ErrInvalidSSHPath is returned when the parsed path fails owner/repo
41 // validation. This is the path-traversal defense.
42 var ErrInvalidSSHPath = errors.New("ssh: invalid repo path")
43
44 // ParseSSHCommand strict-parses SSH_ORIGINAL_COMMAND into Service +
45 // Owner + Repo. Returns ErrUnknownSSHCommand for anything that isn't a
46 // `git-{upload,receive}-pack <path>` invocation, and ErrInvalidSSHPath
47 // when the path fails the storage whitelist.
48 //
49 // Caller is expected to have already trimmed surrounding whitespace if
50 // any; we additionally enforce no leading/trailing whitespace ourselves.
51 func ParseSSHCommand(cmd string) (ParsedSSHCommand, error) {
52 if cmd != strings.TrimSpace(cmd) {
53 return ParsedSSHCommand{}, ErrUnknownSSHCommand
54 }
55 m := sshCmdRE.FindStringSubmatch(cmd)
56 if m == nil {
57 return ParsedSSHCommand{}, ErrUnknownSSHCommand
58 }
59 // The path group has two alternatives — quoted (m[2]) and unquoted
60 // (m[3]). Whichever fired is the path we use.
61 svc, path := Service(m[1]), m[2]
62 if path == "" {
63 path = m[3]
64 }
65
66 // Strip a single trailing `.git` if present.
67 path = strings.TrimSuffix(path, ".git")
68
69 owner, repo, ok := strings.Cut(path, "/")
70 if !ok {
71 return ParsedSSHCommand{}, fmt.Errorf("%w: missing owner/repo split", ErrInvalidSSHPath)
72 }
73 if strings.Contains(repo, "/") {
74 return ParsedSSHCommand{}, fmt.Errorf("%w: extra path segments", ErrInvalidSSHPath)
75 }
76 owner = strings.ToLower(owner)
77 repo = strings.ToLower(repo)
78 if err := validateOwner(owner); err != nil {
79 return ParsedSSHCommand{}, fmt.Errorf("%w: %v", ErrInvalidSSHPath, err)
80 }
81 if err := validateRepo(repo); err != nil {
82 return ParsedSSHCommand{}, fmt.Errorf("%w: %v", ErrInvalidSSHPath, err)
83 }
84 return ParsedSSHCommand{Service: svc, Owner: owner, Repo: repo}, nil
85 }
86
87 // ownerRE / repoRE mirror storage's name validation. We don't import
88 // the storage package directly to avoid a runtime dependency in the
89 // SSH-shell hot path (every SSH connection runs through ParseSSHCommand
90 // and the storage package pulls in extra init).
91 var (
92 ownerRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`)
93 repoRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9_])?$`)
94 )
95
96 func validateOwner(s string) error {
97 if !ownerRE.MatchString(s) {
98 return fmt.Errorf("owner shape")
99 }
100 if strings.Contains(s, "..") {
101 return fmt.Errorf("dot-dot")
102 }
103 return nil
104 }
105
106 func validateRepo(s string) error {
107 if !repoRE.MatchString(s) {
108 return fmt.Errorf("repo shape")
109 }
110 if strings.Contains(s, "..") {
111 return fmt.Errorf("dot-dot")
112 }
113 if strings.HasPrefix(s, ".") {
114 return fmt.Errorf("starts with dot")
115 }
116 return nil
117 }
118