url.rs
In rust/crates/mkit-transport-ssh/src/ · At 619cbadCopy the commit ID
Preview
Code · 17.8 KiB//! Strict `mkit+ssh://` URL parser.
//!
//! SPEC-TRANSPORT §3 and SSH-SECURITY.md §2 are the authority for what
//! is (and isn't) a legal `mkit+ssh://` URL.
//!
//! Accepted forms (all with the mandatory `mkit+` prefix):
//!
//! ```text
//! mkit+ssh://user@host/path
//! mkit+ssh://user@host:port/path
//! mkit+ssh://user@host:path (legacy SCP-style, no port)
//! mkit+ssh://user@host:port:path (legacy SCP-style, explicit port)
//! ```
//!
//! The `user@` prefix is REQUIRED. The `mkit+` prefix is REQUIRED. Paths
//! are restricted to a conservative ASCII subset (`[A-Za-z0-9._-/]`) with
//! no empty / `.` / `..` segments so neither the on-disk config nor the
//! remote-shell transport can be confused by shell meta-characters, NUL
//! bytes, CRLF, or path traversal.
use mkit_core::protocol::TransportError;
/// Mandatory strict-namespace prefix for every mkit remote URL
/// (`mkit+ssh://…`). Forbidding bare `ssh://…` is deliberate: we do not
/// want the parser to silently accept a looser superset.
pub const MKIT_SSH_PREFIX: &str = "mkit+ssh://";
/// A resolved `mkit+ssh://` target. All fields are owned so the struct
/// outlives the caller's input buffer — the SSH child process is spawned
/// asynchronously and we cannot rely on borrows from the URL string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshTarget {
/// Remote account (the `user` in `user@host`). Never empty — the
/// parser rejects missing-user URLs.
pub user: String,
/// Remote hostname. Never empty.
pub host: String,
/// Optional explicit TCP port. `None` means "use ssh(1)'s default".
pub port: Option<u16>,
/// Remote repository path. Always passes [`validate_ssh_path`] — no
/// empty / `.` / `..` segments, no shell meta-characters, no NUL,
/// no CRLF.
pub path: String,
}
/// Validate the repository-path portion of an SSH remote.
///
/// SSH-SECURITY.md §2: paths are restricted to the ASCII subset
/// `[A-Za-z0-9._-/]`, MUST be non-empty, and MUST NOT contain empty /
/// `.` / `..` segments. This blocks:
///
/// - Path traversal (`..`, `/etc/passwd` via percent-encoded `..`).
/// - Shell meta-character injection (`;`, `&`, `|`, `$`, backticks,
/// spaces, newlines).
/// - NUL-byte injection (`%00`-style splices).
/// - CRLF injection in any component that might be echoed back.
///
/// A leading `/` is accepted (absolute repo path) as long as the rest of
/// the path satisfies the segment rules. We intentionally do NOT special-
/// case `/etc/…`: the segment-level charset already forbids bytes like
/// `:` that appear in typical traversal attempts, and the `..`-rejection
/// rule forbids climbing out of an arbitrary chroot.
pub fn validate_ssh_path(path: &str) -> Result<(), TransportError> {
if path.is_empty() {
return Err(TransportError::InvalidRef("empty ssh path".into()));
}
// Fast reject for NUL / CR / LF — these aren't in the allowed ASCII
// subset below, but failing loudly with a distinct message makes
// injection attempts obvious in logs.
if path
.as_bytes()
.iter()
.any(|&b| b == 0 || b == b'\r' || b == b'\n')
{
return Err(TransportError::InvalidRef(
"ssh path contains NUL or CRLF".into(),
));
}
let tail = if let Some(stripped) = path.strip_prefix('/') {
if stripped.is_empty() {
return Err(TransportError::InvalidRef("ssh path is bare slash".into()));
}
stripped
} else {
path
};
for part in tail.split('/') {
if part.is_empty() {
return Err(TransportError::InvalidRef(
"ssh path has empty segment".into(),
));
}
if part == "." || part == ".." {
return Err(TransportError::InvalidRef(
"ssh path has . or .. segment".into(),
));
}
for &c in part.as_bytes() {
let allowed = c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-';
if !allowed {
return Err(TransportError::InvalidRef(
"ssh path has disallowed character".into(),
));
}
}
}
Ok(())
}
/// Parse a `mkit+ssh://…` URL into a [`SshTarget`].
///
/// Returns [`TransportError::InvalidRef`] with a human-readable reason
/// for any validation failure. (`InvalidRef` is reused here as the
/// "caller fed bad input" bucket — SPEC-TRANSPORT §1 does not reserve a
/// dedicated `InvalidUrl` variant, and the wire protocol never sees a
/// URL directly.)
pub fn parse_mkit_ssh_url(url: &str) -> Result<SshTarget, TransportError> {
// SSH-SECURITY.md §2: reject NUL / CR / LF anywhere in the URL
// before we start slicing.
if url
.as_bytes()
.iter()
.any(|&b| b == 0 || b == b'\r' || b == b'\n')
{
return Err(TransportError::InvalidRef(
"ssh url contains NUL or CRLF".into(),
));
}
let rest = url.strip_prefix(MKIT_SSH_PREFIX).ok_or_else(|| {
TransportError::InvalidRef(format!("ssh url must start with {MKIT_SSH_PREFIX}"))
})?;
if rest.is_empty() {
return Err(TransportError::InvalidRef("ssh url has no body".into()));
}
// `user@` is REQUIRED (SPEC-TRANSPORT §3).
let at = rest
.find('@')
.ok_or_else(|| TransportError::InvalidRef("ssh url missing user".into()))?;
let user = &rest[..at];
let tail = &rest[at + 1..];
if user.is_empty() {
return Err(TransportError::InvalidRef("ssh url has empty user".into()));
}
if tail.is_empty() {
return Err(TransportError::InvalidRef("ssh url has no host".into()));
}
// Forbid embedded slashes / colons / @ / whitespace in the user
// field — these would escape the argv boundary when we build
// `user@host`. We restrict the user's byte set explicitly so a CRLF
// or ` -oProxyCommand=...` pasted into the user slot cannot reach
// `ssh(1)`.
for &c in user.as_bytes() {
let ok = c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-' || c == b'+';
if !ok {
return Err(TransportError::InvalidRef(
"ssh url user has disallowed character".into(),
));
}
}
// Two SSH URL families can contain a `/`:
//
// (a) URL-style: user@host[:port]/path
// (b) SCP-style: user@host:path where `path` itself contains `/`.
//
// We distinguish by looking at what's between the first `:` and the
// first `/` after `@`. If a `:` appears BEFORE the first `/` AND
// the token between them parses as a u16 port, it's URL-style.
// Otherwise it's SCP-style (the colon is the SCP separator, and
// the slash lives inside the path).
if let Some(slash) = tail.find('/') {
let host_port = &tail[..slash];
let path_with_slash = &tail[slash..];
// Reject `host:` with no port digits — ambiguous form.
if let Some(colon) = host_port.find(':') {
let port_str = &host_port[colon + 1..];
if port_str.is_empty() {
return Err(TransportError::InvalidRef("ssh url has empty port".into()));
}
}
let is_url_form = match host_port.find(':') {
None => true,
Some(colon) => {
let port_str = &host_port[colon + 1..];
port_str.bytes().all(|b| b.is_ascii_digit())
}
};
if is_url_form {
if host_port.is_empty() || path_with_slash.is_empty() {
return Err(TransportError::InvalidRef(
"ssh url has empty host or path".into(),
));
}
return parse_host_port(user, host_port, path_with_slash);
}
// Fall through to SCP-style parsing with the full tail intact.
}
// SCP-style: `host[:port]:path` — `path` may itself contain slashes.
let first_colon = tail
.find(':')
.ok_or_else(|| TransportError::InvalidRef("ssh url missing host".into()))?;
let host = &tail[..first_colon];
let after = &tail[first_colon + 1..];
if host.is_empty() || after.is_empty() {
return Err(TransportError::InvalidRef(
"ssh url has empty host or path".into(),
));
}
// Look for `host:port:path`. If the candidate port parses as u16
// AND the remainder validates as a path, accept it; otherwise treat
// the whole tail as the path.
if let Some(second_colon) = after.find(':') {
let port_candidate = &after[..second_colon];
let scp_path = &after[second_colon + 1..];
if !port_candidate.is_empty()
&& !scp_path.is_empty()
&& let Ok(port) = port_candidate.parse::<u16>()
{
validate_ssh_path(scp_path)?;
validate_host(host)?;
return Ok(SshTarget {
user: user.to_string(),
host: host.to_string(),
port: Some(port),
path: scp_path.to_string(),
});
}
}
validate_ssh_path(after)?;
validate_host(host)?;
Ok(SshTarget {
user: user.to_string(),
host: host.to_string(),
port: None,
path: after.to_string(),
})
}
/// Parse a `host[:port]` token plus a `/`-prefixed path into a
/// [`SshTarget`]. Internal helper for the URL-style branch.
fn parse_host_port(user: &str, host_port: &str, path: &str) -> Result<SshTarget, TransportError> {
let (host, port) = if let Some(colon) = host_port.find(':') {
let h = &host_port[..colon];
let p = &host_port[colon + 1..];
if h.is_empty() || p.is_empty() {
return Err(TransportError::InvalidRef(
"ssh url host:port is empty".into(),
));
}
let parsed = p
.parse::<u16>()
.map_err(|_| TransportError::InvalidRef("ssh url port is not u16".into()))?;
(h, Some(parsed))
} else {
(host_port, None)
};
if host.is_empty() {
return Err(TransportError::InvalidRef("ssh url host empty".into()));
}
validate_ssh_path(path)?;
validate_host(host)?;
Ok(SshTarget {
user: user.to_string(),
host: host.to_string(),
port,
path: path.to_string(),
})
}
/// Restrict hostnames to a conservative ASCII subset. Defence-in-depth:
/// prevents trivially hostile inputs (CRLF, NUL, embedded argv
/// separators) from reaching the child process argv, even though
/// `ssh(1)` would also reject them.
fn validate_host(host: &str) -> Result<(), TransportError> {
if host.is_empty() {
return Err(TransportError::InvalidRef("ssh host empty".into()));
}
// RFC 1123 permits digits, letters, `-`, `.` in hostnames. We also
// permit `:` so IPv6 literals like `[::1]` survive — but then the
// caller MUST wrap them in brackets themselves. This check is
// deliberately permissive; tighten only if a concrete attack
// surfaces.
for &c in host.as_bytes() {
let ok = c.is_ascii_alphanumeric()
|| c == b'.'
|| c == b'-'
|| c == b'_'
|| c == b':'
|| c == b'['
|| c == b']';
if !ok {
return Err(TransportError::InvalidRef(
"ssh host has disallowed character".into(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// --- Happy paths ------------------------------------------------------
#[test]
fn parses_url_form_no_port() {
let t = parse_mkit_ssh_url("mkit+ssh://alice@host.example.com/repos/project").unwrap();
assert_eq!(t.user, "alice");
assert_eq!(t.host, "host.example.com");
assert_eq!(t.port, None);
assert_eq!(t.path, "/repos/project");
}
#[test]
fn parses_url_form_with_port() {
let t = parse_mkit_ssh_url("mkit+ssh://alice@host.example.com:2222/repos/project").unwrap();
assert_eq!(t.user, "alice");
assert_eq!(t.host, "host.example.com");
assert_eq!(t.port, Some(2222));
assert_eq!(t.path, "/repos/project");
}
#[test]
fn parses_scp_form_no_port() {
let t = parse_mkit_ssh_url("mkit+ssh://git@github.com:org/repo").unwrap();
assert_eq!(t.user, "git");
assert_eq!(t.host, "github.com");
assert_eq!(t.port, None);
assert_eq!(t.path, "org/repo");
}
#[test]
fn parses_scp_form_with_port() {
let t = parse_mkit_ssh_url("mkit+ssh://alice@host.example.com:2222:repos/project").unwrap();
assert_eq!(t.user, "alice");
assert_eq!(t.host, "host.example.com");
assert_eq!(t.port, Some(2222));
assert_eq!(t.path, "repos/project");
}
#[test]
fn accepts_deep_nested_path() {
let t = parse_mkit_ssh_url("mkit+ssh://deploy@internal.host/projects/deep/nested/path")
.unwrap();
assert_eq!(t.path, "/projects/deep/nested/path");
}
// --- Missing-prefix / missing-field rejections -----------------------
#[test]
fn rejects_missing_mkit_prefix() {
assert!(
parse_mkit_ssh_url("ssh://user@host/path").is_err(),
"bare ssh:// must be rejected in strict mode"
);
}
#[test]
fn rejects_empty_url() {
assert!(parse_mkit_ssh_url("").is_err());
}
#[test]
fn rejects_missing_user() {
// No `@` at all.
assert!(parse_mkit_ssh_url("mkit+ssh://host.example.com/repo").is_err());
}
#[test]
fn rejects_empty_user() {
// `@` present but user is empty (`@host:...`).
assert!(parse_mkit_ssh_url("mkit+ssh://@host.example.com/repo").is_err());
}
#[test]
fn rejects_missing_host() {
assert!(parse_mkit_ssh_url("mkit+ssh://user@").is_err());
}
#[test]
fn rejects_empty_path_url_form() {
// No `/path` and no `:path`.
assert!(parse_mkit_ssh_url("mkit+ssh://user@host.example.com").is_err());
}
#[test]
fn rejects_missing_port_value() {
// Bare `host:` with no port digits.
assert!(parse_mkit_ssh_url("mkit+ssh://user@host:/repo").is_err());
}
#[test]
fn rejects_non_u16_port() {
assert!(
parse_mkit_ssh_url("mkit+ssh://user@host:99999/repo").is_err(),
"99999 exceeds u16"
);
}
// --- Path-escape rejections (SSH-SECURITY.md §2) ---------------------
#[test]
fn rejects_dot_dot_traversal() {
assert!(
parse_mkit_ssh_url("mkit+ssh://user@host/../../etc/passwd").is_err(),
"../ traversal must be rejected"
);
}
#[test]
fn rejects_single_dot_segment() {
assert!(parse_mkit_ssh_url("mkit+ssh://user@host/repo/./x").is_err());
}
#[test]
fn rejects_double_slash_empty_segment() {
assert!(parse_mkit_ssh_url("mkit+ssh://user@host/repo//x").is_err());
}
#[test]
fn rejects_nul_byte_injection() {
let injected = "mkit+ssh://user@host/path\0injected";
assert!(parse_mkit_ssh_url(injected).is_err());
}
#[test]
fn rejects_nul_byte_in_path_component() {
let injected = "mkit+ssh://user@host/path\u{0}injected";
assert!(parse_mkit_ssh_url(injected).is_err());
}
#[test]
fn rejects_crlf_in_user_field() {
// CR or LF anywhere in the URL is rejected before we split.
assert!(parse_mkit_ssh_url("mkit+ssh://us\rer@host/repo").is_err());
assert!(parse_mkit_ssh_url("mkit+ssh://us\ner@host/repo").is_err());
}
#[test]
fn rejects_shell_meta_in_path() {
assert!(parse_mkit_ssh_url("mkit+ssh://user@host/repo;touch").is_err());
assert!(parse_mkit_ssh_url("mkit+ssh://user@host/repo$(whoami)").is_err());
assert!(parse_mkit_ssh_url("mkit+ssh://user@host/repo|cat").is_err());
}
#[test]
fn rejects_space_in_path() {
assert!(parse_mkit_ssh_url("mkit+ssh://user@host/has space").is_err());
}
#[test]
fn rejects_percent_encoded_path_payload() {
// We do NOT percent-decode. A literal `%00` in the URL fails
// the ASCII-subset check.
assert!(parse_mkit_ssh_url("mkit+ssh://user@host/path%00injected").is_err());
}
#[test]
fn rejects_disallowed_user_characters() {
// Space / `;` / CR etc. in the user field are blocked before we
// ever touch `ssh(1)`.
assert!(parse_mkit_ssh_url("mkit+ssh://al ice@host/repo").is_err());
assert!(parse_mkit_ssh_url("mkit+ssh://al;ice@host/repo").is_err());
// A `-oProxyCommand=…` paste attempt begins with `-` which IS
// allowed inside the user field, but `=` and space are not,
// so it still fails.
assert!(parse_mkit_ssh_url("mkit+ssh://-oProxyCommand=pwnd@host/repo").is_err());
}
#[test]
fn rejects_uppercase_scheme() {
// We do not lowercase — `MKIT+SSH://…` is not `mkit+ssh://…`.
assert!(parse_mkit_ssh_url("MKIT+SSH://user@host/repo").is_err());
}
#[test]
fn rejects_wrong_scheme() {
assert!(parse_mkit_ssh_url("mkit+https://user@host/repo").is_err());
}
// --- validate_ssh_path direct tests ----------------------------------
#[test]
fn validate_path_accepts_absolute() {
assert!(validate_ssh_path("/repos/project").is_ok());
}
#[test]
fn validate_path_accepts_relative() {
assert!(validate_ssh_path("repos/project").is_ok());
}
#[test]
fn validate_path_rejects_empty() {
assert!(validate_ssh_path("").is_err());
}
#[test]
fn validate_path_rejects_bare_slash() {
assert!(validate_ssh_path("/").is_err());
}
#[test]
fn validate_path_rejects_dot_dot() {
assert!(validate_ssh_path("/../etc").is_err());
assert!(validate_ssh_path("repo/../other").is_err());
}
#[test]
fn validate_path_rejects_nul() {
assert!(validate_ssh_path("repo\0nul").is_err());
}
}