Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-ssh/tests/sandbox.rs

//! The path sandbox at the level it actually protects: the dispatch (the ACL model).
//!
//! `tests/command_parsing.rs` covers the syntax and carries this crate's half of
//! the merged rejection table. This file covers the decision (parse, resolve,
//! check the grant), because that is where "reject an escape" and "accept a
//! legitimate nested path" have to both be true at once.
//!
//! Since the merge the sandbox under all of this is `gunnar_core::resolve_repo_path`,
//! the only one, and the lookups are `gunnar-core`'s real
//! `AuthTable`/`RepoCatalog` rather than test doubles.

mod common;

use gunnar_core::model::Level;
use gunnar_ssh::server::{Dispatch, DispatchError};
use gunnar_ssh::{Access, GitService, Principal, RepoRef};

use common::{plane_with_key, principal, Plane};

/// A dispatch where `principal-1` holds `level` on `nordisk/osm-katana`.
fn dispatch(level: Access) -> (Dispatch, Principal) {
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("principal-1")
        .with_grant("principal-1", level);
    (Dispatch::new(plane.control), principal("principal-1"))
}

#[test]
fn a_legitimate_nested_path_is_served() {
    // The identity-value guard for the whole file. A sandbox that refuses
    // everything satisfies every escape test below and serves nobody.
    let (dispatch, who) = dispatch(Access::Read);
    let request = dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("a registered repository with a read grant must be served");
    assert_eq!(request.service, GitService::UploadPack);
    assert_eq!(request.repo.key(), "nordisk/osm-katana");
    assert_eq!(request.principal.id, "principal-1");

    // And the ssh:// spelling of the same repository reaches the same place.
    let via_url = dispatch
        .authorise(&who, None, b"git-upload-pack '/nordisk/osm-katana'")
        .expect("the ssh:// spelling names the same repository");
    assert_eq!(via_url.repo, request.repo);
}

/// **The behaviour that changed when the sandbox merged, pinned.**
///
/// This crate's deleted sandbox enforced exactly two path components, so
/// `team/sub/repo.git` was a *parse error*. `gunnar-core`'s permits nested
/// namespaces, and that is the one that survived. Without this test the merge
/// could silently be undone by reinstating the stricter rule, and every
/// rejection test in the crate would stay green.
#[test]
fn a_nested_namespace_resolves_and_is_served() {
    let plane = Plane::empty().with_principal("principal-1");
    let deep = RepoRef::new("team/sub", "project").expect("nested namespaces are legal");
    plane
        .catalog()
        .create_namespace("team/sub")
        .expect("create the nested namespace");
    plane
        .catalog()
        .create(gunnar_core::NewRepo::new(deep.clone()))
        .expect("create");
    plane
        .auth()
        .grant(gunnar_core::model::Grant::repo(
            "principal-1",
            deep,
            Level::Read,
        ))
        .expect("grant");

    let dispatch = Dispatch::new(plane.control);
    let request = dispatch
        .authorise(
            &principal("principal-1"),
            None,
            b"git-upload-pack 'team/sub/project.git'",
        )
        .expect("a nested namespace is legal");
    assert_eq!(request.repo.namespace, "team/sub");
    assert_eq!(request.repo.name, "project");
}

/// **The rule that makes the permissive sandbox safe.**
///
/// Nested namespaces being legal would be a privilege escalation if a grant on
/// `team` reached `team/sub`. It does not, and that is asserted here at the
/// dispatch. `gunnar-core`'s `tests/acl.rs` asserts it at the ACL, but the
/// escalation would be *exploited* through this path, so it is pinned on this
/// side too.
#[test]
fn a_grant_on_the_parent_namespace_does_not_reach_a_nested_one() {
    let plane = Plane::empty().with_principal("principal-1");
    for (namespace, name) in [("team", "shallow"), ("team/sub", "deep")] {
        plane
            .catalog()
            .create_namespace(namespace)
            .expect("create namespace");
        plane
            .catalog()
            .create(gunnar_core::NewRepo::new(
                RepoRef::new(namespace, name).expect("legal"),
            ))
            .expect("create");
    }
    // Admin on `team`, and nothing at all on `team/sub`.
    let plane = plane.with_namespace_grant("principal-1", "team", Level::Admin);
    let dispatch = Dispatch::new(plane.control);
    let who = principal("principal-1");

    // The red half first: the grant really does work where it applies. Without
    // this, a grant table that did nothing at all would pass the assertion
    // below.
    dispatch
        .authorise(&who, None, b"git-receive-pack 'team/shallow.git'")
        .expect("an admin grant on `team` must serve a repository in `team`");

    let err = dispatch
        .authorise(&who, None, b"git-upload-pack 'team/sub/deep.git'")
        .expect_err("a grant on `team` must NOT reach `team/sub`");
    assert!(
        matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
        "nested namespaces must not inherit grants: {err}"
    );
}

