Skip to content

Commit 1272542

Browse files
committed
Merge branch 'strange-usernames'
2 parents 55f379b + 996310b commit 1272542

File tree

13 files changed

+325
-50
lines changed

13 files changed

+325
-50
lines changed

gix-transport/src/client/blocking_io/file.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ pub fn connect(
291291
mod tests {
292292
mod ssh {
293293
mod connect {
294-
use crate::{client::blocking_io::ssh::connect, Protocol};
294+
use crate::{client::blocking_io::ssh, Protocol};
295295

296296
#[test]
297297
fn path() {
@@ -304,10 +304,29 @@ mod tests {
304304
("[email protected]:~/repo", "~/repo"),
305305
] {
306306
let url = gix_url::parse((*url).into()).expect("valid url");
307-
let cmd = connect(url, Protocol::V1, Default::default(), false).expect("parse success");
307+
let cmd = ssh::connect(url, Protocol::V1, Default::default(), false).expect("parse success");
308308
assert_eq!(cmd.path, expected, "the path will be substituted by the remote shell");
309309
}
310310
}
311+
312+
#[test]
313+
fn ambiguous_host_disallowed() {
314+
for url in [
315+
"ssh://-oProxyCommand=open$IFS-aCalculator/foo",
316+
"user@-oProxyCommand=open$IFS-aCalculator:username/repo",
317+
] {
318+
let url = gix_url::parse((*url).into()).expect("valid url");
319+
let options = ssh::connect::Options {
320+
command: Some("unrecognized".into()),
321+
disallow_shell: false,
322+
kind: None,
323+
};
324+
assert!(matches!(
325+
ssh::connect(url, Protocol::V1, options, false),
326+
Err(ssh::Error::AmbiguousHostName { host }) if host == "-oProxyCommand=open$IFS-aCalculator",
327+
));
328+
}
329+
}
311330
}
312331
}
313332
}

gix-transport/src/client/blocking_io/ssh/mod.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::process::Stdio;
22

3+
use gix_url::ArgumentSafety::*;
4+
35
use crate::{client::blocking_io, Protocol};
46

57
/// The error used in [`connect()`].
@@ -42,6 +44,8 @@ pub mod invocation {
4244
#[derive(Debug, thiserror::Error)]
4345
#[allow(missing_docs)]
4446
pub enum Error {
47+
#[error("Username '{user}' could be mistaken for a command-line argument")]
48+
AmbiguousUserName { user: String },
4549
#[error("Host name '{host}' could be mistaken for a command-line argument")]
4650
AmbiguousHostName { host: String },
4751
#[error("The 'Simple' ssh variant doesn't support {function}")]
@@ -116,9 +120,11 @@ pub fn connect(
116120
.stdin(Stdio::null())
117121
.with_shell()
118122
.arg("-G")
119-
.arg(url.host_argument_safe().ok_or_else(|| Error::AmbiguousHostName {
120-
host: url.host().expect("set in ssh urls").into(),
121-
})?),
123+
.arg(match url.host_as_argument() {
124+
Usable(host) => host,
125+
Dangerous(host) => Err(Error::AmbiguousHostName { host: host.into() })?,
126+
Absent => panic!("BUG: host should always be present in SSH URLs"),
127+
}),
122128
);
123129
gix_features::trace::debug!(cmd = ?cmd, "invoking `ssh` for feature check");
124130
kind = if cmd.status().ok().map_or(false, |status| status.success()) {

gix-transport/src/client/blocking_io/ssh/program_kind.rs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ use std::{ffi::OsStr, io::ErrorKind};
22

33
use bstr::{BString, ByteSlice, ByteVec};
44

5+
use gix_url::ArgumentSafety::*;
6+
57
use crate::{
68
client::{ssh, ssh::ProgramKind},
79
Protocol,
@@ -60,23 +62,21 @@ impl ProgramKind {
6062
}
6163
}
6264
};
63-
let host_as_ssh_arg = match url.user() {
64-
Some(user) => {
65-
let host = url.host().expect("present in ssh urls");
66-
format!("{user}@{host}")
67-
}
68-
None => {
69-
let host = url
70-
.host_argument_safe()
71-
.ok_or_else(|| ssh::invocation::Error::AmbiguousHostName {
72-
host: url.host().expect("ssh host always set").into(),
73-
})?;
74-
host.into()
75-
}
65+
66+
let host_maybe_with_user_as_ssh_arg = match (url.user_as_argument(), url.host_as_argument()) {
67+
(Usable(user), Usable(host)) => format!("{user}@{host}"),
68+
(Usable(user), Dangerous(host)) => format!("{user}@{host}"), // The `user@` makes it safe.
69+
(Absent, Usable(host)) => host.into(),
70+
(Dangerous(user), _) => Err(ssh::invocation::Error::AmbiguousUserName { user: user.into() })?,
71+
(_, Dangerous(host)) => Err(ssh::invocation::Error::AmbiguousHostName { host: host.into() })?,
72+
(_, Absent) => panic!("BUG: host should always be present in SSH URLs"),
7673
};
7774

78-
// Try to force ssh to yield english messages (for parsing later)
79-
Ok(prepare.arg(host_as_ssh_arg).env("LANG", "C").env("LC_ALL", "C"))
75+
Ok(prepare
76+
.arg(host_maybe_with_user_as_ssh_arg)
77+
// Try to force ssh to yield English messages (for parsing later).
78+
.env("LANG", "C")
79+
.env("LC_ALL", "C"))
8080
}
8181

8282
/// Note that the caller has to assure that the ssh program is launched in English by setting the locale.

gix-transport/src/client/blocking_io/ssh/tests.rs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,22 +144,63 @@ mod program_kind {
144144
assert!(call_args(kind, "ssh://user@host:43/p", Protocol::V2).ends_with("-P 43 user@host"));
145145
}
146146
}
147+
147148
#[test]
148-
fn ambiguous_host_is_allowed_with_user() {
149+
fn ambiguous_user_is_disallowed_explicit_ssh() {
150+
assert!(matches!(
151+
try_call(ProgramKind::Ssh, "ssh://-arg@host/p", Protocol::V2),
152+
Err(ssh::invocation::Error::AmbiguousUserName { user }) if user == "-arg"
153+
))
154+
}
155+
156+
#[test]
157+
fn ambiguous_user_is_disallowed_implicit_ssh() {
158+
assert!(matches!(
159+
try_call(ProgramKind::Ssh, "-arg@host:p/q", Protocol::V2),
160+
Err(ssh::invocation::Error::AmbiguousUserName { user }) if user == "-arg"
161+
))
162+
}
163+
164+
#[test]
165+
fn ambiguous_host_is_allowed_with_user_explicit_ssh() {
149166
assert_eq!(
150167
call_args(ProgramKind::Ssh, "ssh://user@-arg/p", Protocol::V2),
151168
joined(&["ssh", "-o", "SendEnv=GIT_PROTOCOL", "user@-arg"])
152169
);
153170
}
154171

155172
#[test]
156-
fn ambiguous_host_is_disallowed() {
173+
fn ambiguous_host_is_allowed_with_user_implicit_ssh() {
174+
assert_eq!(
175+
call_args(ProgramKind::Ssh, "user@-arg:p/q", Protocol::V2),
176+
joined(&["ssh", "-o", "SendEnv=GIT_PROTOCOL", "user@-arg"])
177+
);
178+
}
179+
180+
#[test]
181+
fn ambiguous_host_is_disallowed_without_user() {
157182
assert!(matches!(
158183
try_call(ProgramKind::Ssh, "ssh://-arg/p", Protocol::V2),
159184
Err(ssh::invocation::Error::AmbiguousHostName { host }) if host == "-arg"
160185
));
161186
}
162187

188+
#[test]
189+
fn ambiguous_user_and_host_remain_disallowed_together_explicit_ssh() {
190+
assert!(matches!(
191+
try_call(ProgramKind::Ssh, "ssh://-arg@host/p", Protocol::V2),
192+
Err(ssh::invocation::Error::AmbiguousUserName { user }) if user == "-arg"
193+
));
194+
}
195+
196+
#[test]
197+
fn ambiguous_user_and_host_remain_disallowed_together_implicit_ssh() {
198+
assert!(matches!(
199+
try_call(ProgramKind::Ssh, "-userarg@-hostarg:p/q", Protocol::V2),
200+
Err(ssh::invocation::Error::AmbiguousUserName { user }) if user == "-userarg"
201+
));
202+
}
203+
163204
#[test]
164205
fn simple_cannot_handle_any_arguments() {
165206
assert!(matches!(

gix-url/src/lib.rs

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,27 @@ pub fn expand_path(user: Option<&expand_path::ForUser>, path: &BStr) -> Result<P
5555
})
5656
}
5757

58+
/// Classification of a portion of a URL by whether it is *syntactically* safe to pass as an argument to a command-line program.
59+
///
60+
/// Various parts of URLs can be specified to begin with `-`. If they are used as options to a command-line application
61+
/// such as an SSH client, they will be treated as options rather than as non-option arguments as the developer intended.
62+
/// This is a security risk, because URLs are not always trusted and can often be composed or influenced by an attacker.
63+
/// See <https://secure.phabricator.com/T12961> for details.
64+
///
65+
/// # Security Warning
66+
///
67+
/// This type only expresses known *syntactic* risk. It does not cover other risks, such as passing a personal access
68+
/// token as a username rather than a password in an application that logs usernames.
69+
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
70+
pub enum ArgumentSafety<'a> {
71+
/// May be safe. There is nothing to pass, so there is nothing dangerous.
72+
Absent,
73+
/// May be safe. The argument does not begin with a `-` and so will not be confused as an option.
74+
Usable(&'a str),
75+
/// Dangerous! Begins with `-` and could be treated as an option. Use the value in error messages only.
76+
Dangerous(&'a str),
77+
}
78+
5879
/// A URL with support for specialized git related capabilities.
5980
///
6081
/// Additionally there is support for [deserialization](Url::from_bytes()) and [serialization](Url::to_bstring()).
@@ -85,12 +106,12 @@ pub struct Url {
85106
pub port: Option<u16>,
86107
/// The path portion of the URL, usually the location of the git repository.
87108
///
88-
/// # Security-Warning
109+
/// # Security Warning
89110
///
90111
/// URLs allow paths to start with `-` which makes it possible to mask command-line arguments as path which then leads to
91112
/// the invocation of programs from an attacker controlled URL. See <https://secure.phabricator.com/T12961> for details.
92113
///
93-
/// If this value is going to be used in a command-line application, call [Self::path_argument_safe()] instead.
114+
/// If this value is ever going to be passed to a command-line application, call [Self::path_argument_safe()] instead.
94115
pub path: BString,
95116
}
96117

@@ -164,48 +185,101 @@ impl Url {
164185

165186
/// Access
166187
impl Url {
167-
/// Returns the user mentioned in the url, if present.
188+
/// Return the username mentioned in the URL, if present.
189+
///
190+
/// # Security Warning
191+
///
192+
/// URLs allow usernames to start with `-` which makes it possible to mask command-line arguments as username which then leads to
193+
/// the invocation of programs from an attacker controlled URL. See <https://secure.phabricator.com/T12961> for details.
194+
///
195+
/// If this value is ever going to be passed to a command-line application, call [Self::user_argument_safe()] instead.
168196
pub fn user(&self) -> Option<&str> {
169197
self.user.as_deref()
170198
}
171-
/// Returns the password mentioned in the url, if present.
199+
200+
/// Classify the username of this URL by whether it is safe to pass as a command-line argument.
201+
///
202+
/// Use this method instead of [Self::user()] if the host is going to be passed to a command-line application.
203+
/// If the unsafe and absent cases need not be distinguished, [Self::user_argument_safe()] may also be used.
204+
pub fn user_as_argument(&self) -> ArgumentSafety<'_> {
205+
match self.user() {
206+
Some(user) if looks_like_command_line_option(user.as_bytes()) => ArgumentSafety::Dangerous(user),
207+
Some(user) => ArgumentSafety::Usable(user),
208+
None => ArgumentSafety::Absent,
209+
}
210+
}
211+
212+
/// Return the username of this URL if present *and* if it can't be mistaken for a command-line argument.
213+
///
214+
/// Use this method or [Self::user_as_argument()] instead of [Self::user()] if the host is going to be
215+
/// passed to a command-line application. Prefer [Self::user_as_argument()] unless the unsafe and absent
216+
/// cases need not be distinguished from each other.
217+
pub fn user_argument_safe(&self) -> Option<&str> {
218+
match self.user_as_argument() {
219+
ArgumentSafety::Usable(user) => Some(user),
220+
_ => None,
221+
}
222+
}
223+
224+
/// Return the password mentioned in the url, if present.
172225
pub fn password(&self) -> Option<&str> {
173226
self.password.as_deref()
174227
}
175-
/// Returns the host mentioned in the url, if present.
228+
229+
/// Return the host mentioned in the URL, if present.
176230
///
177-
/// # Security-Warning
231+
/// # Security Warning
178232
///
179233
/// URLs allow hosts to start with `-` which makes it possible to mask command-line arguments as host which then leads to
180234
/// the invocation of programs from an attacker controlled URL. See <https://secure.phabricator.com/T12961> for details.
181235
///
182-
/// If this value is going to be used in a command-line application, call [Self::host_argument_safe()] instead.
236+
/// If this value is ever going to be passed to a command-line application, call [Self::host_as_argument()]
237+
/// or [Self::host_argument_safe()] instead.
183238
pub fn host(&self) -> Option<&str> {
184239
self.host.as_deref()
185240
}
186241

242+
/// Classify the host of this URL by whether it is safe to pass as a command-line argument.
243+
///
244+
/// Use this method instead of [Self::host()] if the host is going to be passed to a command-line application.
245+
/// If the unsafe and absent cases need not be distinguished, [Self::host_argument_safe()] may also be used.
246+
pub fn host_as_argument(&self) -> ArgumentSafety<'_> {
247+
match self.host() {
248+
Some(host) if looks_like_command_line_option(host.as_bytes()) => ArgumentSafety::Dangerous(host),
249+
Some(host) => ArgumentSafety::Usable(host),
250+
None => ArgumentSafety::Absent,
251+
}
252+
}
253+
187254
/// Return the host of this URL if present *and* if it can't be mistaken for a command-line argument.
188255
///
189-
/// Use this method if the host is going to be passed to a command-line application.
256+
/// Use this method or [Self::host_as_argument()] instead of [Self::host()] if the host is going to be
257+
/// passed to a command-line application. Prefer [Self::host_as_argument()] unless the unsafe and absent
258+
/// cases need not be distinguished from each other.
190259
pub fn host_argument_safe(&self) -> Option<&str> {
191-
self.host().filter(|host| !looks_like_argument(host.as_bytes()))
260+
match self.host_as_argument() {
261+
ArgumentSafety::Usable(host) => Some(host),
262+
_ => None,
263+
}
192264
}
193265

194-
/// Return the path of this URL *and* if it can't be mistaken for a command-line argument.
266+
/// Return the path of this URL *if* it can't be mistaken for a command-line argument.
195267
/// Note that it always begins with a slash, which is ignored for this comparison.
196268
///
197-
/// Use this method if the path is going to be passed to a command-line application.
269+
/// Use this method instead of accessing [Self::path] directly if the path is going to be passed to a
270+
/// command-line application, unless it is certain that the leading `/` will always be included.
198271
pub fn path_argument_safe(&self) -> Option<&BStr> {
199272
self.path
200273
.get(1..)
201-
.and_then(|truncated| (!looks_like_argument(truncated)).then_some(self.path.as_ref()))
274+
.and_then(|truncated| (!looks_like_command_line_option(truncated)).then_some(self.path.as_ref()))
202275
}
203276

204-
/// Returns true if the path portion of the url is `/`.
277+
/// Return true if the path portion of the URL is `/`.
205278
pub fn path_is_root(&self) -> bool {
206279
self.path == "/"
207280
}
208-
/// Returns the actual or default port for use according to the url scheme.
281+
282+
/// Return the actual or default port for use according to the URL scheme.
209283
/// Note that there may be no default port either.
210284
pub fn port_or_default(&self) -> Option<u16> {
211285
self.port.or_else(|| {
@@ -221,13 +295,13 @@ impl Url {
221295
}
222296
}
223297

224-
fn looks_like_argument(b: &[u8]) -> bool {
298+
fn looks_like_command_line_option(b: &[u8]) -> bool {
225299
b.first() == Some(&b'-')
226300
}
227301

228302
/// Transformation
229303
impl Url {
230-
/// Turn a file url like `file://relative` into `file:///root/relative`, hence it assures the url's path component is absolute, using
304+
/// Turn a file URL like `file://relative` into `file:///root/relative`, hence it assures the URL's path component is absolute, using
231305
/// `current_dir` if necessary.
232306
pub fn canonicalized(&self, current_dir: &std::path::Path) -> Result<Self, gix_path::realpath::Error> {
233307
let mut res = self.clone();
@@ -287,7 +361,7 @@ impl Url {
287361

288362
/// Deserialization
289363
impl Url {
290-
/// Parse a URL from `bytes`
364+
/// Parse a URL from `bytes`.
291365
pub fn from_bytes(bytes: &BStr) -> Result<Self, parse::Error> {
292366
parse(bytes)
293367
}

0 commit comments

Comments
 (0)