Skip to content

Commit d2e193f

Browse files
codexByron
andcommitted
fix(gix-submodule): don't follow submodule names with relative paths in them
This made it possible to trick submodule repos to be opened outside of the actual repository. Co-authored-by: Sebastian Thiel <sebastian.thiel@icloud.com>
1 parent e3ca1e6 commit d2e193f

4 files changed

Lines changed: 91 additions & 30 deletions

File tree

gix-validate/src/submodule.rs

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,10 @@ pub fn name(name: &BStr) -> Result<&BStr, name::Error> {
2828
if name.is_empty() {
2929
return Err(name::Error::Empty);
3030
}
31-
match name.find(b"..") {
32-
Some(pos) => {
33-
let &b = name.get(pos + 2).ok_or(name::Error::ParentComponent)?;
34-
if b == b'/' || b == b'\\' {
35-
Err(name::Error::ParentComponent)
36-
} else {
37-
Ok(name)
38-
}
31+
for component in name.as_bytes().split(|b| *b == b'/' || *b == b'\\') {
32+
if component == b".." {
33+
return Err(name::Error::ParentComponent);
3934
}
40-
None => Ok(name),
4135
}
36+
Ok(name)
4237
}

gix/src/submodule/errors.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,42 @@ pub mod open {
7575
#[error(transparent)]
7676
OpenRepository(#[from] crate::open::Error),
7777
#[error(transparent)]
78+
GitDir(#[from] crate::submodule::git_dir_try_old_form::Error),
79+
#[error(transparent)]
7880
PathConfiguration(#[from] gix_submodule::config::path::Error),
7981
#[error(transparent)]
8082
WorktreeDirInaccessible(#[from] std::io::Error),
8183
}
8284
}
8385

86+
///
87+
pub mod git_dir_try_old_form {
88+
/// The error returned by [Submodule::git_dir_try_old_form()](crate::Submodule::git_dir_try_old_form()).
89+
#[derive(Debug, thiserror::Error)]
90+
#[allow(missing_docs)]
91+
pub enum Error {
92+
#[error(transparent)]
93+
PathConfiguration(#[from] gix_submodule::config::path::Error),
94+
#[error(transparent)]
95+
GitDir(#[from] gix_validate::submodule::name::Error),
96+
}
97+
}
98+
99+
///
100+
pub mod state {
101+
/// The error returned by [Submodule::state()](crate::Submodule::state()).
102+
#[derive(Debug, thiserror::Error)]
103+
#[allow(missing_docs)]
104+
pub enum Error {
105+
#[error(transparent)]
106+
GitDirTryOldForm(#[from] crate::submodule::git_dir_try_old_form::Error),
107+
#[error(transparent)]
108+
GitDir(#[from] gix_validate::submodule::name::Error),
109+
#[error(transparent)]
110+
PathConfiguration(#[from] gix_submodule::config::path::Error),
111+
}
112+
}
113+
84114
///
85115
pub mod index_id {
86116
/// The error returned by [Submodule::index_id()](crate::Submodule::index_id()).

gix/src/submodule/mod.rs

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,20 @@ struct IsActiveState {
8484

8585
///Access
8686
impl Submodule<'_> {
87-
/// Return the submodule's name.
87+
/// Return the submodule's configured name as it appears in `submodule.<name>.*`.
88+
///
89+
/// Note that this name is not guaranteed to be valid and may contain traversal components if
90+
/// the configuration was crafted manually.
91+
///
92+
/// Use [`validated_name()`](Self::validated_name()) to obtain a validated submodule name.
8893
pub fn name(&self) -> &BStr {
8994
self.name.as_ref()
9095
}
96+
97+
/// Return the submodule's name after validating it for safe use in paths like `.git/modules/<name>`.
98+
pub fn validated_name(&self) -> Result<&BStr, gix_validate::submodule::name::Error> {
99+
gix_validate::submodule::name(self.name())
100+
}
91101
/// Return the path at which the submodule can be found, relative to the repository.
92102
///
93103
/// For details, see [gix_submodule::File::path()].
@@ -194,13 +204,14 @@ impl Submodule<'_> {
194204

195205
/// Return the path at which the repository of the submodule should be located.
196206
///
197-
/// The directory might not exist yet.
198-
pub fn git_dir(&self) -> PathBuf {
199-
self.state
207+
/// The retunred directory might not exist yet.
208+
pub fn git_dir(&self) -> Result<PathBuf, gix_validate::submodule::name::Error> {
209+
Ok(self
210+
.state
200211
.repo
201212
.common_dir()
202213
.join("modules")
203-
.join(gix_path::from_bstr(self.name()))
214+
.join(gix_path::from_bstr(self.validated_name()?)))
204215
}
205216

206217
/// Return the path to the location at which the workdir would be checked out.
@@ -223,20 +234,21 @@ impl Submodule<'_> {
223234
/// invalid - it's left to the caller to try to open it.
224235
///
225236
/// Also note that the returned path may not actually exist.
226-
pub fn git_dir_try_old_form(&self) -> Result<PathBuf, config::path::Error> {
227-
let worktree_gitdir_or_modules_gitdir = if self.worktree_gitdir()?.is_dir() {
228-
self.worktree_gitdir()?
237+
pub fn git_dir_try_old_form(&self) -> Result<PathBuf, git_dir_try_old_form::Error> {
238+
let worktree_gitdir = self.worktree_gitdir()?;
239+
let worktree_gitdir_or_modules_gitdir = if worktree_gitdir.is_dir() {
240+
worktree_gitdir
229241
} else {
230-
self.git_dir()
242+
self.git_dir()?
231243
};
232244
Ok(worktree_gitdir_or_modules_gitdir)
233245
}
234246

235247
/// Query various parts of the submodule and assemble it into state information.
236248
#[doc(alias = "status", alias = "git2")]
237-
pub fn state(&self) -> Result<State, config::path::Error> {
249+
pub fn state(&self) -> Result<State, state::Error> {
238250
let maybe_old_path = self.git_dir_try_old_form()?;
239-
let git_dir = self.git_dir();
251+
let git_dir = self.git_dir()?;
240252
let worktree_git = self.worktree_gitdir()?;
241253
let superproject_configuration = self
242254
.state
@@ -298,15 +310,15 @@ impl Submodule<'_> {
298310
pub mod status {
299311
use gix_submodule::config;
300312

301-
use super::{head_id, index_id, open, Status};
313+
use super::{head_id, index_id, open, state, Status};
302314
use crate::Submodule;
303315

304316
/// The error returned by [Submodule::status()].
305317
#[derive(Debug, thiserror::Error)]
306318
#[allow(missing_docs)]
307319
pub enum Error {
308320
#[error(transparent)]
309-
State(#[from] config::path::Error),
321+
State(#[from] state::Error),
310322
#[error(transparent)]
311323
HeadId(#[from] head_id::Error),
312324
#[error(transparent)]

gix/tests/gix/submodule.rs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ mod open {
462462

463463
assert_ne!(
464464
sm.git_dir_try_old_form()?,
465-
sm.git_dir(),
465+
sm.git_dir()?,
466466
"compat git dir should be the worktree location"
467467
);
468468
let sm_repo = sm.open()?.expect("initialized");
@@ -497,7 +497,6 @@ mod advisory {
497497
fn traversal_names_do_not_escape_the_modules_directory() -> crate::Result {
498498
let fixture = gix_testtools::scripted_fixture_writable("make_submodule_traversal_advisory.sh")?;
499499
let repo_dir = fixture.path().join("victim-repo");
500-
let redirected_repo = fixture.path().join("escaped-target.git");
501500

502501
let repo = gix::open_opts(&repo_dir, crate::restricted())?;
503502
let sm = repo
@@ -506,15 +505,40 @@ mod advisory {
506505
.next()
507506
.expect("one malicious submodule");
508507

508+
assert!(matches!(
509+
sm.git_dir(),
510+
Err(gix_validate::submodule::name::Error::ParentComponent)
511+
));
512+
assert!(matches!(
513+
sm.git_dir_try_old_form(),
514+
Err(gix::submodule::git_dir_try_old_form::Error::GitDir(
515+
gix_validate::submodule::name::Error::ParentComponent
516+
))
517+
));
518+
519+
assert!(matches!(
520+
sm.open(),
521+
Err(gix::submodule::open::Error::GitDir(
522+
gix::submodule::git_dir_try_old_form::Error::GitDir(
523+
gix_validate::submodule::name::Error::ParentComponent
524+
)
525+
))
526+
));
527+
assert!(matches!(
528+
sm.state(),
529+
Err(gix::submodule::state::Error::GitDirTryOldForm(
530+
gix::submodule::git_dir_try_old_form::Error::GitDir(
531+
gix_validate::submodule::name::Error::ParentComponent
532+
)
533+
))
534+
));
535+
536+
let redirected_repo = fixture.path().join("escaped-target.git");
509537
assert!(
510-
sm.open()?.is_none(),
511-
"opening a submodule must not reach the external repository at {}",
538+
redirected_repo.is_dir(),
539+
"the attacker-controlled repository does indeex exist at {}",
512540
redirected_repo.display()
513541
);
514-
assert!(
515-
!sm.state()?.repository_exists,
516-
"submodule names with traversal components must not resolve to repositories outside `.git/modules`"
517-
);
518542
Ok(())
519543
}
520544

0 commit comments

Comments
 (0)