#[test]
fn escapes_never_reach_a_repository() {
    let (dispatch, who) = dispatch(Access::Admin);
    for raw in [
        &b"git-upload-pack '../../etc/passwd'"[..],
        b"git-upload-pack 'nordisk/../../etc/passwd'",
        b"git-upload-pack '/nordisk/../osm-katana'",
        b"git-upload-pack '.git/../..'",
        b"git-upload-pack '~/nordisk/osm-katana.git'",
        b"git-upload-pack 'nordisk/osm-katana.git\0'",
        // Merged in with the sandbox: git's lock-file suffix, which this
        // crate's deleted copy refused and `gunnar-core`'s did not until now.
        b"git-upload-pack 'nordisk/osm-katana.lock'",
    ] {
        let err = dispatch.authorise(&who, None, raw).expect_err(&format!(
            "should have refused {:?}",
            String::from_utf8_lossy(raw)
        ));
        assert!(
            matches!(err, DispatchError::Command(_)),
            "{:?} should have failed at the parser, not later: {err}",
            String::from_utf8_lossy(raw)
        );
    }
}

#[test]
fn an_absolute_system_path_is_refused_because_it_names_nothing() {
    // The ACL model lists `/etc/passwd` as a sandbox case. It is refused, but
    // one layer later than the spec implies, and for a better reason. See the
    // module docs of `gunnar_core::sandbox`: it parses as namespace `etc`,
    // repository `passwd`, which is not in the catalog, and there is never a
    // filename anywhere in the pipeline for it to have escaped into.
    let (dispatch, who) = dispatch(Access::Admin);
    let err = dispatch
        .authorise(&who, None, b"git-upload-pack '/etc/passwd'")
        .expect_err("must be refused");
    match err {
        DispatchError::NoSuchRepo(path) => {
            assert_eq!(path, RepoRef::new("etc", "passwd").unwrap());
        }
        other => panic!("expected NoSuchRepo, got {other}"),
    }
}

#[test]
fn upload_pack_needs_read_and_receive_pack_needs_write() {
    let (read_only, who) = dispatch(Access::Read);
    read_only
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("read is enough to fetch");
    let err = read_only
        .authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
        .expect_err("read must NOT be enough to push");
    assert!(
        matches!(
            &err,
            DispatchError::Denied { needed: Access::Write, held, .. } if held == "read"
        ),
        "{err}"
    );

    let (writable, who) = dispatch(Access::Write);
    writable
        .authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
        .expect("write is enough to push");
    writable
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("write implies read");
}

#[test]
fn a_principal_with_no_grant_at_all_is_denied() {
    let plane = Plane::empty().with_the_repo().with_principal("stranger");
    let dispatch = Dispatch::new(plane.control);
    let err = dispatch
        .authorise(
            &principal("stranger"),
            None,
            b"git-upload-pack 'nordisk/osm-katana.git'",
        )
        .expect_err("no grant means no access");
    assert!(
        matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
        "{err}"
    );
}

#[test]
fn not_found_and_not_allowed_are_indistinguishable_to_the_client() {
    // Two different server-side facts, one client-visible string. If these ever
    // diverge, a client can enumerate private repositories one guess at a time.
    let (dispatch, who) = dispatch(Access::Read);
    let missing = dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/does-not-exist.git'")
        .expect_err("no such repo");
    let denied = dispatch
        .authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
        .expect_err("no write grant");

    assert!(matches!(missing, DispatchError::NoSuchRepo(_)));
    assert!(matches!(denied, DispatchError::Denied { .. }));
    // Different internally…
    assert_ne!(missing.to_string(), denied.to_string());
    // …and both refer to the repository the client named, with the same wording.
    let missing_msg = missing.client_message().expect("a git client is listening");
    let denied_msg = denied.client_message().expect("a git client is listening");
    assert!(missing_msg.starts_with("repository not found, or you do not have access:"));
    assert!(denied_msg.starts_with("repository not found, or you do not have access:"));

    // The one place they may legitimately differ is the path the client itself
    // sent back to it, so compare the wording with the paths removed.
    let strip = |m: &str| m.split(':').next().unwrap().to_string();
    assert_eq!(strip(&missing_msg), strip(&denied_msg));
}

/// **A storage fault is not a permission denial (the ACL model).**
///
/// The seam this dispatch replaced returned `Option`, so an unreadable catalog
/// and an empty one were the same answer: "no such repository". `gunnar-core`
/// returns `Result` precisely so they are not, and this is the guard that the
/// `Result` is still carried rather than swallowed somewhere in between.
///
/// The red half is built in: the same dispatch, the same command, served
/// successfully first, so a green here cannot come from the request being
/// refused for some other reason.
#[test]
fn an_unreadable_catalog_is_a_fault_and_not_a_denial() {
    let plane = plane_with_key(&common::mint_key(), Access::Read);
    let dispatch = Dispatch::new(plane.control.clone());
    let who = principal("principal-1");

    dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("served while the catalog is readable");

    plane.corrupt_the_catalog();

    let err = dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect_err("an unreadable catalog must not be answered at all");
    assert!(
        matches!(err, DispatchError::Store(_)),
        "a storage fault reported as {err:?}; it must not be a Denied or a NoSuchRepo, \
         or an outage becomes a silent permission denial"
    );
    // And the client is told the truth, not that it lacks access.
    let message = err.client_message().expect("say something");
    assert!(
        !message.contains("do not have access"),
        "a storage fault must not be reported to the client as a permission problem: {message}"
    );
}

