]> quire.kejadlen.dev Git - quire.git/commitdiff
Implement quire exec dispatch with git command allowlist
authorAlpha Chen <alpha@kejadlen.dev>
Fri, 24 Apr 2026 04:05:42 +0000 (04:05 +0000)
committerAlpha Chen <alpha@kejadlen.dev>
Fri, 24 Apr 2026 14:30:43 +0000 (07:30 -0700)
Parses SSH_ORIGINAL_COMMAND, validates git commands against an
allowlist (receive-pack, upload-pack, upload-archive), sanitizes
repo paths, and execs the git subprocess. Removes the separate
quire-dispatch shell script — the binary handles dispatch directly.
Updates host reference configs to use quire exec in ForceCommand.

Assisted-by: GLM-5.1 via pi
Cargo.lock
Cargo.toml
docs/host/README.md
docs/host/quire-dispatch [deleted file]
docs/host/sshd_config
src/bin/quire/commands/exec.rs
src/bin/quire/main.rs

index d70fbedad67b7c727442062b6d28bc2ef1b7d163..36e0fda986e1e2f6474e1186d497bed8f63c30bf 100644 (file)
@@ -282,6 +282,15 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
 
+[[package]]
+name = "fs-err"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.4.2"
@@ -569,7 +578,9 @@ dependencies = [
  "clap",
  "clap_complete",
  "color-eyre",
+ "fs-err",
  "predicates",
+ "shell-words",
  "tempfile",
  "thiserror",
  "tokio",
@@ -712,6 +723,12 @@ dependencies = [
  "lazy_static",
 ]
 
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
 [[package]]
 name = "signal-hook-registry"
 version = "1.4.8"
index d73745156acd089e1d42d7d3336140288aa7e209..f8da1e0fbb395796c52b40c756458e2c53ed2efc 100644 (file)
@@ -11,6 +11,8 @@ path = "src/bin/quire/main.rs"
 clap = { version = "*", features = ["derive", "env"] }
 clap_complete = "*"
 color-eyre = "*"
+fs-err = "*"
+shell-words = "*"
 thiserror = "*"
 tokio = { version = "*", features = ["full"] }
 tracing = "*"
index 9863c5d04ce0b201d906786f93deb630d4aa6f5b..0e212444dc8b9521622381612479a1138790f695 100644 (file)
@@ -5,7 +5,6 @@ Reference configs for dispatching SSH connections into the quire container.
 ## Files
 
 - `sshd_config` — drop into `/etc/ssh/sshd_config.d/` on the host
-- `quire-dispatch` — copy to `/usr/local/bin/quire-dispatch` and `chmod +x`
 
 ## Setup
 
@@ -21,32 +20,23 @@ Reference configs for dispatching SSH connections into the quire container.
        sudo chmod 700 /home/git/.ssh
        sudo chmod 600 /home/git/.ssh/authorized_keys
 
-3. Install the dispatch script:
-
-       sudo cp quire-dispatch /usr/local/bin/quire-dispatch
-       sudo chmod +x /usr/local/bin/quire-dispatch
-
-4. Install the sshd config:
+3. Install the sshd config:
 
        sudo cp sshd_config /etc/ssh/sshd_config.d/quire.conf
        sudo systemctl reload sshd
 
-5. Start the quire container:
+4. Start the quire container:
 
        docker run -d --name quire-container quire
 
-6. Test:
+5. Test:
 
        git clone git@localhost:foo.git /tmp/test-clone
 
 ## Notes
 
-The dispatch script is the security boundary between the host and the container.
-It only allows `git-receive-pack`, `git-upload-pack`, and `git-upload-archive`.
-Repo paths are validated: no `..` traversal, must end in `.git`, no double slashes.
-
-When `quire exec` is built (step 2), the ForceCommand will change to:
-
-    ForceCommand docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"
-
-and this dispatch script will be replaced by the quire binary's own allowlist.
+SSH dispatch is handled by `quire exec` inside the container. The sshd
+ForceCommand passes `$SSH_ORIGINAL_COMMAND` directly to the binary,
+which validates the git command against an allowlist (git-receive-pack,
+git-upload-pack, git-upload-archive) and sanitizes the repository path
+before exec'ing the git subprocess.
diff --git a/docs/host/quire-dispatch b/docs/host/quire-dispatch
deleted file mode 100644 (file)
index f169014..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/usr/bin/env bash
-# /usr/local/bin/quire-dispatch
-#
-# Invoked by sshd ForceCommand for the git user.
-# Parses $SSH_ORIGINAL_COMMAND to extract the repo path and git command,
-# then dispatches into the quire container via docker exec.
-#
-# $SSH_ORIGINAL_COMMAND examples:
-#   git-receive-pack '/foo.git'
-#   git-upload-pack '/foo.git'
-#   git-upload-archive '/foo.git'
-
-set -euo pipefail
-
-# shellcheck disable=SC2154
-cmd="${SSH_ORIGINAL_COMMAND:-}"
-
-if [[ -z "$cmd" ]]; then
-    echo "quire: interactive sessions are not supported" >&2
-    exit 1
-fi
-
-# Extract the git subcommand (git-receive-pack, git-upload-pack, etc).
-git_cmd="${cmd%% *}"
-
-# Extract the repo path argument. Git sends it as a quoted string,
-# e.g. git-receive-pack '/foo.git'. Strip the leading command and
-# surrounding quotes.
-repo="${cmd#* }"
-repo="${repo#\'}"
-repo="${repo%\'}"
-repo="${repo#/}"
-
-case "$git_cmd" in
-    git-receive-pack|git-upload-pack|git-upload-archive)
-        ;;
-    *)
-        echo "quire: unsupported command: $git_cmd" >&2
-        exit 1
-        ;;
-esac
-
-# Validate repo path: no .., must end in .git, no double slashes.
-if [[ "$repo" == *".."* ]] || [[ "$repo" != *.git ]] || [[ "$repo" == *//* ]]; then
-    echo "quire: invalid repository: $repo" >&2
-    exit 1
-fi
-
-exec docker exec -i quire-container \
-    /bin/sh -c "cd /var/quire/repos/${repo} && ${git_cmd} ."
index cefd73d54e89be43aa291e754fb4c64e8d8d6280..096eb663a7a0ef817dd3b5474232434e16b9e367 100644 (file)
@@ -1,13 +1,13 @@
 # /etc/ssh/sshd_config.d/quire.conf
 #
 # Drop this file into sshd_config.d (or append to sshd_config).
-# Forces all connections as the git user through the quire dispatch script.
+# Forces all connections as the git user through quire exec.
 # No port forwarding, agent forwarding, X11, or PTY — these are git/quire
 # connections, not interactive shells.
 
 Match User git
     AuthorizedKeysFile /home/git/.ssh/authorized_keys
-    ForceCommand /usr/local/bin/quire-dispatch
+    ForceCommand docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"
     AllowTcpForwarding no
     AllowAgentForwarding no
     X11Forwarding no
index 3e2e47c3150d12f60709e8877d65f368c1f7e9b5..8aaacee4297562c7a8f6805acfbfcecaddc9fd95 100644 (file)
@@ -1,5 +1,112 @@
-pub async fn run(command: Vec<String>) -> color_eyre::Result<()> {
-    tracing::info!(?command, "quire exec dispatching");
-    // TODO: parse command, validate against allowlist, exec
-    Ok(())
+use std::os::unix::process::CommandExt;
+use std::path::Path;
+use std::process::Command;
+
+use color_eyre::eyre::{self, Context};
+use color_eyre::Result;
+
+const GIT_COMMANDS: &[&str] = &["git-receive-pack", "git-upload-pack", "git-upload-archive"];
+
+const REPOS_DIR: &str = "/var/quire/repos";
+
+pub async fn run(command: Vec<String>) -> Result<()> {
+    let input = if command.len() == 1 {
+        // Single argument: the full SSH_ORIGINAL_COMMAND string.
+        // e.g. git-receive-pack '/foo.git'
+        command[0].clone()
+    } else {
+        // Already split into words (e.g. from CLI: quire exec git-receive-pack /foo.git).
+        command.join(" ")
+    };
+
+    let words = shell_words::split(&input).context("failed to parse command")?;
+
+    if words.is_empty() {
+        eyre::bail!("no command provided");
+    }
+
+    let git_cmd = &words[0];
+
+    if !GIT_COMMANDS.contains(&git_cmd.as_str()) {
+        eyre::bail!("unsupported command: {git_cmd}");
+    }
+
+    if words.len() != 2 {
+        eyre::bail!("expected usage: {git_cmd} '<repo>', got {} arguments", words.len() - 1);
+    }
+
+    let repo = validate_repo_path(&words[1])?;
+
+    let repo_dir = Path::new(REPOS_DIR).join(&repo);
+    if !repo_dir.is_dir() {
+        eyre::bail!("repository not found: {repo}");
+    }
+
+    tracing::info!(%git_cmd, %repo, "dispatching git command");
+
+    let repo_dir = Path::new(REPOS_DIR).join(&repo);
+    let err = Command::new(git_cmd).arg(".").current_dir(&repo_dir).exec();
+
+    Err(eyre::eyre!("exec failed: {err}"))
+}
+
+/// Validate a repo path argument from the SSH protocol.
+///
+/// Git sends paths like '/foo.git'. We strip the leading slash,
+/// reject path traversal (..), require a .git suffix, and reject
+/// empty or double-slash paths.
+fn validate_repo_path(raw: &str) -> Result<String> {
+    let path = raw.trim_start_matches('/');
+
+    if path.is_empty() {
+        eyre::bail!("empty repository path");
+    }
+
+    if path.contains("..") {
+        eyre::bail!("invalid repository path: {raw}");
+    }
+
+    if !path.ends_with(".git") {
+        eyre::bail!("invalid repository path (must end in .git): {raw}");
+    }
+
+    if path.contains("//") {
+        eyre::bail!("invalid repository path: {raw}");
+    }
+
+    Ok(path.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn valid_repo_paths() {
+        assert_eq!(validate_repo_path("/foo.git").unwrap(), "foo.git");
+        assert_eq!(validate_repo_path("foo.git").unwrap(), "foo.git");
+        assert_eq!(validate_repo_path("/work/foo.git").unwrap(), "work/foo.git");
+    }
+
+    #[test]
+    fn rejects_traversal() {
+        assert!(validate_repo_path("/../etc/passwd").is_err());
+        assert!(validate_repo_path("/foo/../../bar.git").is_err());
+    }
+
+    #[test]
+    fn rejects_no_git_suffix() {
+        assert!(validate_repo_path("/foo").is_err());
+    }
+
+    #[test]
+    fn rejects_empty() {
+        assert!(validate_repo_path("").is_err());
+        assert!(validate_repo_path("/").is_err());
+    }
+
+    #[test]
+    fn rejects_double_slash() {
+        assert!(validate_repo_path("/foo//bar.git").is_err());
+    }
 }
index aef5fd2a4828c18749b301c1a249809be9de45b4..0c96e5e5cf08cf4bbaedfe24a610052e561a7532 100644 (file)
@@ -30,7 +30,8 @@ enum Commands {
 
     /// Dispatch an SSH-originated command.
     Exec {
-        /// The original SSH command string to parse and execute.
+        /// The original SSH command string (e.g. git-receive-pack '/foo.git').
+        /// Pass as a single argument: quire exec "git-receive-pack '/foo.git'"
         command: Vec<String>,
     },
 }