From: Alpha Chen Date: Fri, 24 Apr 2026 04:05:42 +0000 (+0000) Subject: Implement quire exec dispatch with git command allowlist X-Git-Url: http://quire.kejadlen.dev/?a=commitdiff_plain;h=51af71f43a0bffdb988e6afc2e9c6462917847b5;p=quire.git Implement quire exec dispatch with git command allowlist 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 --- diff --git a/Cargo.lock b/Cargo.lock index d70fbed..36e0fda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d737451..f8da1e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "*" diff --git a/docs/host/README.md b/docs/host/README.md index 9863c5d..0e21244 100644 --- a/docs/host/README.md +++ b/docs/host/README.md @@ -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 index f169014..0000000 --- a/docs/host/quire-dispatch +++ /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} ." diff --git a/docs/host/sshd_config b/docs/host/sshd_config index cefd73d..096eb66 100644 --- a/docs/host/sshd_config +++ b/docs/host/sshd_config @@ -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 diff --git a/src/bin/quire/commands/exec.rs b/src/bin/quire/commands/exec.rs index 3e2e47c..8aaacee 100644 --- a/src/bin/quire/commands/exec.rs +++ b/src/bin/quire/commands/exec.rs @@ -1,5 +1,112 @@ -pub async fn run(command: Vec) -> 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) -> 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} '', 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 { + 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()); + } } diff --git a/src/bin/quire/main.rs b/src/bin/quire/main.rs index aef5fd2..0c96e5e 100644 --- a/src/bin/quire/main.rs +++ b/src/bin/quire/main.rs @@ -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, }, }