forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.7 KiB
1//! Strict parsing of the SSH `exec` request: `git-upload-pack '/owner/repo.git'`.
2//!
3//! No shell is ever involved. The accepted grammar is deliberately tiny:
4//! one of four service prefixes, one space, then one argument that is either
5//! single-quoted or a bare token with no whitespace or quoting characters.
6
7use forge_git::GitService;
8
9#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
10pub enum ParseError {
11 #[error("unsupported command")]
12 UnsupportedCommand,
13 #[error("malformed repository path")]
14 MalformedPath,
15}
16
17/// Service prefixes accepted by the server. Both the hyphenated form git uses and
18/// the spaced form `git upload-pack` are allowed. Anything else is rejected.
19const PREFIXES: &[(&str, GitService)] = &[
20 ("git-upload-pack ", GitService::UploadPack),
21 ("git-receive-pack ", GitService::ReceivePack),
22 ("git upload-pack ", GitService::UploadPack),
23 ("git receive-pack ", GitService::ReceivePack),
24];
25
26/// Returns the service and the repository path with `.git` stripped and the
27/// leading `/` or `~/` removed. Anything else is an error.
28pub fn parse_exec(cmd: &str) -> Result<(GitService, String), ParseError> {
29 let (service, rest) = split_service(cmd)?;
30 let raw = unquote_argument(rest)?;
31 let path = normalize_path(raw)?;
32 Ok((service, path))
33}
34
35/// Exec prefix of the Git LFS authenticate request.
36pub(crate) const LFS_AUTHENTICATE_PREFIX: &str = "git-lfs-authenticate ";
37
38/// Direction of a `git-lfs-authenticate` request.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum LfsOperation {
41 Download,
42 Upload,
43}
44
45/// Parses `git-lfs-authenticate '<owner>/<repo>[.git]' download|upload`. The path follows the
46/// same rules as `parse_exec`, and the operation must be exactly `download` or `upload`.
47pub fn parse_lfs_authenticate(cmd: &str) -> Result<(String, LfsOperation), ParseError> {
48 let rest = cmd
49 .strip_prefix(LFS_AUTHENTICATE_PREFIX)
50 .ok_or(ParseError::UnsupportedCommand)?;
51 // The path argument ends at its closing quote, or at the first space when it is bare.
52 let end = if let Some(quoted) = rest.strip_prefix('\'') {
53 quoted.find('\'').ok_or(ParseError::MalformedPath)? + 2
54 } else {
55 rest.find(' ').ok_or(ParseError::MalformedPath)?
56 };
57 let (arg, tail) = rest.split_at(end);
58 let operation = match tail {
59 " download" => LfsOperation::Download,
60 " upload" => LfsOperation::Upload,
61 _ => return Err(ParseError::MalformedPath),
62 };
63 let path = normalize_path(unquote_argument(arg)?)?;
64 Ok((path, operation))
65}
66
67fn split_service(cmd: &str) -> Result<(GitService, &str), ParseError> {
68 PREFIXES
69 .iter()
70 .find_map(|(prefix, service)| cmd.strip_prefix(prefix).map(|rest| (*service, rest)))
71 .ok_or(ParseError::UnsupportedCommand)
72}
73
74/// Extracts the argument. A quoted argument may contain spaces but no quotes,
75/// backslashes or control characters. An unquoted argument must contain none of
76/// whitespace, quotes, backslashes or control characters.
77fn unquote_argument(rest: &str) -> Result<&str, ParseError> {
78 if let Some(quoted) = rest.strip_prefix('\'') {
79 let inner = quoted.strip_suffix('\'').ok_or(ParseError::MalformedPath)?;
80 if inner
81 .chars()
82 .any(|c| matches!(c, '\'' | '\\') || c.is_control())
83 {
84 return Err(ParseError::MalformedPath);
85 }
86 return Ok(inner);
87 }
88 if rest.is_empty()
89 || rest
90 .chars()
91 .any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '\\') || c.is_control())
92 {
93 return Err(ParseError::MalformedPath);
94 }
95 Ok(rest)
96}
97
98/// Strips one leading `/` or `~/`, then requires exactly `owner/repo` or
99/// `owner/repo.git`, returning `owner/repo`.
100fn normalize_path(raw: &str) -> Result<String, ParseError> {
101 let path = raw.strip_prefix('/').unwrap_or(raw);
102 let path = path.strip_prefix("~/").unwrap_or(path);
103
104 let segments: Vec<&str> = path.split('/').collect();
105 let [owner, repo] = segments.as_slice() else {
106 return Err(ParseError::MalformedPath);
107 };
108 for segment in [owner, repo] {
109 if segment.is_empty()
110 || *segment == ".."
111 || segment.starts_with('-')
112 || segment.starts_with('.')
113 || !segment
114 .bytes()
115 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
116 {
117 return Err(ParseError::MalformedPath);
118 }
119 }
120
121 let repo = repo.strip_suffix(".git").unwrap_or(repo);
122 if repo.is_empty() {
123 return Err(ParseError::MalformedPath);
124 }
125 Ok(format!("{owner}/{repo}"))
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn ok(cmd: &str) -> (GitService, String) {
133 match parse_exec(cmd) {
134 Ok(v) => v,
135 Err(e) => panic!("expected {cmd:?} to parse, got {e:?}"),
136 }
137 }
138
139 fn err(cmd: &str) -> ParseError {
140 match parse_exec(cmd) {
141 Ok(v) => panic!("expected {cmd:?} to be rejected, got {v:?}"),
142 Err(e) => e,
143 }
144 }
145
146 #[test]
147 fn accepts_upload_and_receive_pack() {
148 assert_eq!(
149 ok("git-upload-pack '/alice/proj.git'"),
150 (GitService::UploadPack, "alice/proj".into())
151 );
152 assert_eq!(
153 ok("git-receive-pack '/alice/proj.git'"),
154 (GitService::ReceivePack, "alice/proj".into())
155 );
156 }
157
158 #[test]
159 fn wiki_paths_keep_the_wiki_suffix() {
160 assert_eq!(
161 ok("git-receive-pack '/alice/proj.wiki.git'"),
162 (GitService::ReceivePack, "alice/proj.wiki".to_owned())
163 );
164 assert_eq!(
165 ok("git-upload-pack 'alice/proj.wiki'"),
166 (GitService::UploadPack, "alice/proj.wiki".to_owned())
167 );
168 }
169
170 #[test]
171 fn accepts_spaced_form() {
172 assert_eq!(
173 ok("git upload-pack '/alice/proj'"),
174 (GitService::UploadPack, "alice/proj".into())
175 );
176 assert_eq!(
177 ok("git receive-pack 'alice/proj.git'"),
178 (GitService::ReceivePack, "alice/proj".into())
179 );
180 }
181
182 #[test]
183 fn strips_leading_slash_and_tilde() {
184 assert_eq!(ok("git-upload-pack '~/alice/proj'").1, "alice/proj");
185 assert_eq!(ok("git-upload-pack '/~/alice/proj'").1, "alice/proj");
186 assert_eq!(ok("git-upload-pack 'alice/proj'").1, "alice/proj");
187 // Only one leading slash is stripped.
188 assert_eq!(
189 err("git-upload-pack '//alice/proj'"),
190 ParseError::MalformedPath
191 );
192 }
193
194 #[test]
195 fn unquoted_path_without_spaces_is_accepted() {
196 assert_eq!(
197 ok("git-upload-pack /alice/proj.git"),
198 (GitService::UploadPack, "alice/proj".into())
199 );
200 assert_eq!(
201 ok("git-receive-pack alice/proj"),
202 (GitService::ReceivePack, "alice/proj".into())
203 );
204 }
205
206 #[test]
207 fn unquoted_path_with_quote_or_space_is_rejected() {
208 assert_eq!(err("git-upload-pack a/b c"), ParseError::MalformedPath);
209 assert_eq!(err("git-upload-pack a/b'"), ParseError::MalformedPath);
210 assert_eq!(err("git-upload-pack a\"b/c"), ParseError::MalformedPath);
211 assert_eq!(err("git-upload-pack a\\b/c"), ParseError::MalformedPath);
212 }
213
214 #[test]
215 fn rejects_option_injection() {
216 assert_eq!(err("git-upload-pack '--help'"), ParseError::MalformedPath);
217 assert_eq!(
218 err("git-upload-pack '--upload-pack=touch /x'"),
219 ParseError::MalformedPath
220 );
221 assert_eq!(err("git-upload-pack '-x/y'"), ParseError::MalformedPath);
222 assert_eq!(err("git-upload-pack 'a/-b'"), ParseError::MalformedPath);
223 }
224
225 #[test]
226 fn rejects_shell_injection() {
227 assert_eq!(
228 err("git-upload-pack 'a/b'; rm -rf /"),
229 ParseError::MalformedPath
230 );
231 assert_eq!(
232 err("git-upload-pack 'a/b' 'c/d'"),
233 ParseError::MalformedPath
234 );
235 assert_eq!(err("git-upload-pack 'a/b$(id)'"), ParseError::MalformedPath);
236 assert_eq!(err("git-upload-pack 'a/b;c'"), ParseError::MalformedPath);
237 assert_eq!(err("git-upload-pack 'a/b|c'"), ParseError::MalformedPath);
238 assert_eq!(err("git-upload-pack 'a/b&c'"), ParseError::MalformedPath);
239 assert_eq!(err("git-upload-pack 'a/b%20c'"), ParseError::MalformedPath);
240 assert_eq!(err("git-upload-pack 'a/é'"), ParseError::MalformedPath);
241 assert_eq!(err("git-upload-pack 'a/b`id`'"), ParseError::MalformedPath);
242 assert_eq!(
243 err("git-upload-pack 'a/b' && id"),
244 ParseError::MalformedPath
245 );
246 }
247
248 #[test]
249 fn rejects_quote_backslash_newline_nul_and_control_chars_inside_quotes() {
250 assert_eq!(err("git-upload-pack 'a/b\\c'"), ParseError::MalformedPath);
251 assert_eq!(err("git-upload-pack 'a/b\nc'"), ParseError::MalformedPath);
252 assert_eq!(err("git-upload-pack 'a/b\rc'"), ParseError::MalformedPath);
253 assert_eq!(err("git-upload-pack 'a/b\0c'"), ParseError::MalformedPath);
254 assert_eq!(err("git-upload-pack 'a/b\x07c'"), ParseError::MalformedPath);
255 assert_eq!(err("git-upload-pack 'a/b\x7fc'"), ParseError::MalformedPath);
256 assert_eq!(err("git-upload-pack 'a/b'c'"), ParseError::MalformedPath);
257 }
258
259 #[test]
260 fn rejects_traversal() {
261 assert_eq!(err("git-upload-pack '../x/y'"), ParseError::MalformedPath);
262 assert_eq!(err("git-upload-pack 'a/..'"), ParseError::MalformedPath);
263 assert_eq!(err("git-upload-pack '/a/../b'"), ParseError::MalformedPath);
264 assert_eq!(
265 err("git-upload-pack '.hidden/repo'"),
266 ParseError::MalformedPath
267 );
268 assert_eq!(
269 err("git-upload-pack 'owner/.git'"),
270 ParseError::MalformedPath
271 );
272 }
273
274 #[test]
275 fn rejects_wrong_segment_count() {
276 assert_eq!(err("git-upload-pack 'a/b/c'"), ParseError::MalformedPath);
277 assert_eq!(err("git-upload-pack 'a'"), ParseError::MalformedPath);
278 assert_eq!(err("git-upload-pack 'a/'"), ParseError::MalformedPath);
279 assert_eq!(err("git-upload-pack '/a/b/'"), ParseError::MalformedPath);
280 assert_eq!(err("git-upload-pack '//'"), ParseError::MalformedPath);
281 }
282
283 #[test]
284 fn rejects_empty_values() {
285 assert_eq!(err("git-upload-pack ''"), ParseError::MalformedPath);
286 assert_eq!(err("git-upload-pack '/'"), ParseError::MalformedPath);
287 assert_eq!(err("git-upload-pack '~/'"), ParseError::MalformedPath);
288 assert_eq!(err("git-upload-pack '.git'"), ParseError::MalformedPath);
289 assert_eq!(err("git-upload-pack "), ParseError::MalformedPath);
290 }
291
292 #[test]
293 fn rejects_unknown_commands() {
294 assert_eq!(
295 err("git-upload-archive '/a/b'"),
296 ParseError::UnsupportedCommand
297 );
298 assert_eq!(
299 err("git upload-archive '/a/b'"),
300 ParseError::UnsupportedCommand
301 );
302 assert_eq!(err("ls -la"), ParseError::UnsupportedCommand);
303 assert_eq!(err(""), ParseError::UnsupportedCommand);
304 assert_eq!(
305 err("git-upload-packx '/a/b'"),
306 ParseError::UnsupportedCommand
307 );
308 assert_eq!(err("git-upload-pack'/a/b'"), ParseError::UnsupportedCommand);
309 assert_eq!(err("git-upload-pack"), ParseError::UnsupportedCommand);
310 assert_eq!(
311 err("GIT-UPLOAD-PACK '/a/b'"),
312 ParseError::UnsupportedCommand
313 );
314 assert_eq!(
315 err(" git-upload-pack '/a/b'"),
316 ParseError::UnsupportedCommand
317 );
318 }
319
320 #[test]
321 fn rejects_extra_spaces_and_trailing_garbage() {
322 assert_eq!(err("git-upload-pack '/a/b'"), ParseError::MalformedPath);
323 assert_eq!(err("git-upload-pack '/a/b' "), ParseError::MalformedPath);
324 assert_eq!(err("git-upload-pack '/a/b"), ParseError::MalformedPath);
325 }
326
327 fn lfs_ok(cmd: &str) -> (String, LfsOperation) {
328 match parse_lfs_authenticate(cmd) {
329 Ok(v) => v,
330 Err(e) => panic!("expected {cmd:?} to parse, got {e:?}"),
331 }
332 }
333
334 fn lfs_err(cmd: &str) -> ParseError {
335 match parse_lfs_authenticate(cmd) {
336 Ok(v) => panic!("expected {cmd:?} to be rejected, got {v:?}"),
337 Err(e) => e,
338 }
339 }
340
341 #[test]
342 fn lfs_accepts_quoted_and_bare_paths() {
343 assert_eq!(
344 lfs_ok("git-lfs-authenticate '/alice/proj.git' download"),
345 ("alice/proj".into(), LfsOperation::Download)
346 );
347 assert_eq!(
348 lfs_ok("git-lfs-authenticate 'alice/proj' upload"),
349 ("alice/proj".into(), LfsOperation::Upload)
350 );
351 assert_eq!(
352 lfs_ok("git-lfs-authenticate /alice/proj.git upload"),
353 ("alice/proj".into(), LfsOperation::Upload)
354 );
355 }
356
357 #[test]
358 fn lfs_rejects_bad_operation() {
359 assert_eq!(
360 lfs_err("git-lfs-authenticate 'a/b' push"),
361 ParseError::MalformedPath
362 );
363 assert_eq!(
364 lfs_err("git-lfs-authenticate 'a/b' "),
365 ParseError::MalformedPath
366 );
367 assert_eq!(
368 lfs_err("git-lfs-authenticate 'a/b' download extra"),
369 ParseError::MalformedPath
370 );
371 assert_eq!(
372 lfs_err("git-lfs-authenticate 'a/b'download"),
373 ParseError::MalformedPath
374 );
375 assert_eq!(
376 lfs_err("git-lfs-authenticate 'a/b' Download"),
377 ParseError::MalformedPath
378 );
379 }
380
381 #[test]
382 fn lfs_rejects_bad_paths() {
383 assert_eq!(
384 lfs_err("git-lfs-authenticate '--help' download"),
385 ParseError::MalformedPath
386 );
387 assert_eq!(
388 lfs_err("git-lfs-authenticate '../x/y' upload"),
389 ParseError::MalformedPath
390 );
391 assert_eq!(
392 lfs_err("git-lfs-authenticate 'a/b'; id download"),
393 ParseError::MalformedPath
394 );
395 assert_eq!(
396 lfs_err("git-lfs-authenticate 'a/b' 'c' download"),
397 ParseError::MalformedPath
398 );
399 assert_eq!(
400 lfs_err("git-lfs-authenticate 'a/b download"),
401 ParseError::MalformedPath
402 );
403 assert_eq!(
404 lfs_err("git-lfs-authenticate a/b c download"),
405 ParseError::MalformedPath
406 );
407 assert_eq!(
408 lfs_err("git-lfs-authenticate 'a/b/c' download"),
409 ParseError::MalformedPath
410 );
411 assert_eq!(
412 lfs_err("git-lfs-authenticate ''"),
413 ParseError::MalformedPath
414 );
415 }
416
417 #[test]
418 fn lfs_rejects_other_commands() {
419 assert_eq!(
420 lfs_err("git-upload-pack 'a/b' download"),
421 ParseError::UnsupportedCommand
422 );
423 assert_eq!(
424 lfs_err("git-lfs-authenticate-x 'a/b' download"),
425 ParseError::UnsupportedCommand
426 );
427 }
428}