/// The same, for the identity table rather than the catalog.
#[test]
fn an_unreadable_identity_table_is_a_fault_and_not_a_denial() {
    let plane = plane_with_key(&common::mint_key(), Access::Read);
    let dispatch = Dispatch::new(plane.control.clone());
    let who = principal("principal-1");

    dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("served while the table is readable");

    plane.corrupt_the_auth_table();

    assert!(
        matches!(
            dispatch.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'"),
            Err(DispatchError::Store(_))
        ),
        "an unreadable identity table must be a fault, not `you have no grant`"
    );
}

#[test]
fn a_shell_gets_no_message_at_all() {
    // `None` means channel_failure and silence: there is no git client on the
    // other end that would understand a pkt-line.
    let (dispatch, who) = dispatch(Access::Admin);
    let err = dispatch
        .authorise(&who, None, b"bash")
        .expect_err("no shell, ever");
    assert!(err.client_message().is_none(), "{err}");
}

/// **The LFS verb through the real ACL.**
///
/// `tests/command_parsing.rs` proves the exec line `git-lfs-transfer <path>
/// upload` *parses* to `Access::Write`. That is a statement about the parser
/// and nothing else: it never builds an `AuthTable`, never holds a grant, and
/// would stay green if the dispatch dropped the parsed requirement on the floor
/// and handed every authenticated caller a writable LFS stream.
///
/// This is the same matrix `upload_pack_needs_read_and_receive_pack_needs_write`
/// asserts for the two git verbs, for the third one, which had no coverage at
/// this level at all. The `download` half is what makes it discriminating: a
/// dispatch that refused all LFS would satisfy the upload assertion alone.
#[test]
fn lfs_download_needs_read_and_lfs_upload_needs_write() {
    const DOWNLOAD: &[u8] = b"git-lfs-transfer nordisk/osm-katana.git download";
    const UPLOAD: &[u8] = b"git-lfs-transfer nordisk/osm-katana.git upload";

    let (read_only, who) = dispatch(Access::Read);
    read_only
        .authorise(&who, None, DOWNLOAD)
        .expect("read is enough to download an LFS object");
    let err = read_only
        .authorise(&who, None, UPLOAD)
        .expect_err("read must NOT be enough to upload an LFS object");
    assert!(
        matches!(
            &err,
            DispatchError::Denied { needed: Access::Write, held, .. } if held == "read"
        ),
        "an LFS upload refused a read-only principal for the wrong reason: {err}"
    );

    let (writable, who) = dispatch(Access::Write);
    writable
        .authorise(&who, None, UPLOAD)
        .expect("write is enough to upload an LFS object");
    writable
        .authorise(&who, None, DOWNLOAD)
        .expect("write implies read for LFS too");

    // And a principal with no grant reaches neither, so the read half above is
    // a grant being honoured rather than LFS being ungated.
    let plane = Plane::empty().with_the_repo().with_principal("stranger");
    let ungranted = Dispatch::new(plane.control);
    for line in [DOWNLOAD, UPLOAD] {
        let err = ungranted
            .authorise(&principal("stranger"), None, line)
            .expect_err("no grant must reach no LFS operation");
        assert!(
            matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
            "{err}"
        );
    }
}

/// The LFS operation the *caller* names is the one that is checked.
///
/// The failure this excludes: a dispatch that read the requirement from the
/// service's first `OperationSpec` (or from any fixed field) rather than from
/// the operation on the exec line would grant `upload` at `Read`, because
/// `Download` is the row that comes first in `SERVICES`.
#[test]
fn the_lfs_operation_named_on_the_exec_line_is_the_one_that_is_checked() {
    let (read_only, who) = dispatch(Access::Read);

    let allowed = read_only
        .authorise(
            &who,
            None,
            b"git-lfs-transfer nordisk/osm-katana.git download",
        )
        .expect("download is a read");
    assert!(
        matches!(
            allowed.service,
            GitService::LfsTransfer(gunnar_lfs::Operation::Download)
        ),
        "the dispatch carried the wrong operation forward: {:?}",
        allowed.service
    );

    // Same principal, same repository, same verb — only the operation differs.
    read_only
        .authorise(
            &who,
            None,
            b"git-lfs-transfer nordisk/osm-katana.git upload",
        )
        .expect_err("upload must be judged as a write even though download was allowed");
}