VYPR
High severity7.8NVD Advisory· Published May 26, 2026· Updated May 26, 2026

CVE-2026-40034

CVE-2026-40034

Description

gix-submodule before 0.82.0 incorrectly validates the update field in .gitmodules, allowing attackers to bypass the CommandForbiddenInModulesConfiguration guard when a submodule has been initialized with only partial configuration in .git/config. An attacker can inject arbitrary shell commands via the update field in .gitmodules that will be executed when Submodule::update() is called on a previously-initialized submodule, enabling remote code execution.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

gix-submodule before 0.82.0 incorrectly validates the update field in .gitmodules, allowing attacker-injected shell commands to execute via Submodule::update().

Vulnerability

In gix-submodule before version 0.82.0, the gix_submodule::File::update() method incorrectly validates the update field in .gitmodules [1][3]. When a submodule has been previously initialized (i.e., submodule..url exists in .git/config), the guard CommandForbiddenInModulesConfiguration passes because it only checks whether any section with the same submodule name exists from a non-.gitmodules source, not whether the update value actually originated from that trusted source [1]. Consequently, an attacker can set update = ! in .gitmodules, and the code path in access.rs lines 168–193 will return the attacker-controlled command as trusted [1][2]. The vulnerable logic was introduced in commit 6a2e6a4 [1][2].

Exploitation

An attacker needs to provide a malicious .gitmodules file (e.g., via a repository the victim clones or fetches) containing an update entry such as update = !touch /tmp/pwned for a submodule that has already been initialized in the victim's local .git/config [1][3]. No special authentication is required beyond the victim interacting with the repository (e.g., running gix::Submodule::update() on the submodule) [1]. The victim need not explicitly modify .git/config; any workflow that writes submodule..url to .git/config suffices [1]. The attack does not require dynamic reproduction, having been identified via static analysis [3].

Impact

Successful exploitation leads to remote code execution at the privilege level of the victim process invoking Submodule::update() [1][3]. The attacker can execute arbitrary shell commands on the victim's system, which may result in full compromise of the affected environment. This bypass mirrors the attack class described in Git CVE-2019-19604 [1]. Note that the official git client aborts with fatal: invalid value for 'submodule.sub.update' on the same repository state, highlighting the severity of the deviation in gix [1].

Mitigation

Upgrade gix-submodule to version 0.82.0 or later, which contains the fix merged in commit dd5c18d [4]. The fix ensures that the CommandForbiddenInModulesConfiguration guard correctly verifies the origin of the update value, not just the existence of a section in trusted configuration [1]. Users who cannot immediately upgrade should avoid cloning or fetching repositories from untrusted sources, and review any submodule operations for unexpected update directives. No workaround patch for earlier versions has been provided beyond the fix commit [4]. The CVE is not listed on CISA KEV as of publication.

AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Gitoxide/Gitoxidereferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <0.82.0

Patches

2
dd5c18d9e526

Merge pull request #2464 from GitoxideLabs/improvements

https://github.com/GitoxideLabs/gitoxideSebastian ThielMar 9, 2026via nvd-ref
1 file changed · +6 2
  • .mailmap+6 2 modified
    @@ -1,2 +1,6 @@
    -<sebastian.thiel@icloud.com> <byronimo@gmail.com>  
    -<sebastian.thiel@icloud.com> <sthiel@thoughtworks.com>
    +Byron <sebastian.thiel@icloud.com> <byronimo@gmail.com>
    +Byron <sebastian.thiel@icloud.com> <sthiel@thoughtworks.com>
    +
    +Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
    +Byron <sebastian.thiel@icloud.com> Sebastian Thiel <sebastian.thiel@icloud.com>
    +Copilot <198982749+Copilot@users.noreply.github.com> copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
    
6a2e6a436f76

`.gitmodule` file abstraction

https://github.com/GitoxideLabs/gitoxideSebastian ThielAug 4, 2023via nvd-ref
12 files changed · +969 6
  • Cargo.lock+14 4 modified
    @@ -2196,6 +2196,16 @@ version = "0.0.0"
     [[package]]
     name = "gix-submodule"
     version = "0.0.0"
    +dependencies = [
    + "bstr",
    + "gix-config",
    + "gix-features 0.32.1",
    + "gix-path 0.8.4",
    + "gix-refspec",
    + "gix-testtools",
    + "gix-url",
    + "thiserror",
    +]
     
     [[package]]
     name = "gix-tempfile"
    @@ -4073,18 +4083,18 @@ dependencies = [
     
     [[package]]
     name = "thiserror"
    -version = "1.0.43"
    +version = "1.0.44"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42"
    +checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90"
     dependencies = [
      "thiserror-impl",
     ]
     
     [[package]]
     name = "thiserror-impl"
    -version = "1.0.43"
    +version = "1.0.44"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f"
    +checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96"
     dependencies = [
      "proc-macro2",
      "quote",
    
  • crate-status.md+6 1 modified
    @@ -486,6 +486,7 @@ Make it the best-performing implementation and the most convenient one.
     * [x] primitives to help with graph traversal, along with commit-graph acceleration.
      
     ### gix-submodule
    +* [ ] read `.gitmodule` files, access all their fields, and apply overrides
     * CRUD for submodules
     * try to handle with all the nifty interactions and be a little more comfortable than what git offers, lay a foundation for smarter git submodules.
     
    @@ -721,7 +722,10 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/gix-lock/README.
         * [ ] Use _Commit Graph_ to speed up certain queries
         * [ ] subtree
         * [ ] interactive rebase status/manipulation
    -    * submodules
    +    * **submodules**
    +       * [ ] handle 'old' form for reading
    +       * [ ] list
    +       * [ ] traverse recursively
     * [ ] API documentation
         * [ ] Some examples
     
    @@ -754,6 +758,7 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/gix-lock/README.
     
     ### gix-validate
     * [x] validate ref names
    +* [x] validate submodule names
     * [x] [validate][tagname-validation] tag names
     
     ### gix-ref
    
  • gix-submodule/Cargo.toml+12 0 modified
    @@ -12,3 +12,15 @@ rust-version = "1.65"
     doctest = false
     
     [dependencies]
    +gix-refspec = { version = "^0.14.1", path = "../gix-refspec" }
    +gix-config = { version = "^0.26.2", path = "../gix-config" }
    +gix-path = { version = "^0.8.4", path = "../gix-path" }
    +gix-url = { version = "^0.21.1", path = "../gix-url" }
    +
    +bstr = { version = "1.5.0", default-features = false }
    +thiserror = "1.0.44"
    +
    +[dev-dependencies]
    +gix-testtools = { path = "../tests/tools"}
    +gix-features = { path = "../gix-features", features = ["walkdir"] }
    +
    
  • gix-submodule/src/access.rs+184 0 added
    @@ -0,0 +1,184 @@
    +use crate::config::{Branch, FetchRecurse, Ignore, Update};
    +use crate::{config, File};
    +use bstr::BStr;
    +use std::borrow::Cow;
    +use std::path::Path;
    +
    +/// Access
    +///
    +/// Note that all methods perform validation of the requested value and report issues right away.
    +/// If a bypass is needed, use [`config()`](File::config()) for direct access.
    +impl File {
    +    /// Return the underlying configuration file.
    +    ///
    +    /// Note that it might have been merged with values from another configuration file and may
    +    /// thus not be accurately reflecting that state of a `.gitmodules` file anymore.
    +    pub fn config(&self) -> &gix_config::File<'static> {
    +        &self.config
    +    }
    +
    +    /// Return the path at which the `.gitmodules` file lives, if it is known.
    +    pub fn config_path(&self) -> Option<&Path> {
    +        self.config.sections().filter_map(|s| s.meta().path.as_deref()).next()
    +    }
    +
    +    /// Return the unvalidated names of the submodules for which configuration is present.
    +    ///
    +    /// Note that these exact names have to be used for querying submodule values.
    +    pub fn names(&self) -> impl Iterator<Item = &BStr> {
    +        self.config
    +            .sections_by_name("submodule")
    +            .into_iter()
    +            .flatten()
    +            .filter_map(|s| s.header().subsection_name())
    +    }
    +
    +    /// Given the `relative_path` (as seen from the root of the worktree) of a submodule with possibly platform-specific
    +    /// component separators, find the submodule's name associated with this path, or `None` if none was found.
    +    ///
    +    /// Note that this does a linear search and compares `relative_path` in a normalized form to the same form of the path
    +    /// associated with the submodule.
    +    pub fn name_by_path(&self, relative_path: &BStr) -> Option<&BStr> {
    +        self.names()
    +            .filter_map(|n| self.path(n).ok().map(|p| (n, p)))
    +            .find_map(|(n, p)| (p == relative_path).then_some(n))
    +    }
    +
    +    /// Return the path relative to the root directory of the working tree at which the submodule is expected to be checked out.
    +    /// It's an error if the path doesn't exist as it's the only way to associate a path in the index with additional submodule
    +    /// information, like the URL to fetch from.
    +    ///
    +    /// ### Deviation
    +    ///
    +    /// Git currently allows absolute paths to be used when adding submodules, but fails later as it can't find the submodule by
    +    /// relative path anymore. Let's play it safe here.
    +    pub fn path(&self, name: &BStr) -> Result<Cow<'_, BStr>, config::path::Error> {
    +        let path_bstr =
    +            self.config
    +                .string("submodule", Some(name), "path")
    +                .ok_or_else(|| config::path::Error::Missing {
    +                    submodule: name.to_owned(),
    +                })?;
    +        if path_bstr.is_empty() {
    +            return Err(config::path::Error::Missing {
    +                submodule: name.to_owned(),
    +            });
    +        }
    +        let path = gix_path::from_bstr(path_bstr.as_ref());
    +        if path.is_absolute() {
    +            return Err(config::path::Error::Absolute {
    +                submodule: name.to_owned(),
    +                actual: path_bstr.into_owned(),
    +            });
    +        }
    +        if gix_path::normalize(path, "").is_none() {
    +            return Err(config::path::Error::OutsideOfWorktree {
    +                submodule: name.to_owned(),
    +                actual: path_bstr.into_owned(),
    +            });
    +        }
    +        Ok(path_bstr)
    +    }
    +
    +    /// Retrieve the `url` field of the submodule named `name`. It's an error if it doesn't exist or is empty.
    +    pub fn url(&self, name: &BStr) -> Result<gix_url::Url, config::url::Error> {
    +        let url = self
    +            .config
    +            .string("submodule", Some(name), "url")
    +            .ok_or_else(|| config::url::Error::Missing {
    +                submodule: name.to_owned(),
    +            })?;
    +
    +        if url.is_empty() {
    +            return Err(config::url::Error::Missing {
    +                submodule: name.to_owned(),
    +            });
    +        }
    +        gix_url::Url::from_bytes(url.as_ref()).map_err(|err| config::url::Error::Parse {
    +            submodule: name.to_owned(),
    +            source: err,
    +        })
    +    }
    +
    +    /// Retrieve the `update` field of the submodule named `name`, if present.
    +    pub fn update(&self, name: &BStr) -> Result<Option<Update>, config::update::Error> {
    +        let value: Update = match self.config.string("submodule", Some(name), "update") {
    +            Some(v) => v.as_ref().try_into().map_err(|()| config::update::Error::Invalid {
    +                submodule: name.to_owned(),
    +                actual: v.into_owned(),
    +            })?,
    +            None => return Ok(None),
    +        };
    +
    +        if let Update::Command(cmd) = &value {
    +            let ours = self.config.meta();
    +            let has_value_from_foreign_section = self
    +                .config
    +                .sections_by_name("submodule")
    +                .into_iter()
    +                .flatten()
    +                .any(|s| (s.header().subsection_name() == Some(name) && s.meta() as *const _ != ours as *const _));
    +            if !has_value_from_foreign_section {
    +                return Err(config::update::Error::CommandForbiddenInModulesConfiguration {
    +                    submodule: name.to_owned(),
    +                    actual: cmd.to_owned(),
    +                });
    +            }
    +        }
    +        Ok(Some(value))
    +    }
    +
    +    /// Retrieve the `branch` field of the submodule named `name`, or `None` if unset.
    +    ///
    +    /// Note that `Default` is implemented for [`Branch`].
    +    pub fn branch(&self, name: &BStr) -> Result<Option<Branch>, config::branch::Error> {
    +        let branch = match self.config.string("submodule", Some(name), "branch") {
    +            Some(v) => v,
    +            None => return Ok(None),
    +        };
    +
    +        Branch::try_from(branch.as_ref())
    +            .map(Some)
    +            .map_err(|err| config::branch::Error {
    +                submodule: name.to_owned(),
    +                actual: branch.into_owned(),
    +                source: err,
    +            })
    +    }
    +
    +    /// Retrieve the `fetchRecurseSubmodules` field of the submodule named `name`, or `None` if unset.
    +    ///
    +    /// Note that if it's unset, it should be retrieved from `fetch.recurseSubmodules` in the configuration.
    +    pub fn fetch_recurse(&self, name: &BStr) -> Result<Option<FetchRecurse>, config::Error> {
    +        self.config
    +            .boolean("submodule", Some(name), "fetchRecurseSubmodules")
    +            .map(FetchRecurse::new)
    +            .transpose()
    +            .map_err(|value| config::Error {
    +                field: "fetchRecurseSubmodules",
    +                submodule: name.to_owned(),
    +                actual: value,
    +            })
    +    }
    +
    +    /// Retrieve the `ignore` field of the submodule named `name`, or `None` if unset.
    +    pub fn ignore(&self, name: &BStr) -> Result<Option<Ignore>, config::Error> {
    +        self.config
    +            .string("submodule", Some(name), "ignore")
    +            .map(|value| {
    +                Ignore::try_from(value.as_ref()).map_err(|()| config::Error {
    +                    field: "ignore",
    +                    submodule: name.to_owned(),
    +                    actual: value.into_owned(),
    +                })
    +            })
    +            .transpose()
    +    }
    +
    +    /// Retrieve the `shallow` field of the submodule named `name`, or `None` if unset.
    +    ///
    +    /// If `true`, the submodule will be checked out with `depth = 1`. If unset, `false` is assumed.
    +    pub fn shallow(&self, name: &BStr) -> Result<Option<bool>, gix_config::value::Error> {
    +        self.config.boolean("submodule", Some(name), "shallow").transpose()
    +    }
    +}
    
  • gix-submodule/src/config.rs+216 0 added
    @@ -0,0 +1,216 @@
    +use bstr::{BStr, BString, ByteSlice};
    +
    +/// Determine how the submodule participates in `git status` queries. This setting also affects `git diff`.
    +#[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)]
    +pub enum Ignore {
    +    /// Submodule changes won't be considered at all, which is the fastest option.
    +    ///
    +    /// Note that changes to the submodule hash in the superproject will still be observable.
    +    All,
    +    /// Ignore any changes to the submodule working tree, only show committed differences between the `HEAD` of the submodule
    +    /// compared to the recorded commit in the superproject.
    +    Dirty,
    +    /// Only ignore untracked files in the submodule, but show modifications to the submodule working tree as well as differences
    +    /// between the recorded commit in the superproject and the checked-out commit in the submodule.
    +    Untracked,
    +    /// No modifications to the submodule are ignored, which shows untracked files, modified files in the submodule worktree as well as
    +    /// differences between the recorded commit in the superproject and the checked-out commit in the submodule.
    +    #[default]
    +    None,
    +}
    +
    +impl TryFrom<&BStr> for Ignore {
    +    type Error = ();
    +
    +    fn try_from(value: &BStr) -> Result<Self, Self::Error> {
    +        Ok(match value.as_bytes() {
    +            b"all" => Ignore::All,
    +            b"dirty" => Ignore::Dirty,
    +            b"untracked" => Ignore::Untracked,
    +            b"none" => Ignore::None,
    +            _ => return Err(()),
    +        })
    +    }
    +}
    +
    +/// Determine how to recurse into this module from the superproject when fetching.
    +///
    +/// Generally, a fetch is only performed if the submodule commit referenced by the superproject isn't already
    +/// present in the submodule repository.
    +///
    +/// Note that when unspecified, the `fetch.recurseSubmodules` configuration variable should be used instead.
    +#[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)]
    +pub enum FetchRecurse {
    +    /// Fetch only changed submodules.
    +    #[default]
    +    OnDemand,
    +    /// Fetch all populated submodules, changed or not.
    +    ///
    +    /// This skips the work needed to determine whether a submodule has changed in the first place, but may work
    +    /// more as some fetches might not be necessary.
    +    Always,
    +    /// Submodules are never fetched.
    +    Never,
    +}
    +
    +impl FetchRecurse {
    +    /// Check if `boolean` is set and translate it the respective variant, or check the underlying string
    +    /// value for non-boolean options.
    +    /// On error, it returns the obtained string value which would be the invalid value.
    +    pub fn new(boolean: Result<bool, gix_config::value::Error>) -> Result<Self, BString> {
    +        Ok(match boolean {
    +            Ok(value) => {
    +                if value {
    +                    FetchRecurse::Always
    +                } else {
    +                    FetchRecurse::Never
    +                }
    +            }
    +            Err(err) => {
    +                if err.input != "on-demand" {
    +                    return Err(err.input);
    +                }
    +                FetchRecurse::OnDemand
    +            }
    +        })
    +    }
    +}
    +
    +/// Describes the branch that should be tracked on the remote.
    +#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq, Hash)]
    +pub enum Branch {
    +    /// The name of the remote branch should be the same as the one currently checked out in the superproject.
    +    CurrentInSuperproject,
    +    /// The validated remote-only branch that could be used for fetching.
    +    Name(BString),
    +}
    +
    +impl Default for Branch {
    +    fn default() -> Self {
    +        Branch::Name("HEAD".into())
    +    }
    +}
    +
    +impl TryFrom<&BStr> for Branch {
    +    type Error = gix_refspec::parse::Error;
    +
    +    fn try_from(value: &BStr) -> Result<Self, Self::Error> {
    +        if value == "." {
    +            return Ok(Branch::CurrentInSuperproject);
    +        }
    +
    +        gix_refspec::parse(value, gix_refspec::parse::Operation::Fetch)
    +            .map(|spec| Branch::Name(spec.source().expect("no object").to_owned()))
    +    }
    +}
    +
    +/// Determine how `git submodule update` should deal with this submodule to bring it up-to-date with the
    +/// super-project's expectations.
    +#[derive(Default, Debug, Clone, Hash, PartialOrd, PartialEq, Ord, Eq)]
    +pub enum Update {
    +    /// The commit recorded in the superproject should be checked out on a detached `HEAD`.
    +    #[default]
    +    Checkout,
    +    /// The current branch in the submodule will be rebased onto the commit recorded in the superproject.
    +    Rebase,
    +    /// The commit recorded in the superproject will merged into the current branch of the submodule.
    +    Merge,
    +    /// A custom command to be called like `<command> hash-of-submodule-commit` that is to be executed to
    +    /// perform the submodule update.
    +    ///
    +    /// Note that this variant is only allowed if the value is coming from an override. Thus it's not allowed to distribute
    +    /// arbitrary commands via `.gitmodules` for security reasons.
    +    Command(BString),
    +    /// The submodule update is not performed at all.
    +    None,
    +}
    +
    +impl TryFrom<&BStr> for Update {
    +    type Error = ();
    +
    +    fn try_from(value: &BStr) -> Result<Self, Self::Error> {
    +        Ok(match value.as_bstr().as_bytes() {
    +            b"checkout" => Update::Checkout,
    +            b"rebase" => Update::Rebase,
    +            b"merge" => Update::Merge,
    +            b"none" => Update::None,
    +            command if command.first() == Some(&b'!') => Update::Command(command[1..].to_owned().into()),
    +            _ => return Err(()),
    +        })
    +    }
    +}
    +
    +/// The error returned by [File::fetch_recurse()](crate::File::fetch_recurse) and [File::ignore()](crate::File::ignore).
    +#[derive(Debug, thiserror::Error)]
    +#[allow(missing_docs)]
    +#[error("The '{field}' field of submodule '{submodule}' was invalid: '{actual}'")]
    +pub struct Error {
    +    pub field: &'static str,
    +    pub submodule: BString,
    +    pub actual: BString,
    +}
    +
    +///
    +pub mod branch {
    +    use bstr::BString;
    +
    +    /// The error returned by [File::branch()](crate::File::branch).
    +    #[derive(Debug, thiserror::Error)]
    +    #[allow(missing_docs)]
    +    #[error("The value '{actual}' of the 'branch' field of submodule '{submodule}' couldn't be turned into a valid fetch refspec")]
    +    pub struct Error {
    +        pub submodule: BString,
    +        pub actual: BString,
    +        pub source: gix_refspec::parse::Error,
    +    }
    +}
    +
    +///
    +pub mod update {
    +    use bstr::BString;
    +
    +    /// The error returned by [File::update()](crate::File::update).
    +    #[derive(Debug, thiserror::Error)]
    +    #[allow(missing_docs)]
    +    pub enum Error {
    +        #[error("The 'update' field of submodule '{submodule}' tried to set command '{actual}' to be shared")]
    +        CommandForbiddenInModulesConfiguration { submodule: BString, actual: BString },
    +        #[error("The 'update' field of submodule '{submodule}' was invalid: '{actual}'")]
    +        Invalid { submodule: BString, actual: BString },
    +    }
    +}
    +
    +///
    +pub mod url {
    +    use bstr::BString;
    +
    +    /// The error returned by [File::url()](crate::File::url).
    +    #[derive(Debug, thiserror::Error)]
    +    #[allow(missing_docs)]
    +    pub enum Error {
    +        #[error("The url of submodule '{submodule}' could not be parsed")]
    +        Parse {
    +            submodule: BString,
    +            source: gix_url::parse::Error,
    +        },
    +        #[error("The submodule '{submodule}' was missing its 'url' field or it was empty")]
    +        Missing { submodule: BString },
    +    }
    +}
    +
    +///
    +pub mod path {
    +    use bstr::BString;
    +
    +    /// The error returned by [File::path()](crate::File::path).
    +    #[derive(Debug, thiserror::Error)]
    +    #[allow(missing_docs)]
    +    pub enum Error {
    +        #[error("The path '{actual}' of submodule '{submodule}' needs to be relative")]
    +        Absolute { actual: BString, submodule: BString },
    +        #[error("The submodule '{submodule}' was missing its 'path' field or it was empty")]
    +        Missing { submodule: BString },
    +        #[error("The path '{actual}' would lead outside of the repository worktree")]
    +        OutsideOfWorktree { actual: BString, submodule: BString },
    +    }
    +}
    
  • gix-submodule/src/lib.rs+122 0 modified
    @@ -1,2 +1,124 @@
    +#![allow(missing_docs)]
     #![deny(rust_2018_idioms)]
     #![forbid(unsafe_code)]
    +
    +use bstr::BStr;
    +use std::borrow::Cow;
    +use std::collections::BTreeMap;
    +
    +/// All relevant information about a git module, typically from `.gitmodules` files.
    +///
    +/// Note that overrides from other configuration might be relevant, which is why this type
    +/// can be used to take these into consideration when presented with other configuration
    +/// from the superproject.
    +#[derive(Clone)]
    +pub struct File {
    +    config: gix_config::File<'static>,
    +}
    +
    +mod access;
    +
    +///
    +pub mod config;
    +
    +/// Mutation
    +impl File {
    +    /// This can be used to let `config` override some values we know about submodules, namely…
    +    ///
    +    /// * `url`
    +    /// * `fetchRecurseSubmodules`
    +    /// * `ignore`
    +    /// * `update`
    +    /// * `branch`
    +    ///
    +    /// These values aren't validated yet, which will happen upon query.
    +    pub fn append_submodule_overrides(&mut self, config: &gix_config::File<'_>) -> &mut Self {
    +        let mut values = BTreeMap::<_, Vec<_>>::new();
    +        for (module_name, section) in config
    +            .sections_by_name("submodule")
    +            .into_iter()
    +            .flatten()
    +            .filter_map(|s| s.header().subsection_name().map(|n| (n, s)))
    +        {
    +            for field in ["url", "fetchRecurseSubmodules", "ignore", "update", "branch"] {
    +                if let Some(value) = section.value(field) {
    +                    values.entry((module_name, field)).or_default().push(value);
    +                }
    +            }
    +        }
    +
    +        let values = {
    +            let mut v: Vec<_> = values.into_iter().collect();
    +            v.sort_by_key(|a| a.0 .0);
    +            v
    +        };
    +
    +        let mut config_to_append = gix_config::File::new(config.meta_owned());
    +        let mut prev_name = None;
    +        let mut section = None;
    +        for ((module_name, field), values) in values {
    +            if prev_name.map_or(true, |pn: &BStr| pn != module_name) {
    +                section.take();
    +                section = Some(
    +                    config_to_append
    +                        .new_section("submodule", Cow::Owned(module_name.to_owned()))
    +                        .expect("all names come from valid configuration, so remain valid"),
    +                );
    +                prev_name = Some(module_name);
    +            }
    +            let section = section.as_mut().expect("always set at this point");
    +            section.push(
    +                field.try_into().expect("statically known key"),
    +                Some(values.last().expect("at least one value or we wouldn't be here")),
    +            );
    +        }
    +
    +        self.config.append(config_to_append);
    +        self
    +    }
    +}
    +
    +///
    +mod init {
    +    use crate::File;
    +    use std::path::PathBuf;
    +
    +    impl std::fmt::Debug for File {
    +        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    +            f.debug_struct("File")
    +                .field("config_path", &self.config_path())
    +                .field("config", &format_args!("r#\"{}\"#", self.config))
    +                .finish()
    +        }
    +    }
    +
    +    /// Lifecycle
    +    impl File {
    +        /// Parse `bytes` as git configuration, typically from `.gitmodules`, without doing any further validation.
    +        /// `path` can be provided to keep track of where the file was read from in the underlying [`config`](Self::config())
    +        /// instance.
    +        ///
    +        /// Future access to the module information is lazy and configuration errors are exposed there on a per-value basis.
    +        ///
    +        /// ### Security Considerations
    +        ///
    +        /// The information itself should be used with care as it can direct the caller to fetch from remotes. It is, however,
    +        /// on the caller to assure the input data can be trusted.
    +        pub fn from_bytes(bytes: &[u8], path: impl Into<Option<PathBuf>>) -> Result<Self, gix_config::parse::Error> {
    +            let metadata = path.into().map_or_else(gix_config::file::Metadata::api, |path| {
    +                gix_config::file::Metadata::from(gix_config::Source::Worktree).at(path)
    +            });
    +            let config = gix_config::File::from_parse_events_no_includes(
    +                gix_config::parse::Events::from_bytes_owned(bytes, None)?,
    +                metadata,
    +            );
    +
    +            Ok(Self { config })
    +        }
    +
    +        /// Turn ourselves into the underlying parsed configuration file.
    +        pub fn into_config(self) -> gix_config::File<'static> {
    +            self.config
    +        }
    +    }
    +}
    
  • gix-submodule/tests/file/baseline.rs+77 0 added
    @@ -0,0 +1,77 @@
    +use bstr::ByteSlice;
    +use gix_features::fs::walkdir::Parallelism;
    +use std::ffi::OsStr;
    +use std::path::PathBuf;
    +
    +#[test]
    +fn common_values_and_names_by_path() -> crate::Result {
    +    let modules = module_files()
    +        .map(|(path, stripped)| gix_submodule::File::from_bytes(&std::fs::read(path).unwrap(), stripped))
    +        .collect::<Result<Vec<_>, _>>()?;
    +
    +    assert_eq!(
    +        modules
    +            .iter()
    +            .map(|m| m.config_path().expect("present").to_owned())
    +            .collect::<Vec<_>>(),
    +        [
    +            "empty-clone/.gitmodules",
    +            "multiple/.gitmodules",
    +            "recursive-clone/.gitmodules",
    +            "recursive-clone/submodule/.gitmodules",
    +            "relative-clone/.gitmodules",
    +            "relative-clone/submodule/.gitmodules",
    +            "super/.gitmodules",
    +            "super/submodule/.gitmodules",
    +            "super-clone/.gitmodules",
    +            "super-clone/submodule/.gitmodules",
    +            "top-only-clone/.gitmodules"
    +        ]
    +        .into_iter()
    +        .map(PathBuf::from)
    +        .collect::<Vec<_>>(),
    +        "config_path() yields the path provided when instantiating (for .gitmodules), and not the path of a submodule."
    +    );
    +
    +    assert_eq!(
    +        {
    +            let mut v = modules.iter().flat_map(gix_submodule::File::names).collect::<Vec<_>>();
    +            v.sort();
    +            v.dedup();
    +            v
    +        },
    +        [".a/..c", "a/b", "a/d\\", "a\\e", "submodule"]
    +            .into_iter()
    +            .map(|n| n.as_bytes().as_bstr())
    +            .collect::<Vec<_>>(),
    +        "names can be iterated"
    +    );
    +
    +    for module in &modules {
    +        for name in module.names() {
    +            let path = module.path(name)?;
    +            assert_eq!(module.name_by_path(path.as_ref()).expect("found"), name);
    +        }
    +    }
    +    Ok(())
    +}
    +
    +fn module_files() -> impl Iterator<Item = (PathBuf, PathBuf)> {
    +    let dir = gix_testtools::scripted_fixture_read_only("basic.sh").expect("valid fixture");
    +    gix_features::fs::walkdir_sorted_new(&dir, Parallelism::Serial)
    +        .follow_links(false)
    +        .into_iter()
    +        .filter_map(move |entry| {
    +            let entry = entry.unwrap();
    +            (entry.file_name() == OsStr::new(".gitmodules")).then(|| {
    +                (
    +                    entry.path().to_owned(),
    +                    entry
    +                        .path()
    +                        .strip_prefix(&dir)
    +                        .expect("can only provide sub-dirs")
    +                        .to_owned(),
    +                )
    +            })
    +        })
    +}
    
  • gix-submodule/tests/file/mod.rs+276 0 added
    @@ -0,0 +1,276 @@
    +fn submodule(bytes: &str) -> gix_submodule::File {
    +    gix_submodule::File::from_bytes(bytes.as_bytes(), None).expect("valid module")
    +}
    +
    +mod path {
    +    use crate::file::submodule;
    +    use gix_submodule::config::path::Error;
    +
    +    fn submodule_path(value: &str) -> Error {
    +        let module = submodule(&format!("[submodule.a]\npath = {value}"));
    +        module.path("a".into()).unwrap_err()
    +    }
    +
    +    #[test]
    +    fn valid() -> crate::Result {
    +        let module = submodule("[submodule.a]\n path = relative/path/submodule");
    +        assert_eq!(module.path("a".into())?.as_ref(), "relative/path/submodule");
    +        Ok(())
    +    }
    +
    +    #[test]
    +    fn validate_upon_retrieval() {
    +        assert!(matches!(
    +            submodule_path(if cfg!(windows) {
    +                "c:\\\\hello"
    +            } else {
    +                "/definitely/absolute\\\\"
    +            }),
    +            Error::Absolute { .. }
    +        ));
    +        assert!(matches!(submodule_path(""), Error::Missing { .. }));
    +        assert!(matches!(submodule_path("../attack"), Error::OutsideOfWorktree { .. }));
    +
    +        {
    +            let module = submodule("[submodule.a]\n path");
    +            assert!(matches!(module.path("a".into()).unwrap_err(), Error::Missing { .. }));
    +        }
    +
    +        {
    +            let module = submodule("[submodule.a]\n");
    +            assert!(matches!(module.path("a".into()).unwrap_err(), Error::Missing { .. }));
    +        }
    +    }
    +}
    +
    +mod url {
    +    use crate::file::submodule;
    +    use gix_submodule::config::url::Error;
    +
    +    fn submodule_url(value: &str) -> Error {
    +        let module = submodule(&format!("[submodule.a]\nurl = {value}"));
    +        module.url("a".into()).unwrap_err()
    +    }
    +
    +    #[test]
    +    fn valid() -> crate::Result {
    +        let module = submodule("[submodule.a]\n url = path-to-repo");
    +        assert_eq!(module.url("a".into())?.to_bstring(), "path-to-repo");
    +        Ok(())
    +    }
    +
    +    #[test]
    +    fn validate_upon_retrieval() {
    +        assert!(matches!(submodule_url(""), Error::Missing { .. }));
    +        {
    +            let module = submodule("[submodule.a]\n url");
    +            assert!(matches!(module.url("a".into()).unwrap_err(), Error::Missing { .. }));
    +        }
    +
    +        {
    +            let module = submodule("[submodule.a]\n");
    +            assert!(matches!(module.url("a".into()).unwrap_err(), Error::Missing { .. }));
    +        }
    +
    +        assert!(matches!(submodule_url("file://"), Error::Parse { .. }));
    +    }
    +}
    +
    +mod update {
    +    use crate::file::submodule;
    +    use gix_submodule::config::update::Error;
    +    use gix_submodule::config::Update;
    +    use std::str::FromStr;
    +
    +    fn submodule_update(value: &str) -> Error {
    +        let module = submodule(&format!("[submodule.a]\nupdate = {value}"));
    +        module.update("a".into()).unwrap_err()
    +    }
    +
    +    #[test]
    +    fn default() {
    +        assert_eq!(Update::default(), Update::Checkout, "as defined in the docs");
    +    }
    +
    +    #[test]
    +    fn valid() -> crate::Result {
    +        for (valid, expected) in [
    +            ("checkout", Update::Checkout),
    +            ("rebase", Update::Rebase),
    +            ("merge", Update::Merge),
    +            ("none", Update::None),
    +        ] {
    +            let module = submodule(&format!("[submodule.a]\n update = {valid}"));
    +            assert_eq!(module.update("a".into())?.expect("present"), expected);
    +        }
    +        Ok(())
    +    }
    +
    +    #[test]
    +    fn valid_in_overrides() -> crate::Result {
    +        let mut module = submodule("[submodule.a]\n update = merge");
    +        let repo_config = gix_config::File::from_str("[submodule.a]\n update = !dangerous")?;
    +        module.append_submodule_overrides(&repo_config);
    +
    +        assert_eq!(
    +            module.update("a".into())?.expect("present"),
    +            Update::Command("dangerous".into()),
    +            "overridden values are picked up and make commands possible - these are local"
    +        );
    +        Ok(())
    +    }
    +
    +    #[test]
    +    fn validate_upon_retrieval() {
    +        assert!(matches!(submodule_update(""), Error::Invalid { .. }));
    +        assert!(matches!(submodule_update("bogus"), Error::Invalid { .. }));
    +        assert!(
    +            matches!(
    +                submodule_update("!dangerous"),
    +                Error::CommandForbiddenInModulesConfiguration { .. }
    +            ),
    +            "forbidden unless it's an override"
    +        );
    +    }
    +}
    +
    +mod fetch_recurse {
    +    use crate::file::submodule;
    +    use gix_submodule::config::FetchRecurse;
    +
    +    #[test]
    +    fn default() {
    +        assert_eq!(
    +            FetchRecurse::default(),
    +            FetchRecurse::OnDemand,
    +            "as defined in git codebase actually"
    +        );
    +    }
    +
    +    #[test]
    +    fn valid() -> crate::Result {
    +        for (valid, expected) in [
    +            ("yes", FetchRecurse::Always),
    +            ("true", FetchRecurse::Always),
    +            ("", FetchRecurse::Never),
    +            ("no", FetchRecurse::Never),
    +            ("false", FetchRecurse::Never),
    +            ("on-demand", FetchRecurse::OnDemand),
    +        ] {
    +            let module = submodule(&format!("[submodule.a]\n fetchRecurseSubmodules = {valid}"));
    +            assert_eq!(module.fetch_recurse("a".into())?.expect("present"), expected);
    +        }
    +        let module = submodule("[submodule.a]\n fetchRecurseSubmodules");
    +        assert_eq!(
    +            module.fetch_recurse("a".into())?.expect("present"),
    +            FetchRecurse::Always,
    +            "no value means true, which means to always recurse"
    +        );
    +        Ok(())
    +    }
    +
    +    #[test]
    +    fn validate_upon_retrieval() -> crate::Result {
    +        for invalid in ["foo", "ney", "On-demand"] {
    +            let module = submodule(&format!("[submodule.a]\n fetchRecurseSubmodules = \"{invalid}\""));
    +            assert!(module.fetch_recurse("a".into()).is_err());
    +        }
    +        Ok(())
    +    }
    +}
    +
    +mod ignore {
    +    use crate::file::submodule;
    +    use gix_submodule::config::Ignore;
    +
    +    #[test]
    +    fn default() {
    +        assert_eq!(Ignore::default(), Ignore::None, "as defined in the docs");
    +    }
    +
    +    #[test]
    +    fn valid() -> crate::Result {
    +        for (valid, expected) in [
    +            ("all", Ignore::All),
    +            ("dirty", Ignore::Dirty),
    +            ("untracked", Ignore::Untracked),
    +            ("none", Ignore::None),
    +        ] {
    +            let module = submodule(&format!("[submodule.a]\n ignore = {valid}"));
    +            assert_eq!(module.ignore("a".into())?.expect("present"), expected);
    +        }
    +        let module = submodule("[submodule.a]\n ignore");
    +        assert!(
    +            module.ignore("a".into())?.is_none(),
    +            "no value is interpreted as non-existing string, hence the caller will see None"
    +        );
    +        Ok(())
    +    }
    +
    +    #[test]
    +    fn validate_upon_retrieval() -> crate::Result {
    +        for invalid in ["All", ""] {
    +            let module = submodule(&format!("[submodule.a]\n ignore = \"{invalid}\""));
    +            assert!(module.ignore("a".into()).is_err());
    +        }
    +        Ok(())
    +    }
    +}
    +
    +mod branch {
    +    use crate::file::submodule;
    +    use gix_submodule::config::Branch;
    +
    +    #[test]
    +    fn valid() -> crate::Result {
    +        for (valid, expected) in [
    +            (".", Branch::CurrentInSuperproject),
    +            ("", Branch::Name("HEAD".into())),
    +            ("master", Branch::Name("master".into())),
    +            ("feature/a", Branch::Name("feature/a".into())),
    +        ] {
    +            let module = submodule(&format!("[submodule.a]\n branch = {valid}"));
    +            assert_eq!(module.branch("a".into())?.expect("present"), expected);
    +        }
    +        let module = submodule("[submodule.a]\n branch");
    +        assert!(
    +            module.branch("a".into())?.is_none(),
    +            "no value implies it's not set, but the caller will then default"
    +        );
    +        Ok(())
    +    }
    +
    +    #[test]
    +    fn validate_upon_retrieval() -> crate::Result {
    +        let module = submodule("[submodule.a]\n branch = /invalid");
    +        assert!(module.branch("a".into()).is_err());
    +        Ok(())
    +    }
    +}
    +
    +#[test]
    +fn shallow() -> crate::Result {
    +    let module = submodule("[submodule.a]\n shallow");
    +    assert_eq!(
    +        module.shallow("a".into())?,
    +        Some(true),
    +        "shallow is a simple boolean without anything special (yet)"
    +    );
    +    Ok(())
    +}
    +
    +mod append_submodule_overrides {
    +    use crate::file::submodule;
    +    use std::str::FromStr;
    +
    +    #[test]
    +    fn last_of_multiple_values_wins() -> crate::Result {
    +        let mut module = submodule("[submodule.a] url = from-module");
    +        let repo_config =
    +            gix_config::File::from_str("[submodule.a]\n url = a\n url = b\n ignore = x\n [submodule.a]\n url = c\n[submodule.b] url = not-relevant")?;
    +        module.append_submodule_overrides(&repo_config);
    +        Ok(())
    +    }
    +}
    +
    +mod baseline;
    
  • gix-submodule/tests/fixtures/basic.sh+39 0 added
    @@ -0,0 +1,39 @@
    +#!/bin/bash
    +set -eu -o pipefail
    +
    +set -x
    +git init
    +touch empty && git add empty
    +git commit -m upstream
    +git clone . super 
    +git clone super multiple
    +(cd multiple
    +  git submodule add ../multiple submodule
    +  git submodule add ../multiple a/b
    +  git submodule add --name .a/..c ../multiple a\\c
    +  git submodule add --name a/d\\ ../multiple a/d\\
    +  git submodule add --name a\\e ../multiple a/e/
    +  git commit -m "subsubmodule-a"
    +)
    +
    +(cd super
    +  git submodule add ../multiple submodule
    +  git commit -m "submodule"
    +) 
    +git clone super super-clone 
    +(cd super-clone
    +  git submodule update --init --recursive
    +) 
    +git clone super empty-clone 
    +(cd empty-clone
    +  git submodule init
    +) 
    +git clone super top-only-clone 
    +git clone super relative-clone 
    +(cd relative-clone
    +  git submodule update --init --recursive
    +) 
    +git clone super recursive-clone 
    +(cd recursive-clone
    +  git submodule update --init --recursive
    +)
    
  • gix-submodule/tests/fixtures/generated-archives/basic.tar.xz+3 0 added
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:c961fe67eb7af352064aeae412cabf8b8260782db003ddea7ca251c491c4963e
    +size 31404
    
  • gix-submodule/tests/submodule.rs+3 0 added
    @@ -0,0 +1,3 @@
    +use gix_testtools::Result;
    +
    +mod file;
    
  • src/plumbing/progress.rs+17 1 modified
    @@ -210,7 +210,23 @@ static GIT_CONFIG: &[Record] = &[
         },
         Record {
             config: "submodule.recurse",
    -        usage: Planned {note: Some("very relevant for doing the right thing during checkouts")},
    +        usage: Planned {note: Some("very relevant for doing the right thing during checkouts. Note that 'clone' isnt' affected by it, even though we could make it so for good measure.")},
    +    },
    +    Record {
    +        config: "submodule.propagateBranches",
    +        usage: NotPlanned {reason: "it is experimental, let's see how it pans out"}
    +    },
    +    Record {
    +        config: "submodule.alternateLocation",
    +        usage: NotPlanned {reason: "not currently supported when we clone either"}
    +    },
    +    Record {
    +        config: "submodule.alternateErrorStrategy",
    +        usage: NotPlanned {reason: "not currently supported when we clone either"}
    +    },
    +    Record {
    +        config: "submodule.fetchJobs",
    +        usage: Planned {note: Some("relevant for fetching")},
         },
         Record {
             config: "branch.autoSetupRebase",
    

Vulnerability mechanics

Root cause

"Mismatch between value lookup and trust check: `File::update()` reads the `update` value from whichever section provides it (falling through to `.gitmodules`), but the `CommandForbiddenInModulesConfiguration` guard only checks whether *any* section with the same submodule name exists from a trusted source, not whether the actual value came from that trusted source."

Attack vector

An attacker first ships a benign `.gitmodules` (no `update` key) in a repository. The victim clones and runs `git submodule init`, which copies only `url` (and `active`) into `.git/config` — it does not copy `update` [ref_id=1]. The attacker then pushes a new commit that adds `update = !command` to `.gitmodules`. When the victim pulls, `.gitmodules` contains the malicious command while `.git/config` has a section for the same submodule name but without an `update` key. Because `append_submodule_overrides` creates an override section with foreign (non-`.gitmodules`) metadata, the existence check at [B] passes, but the value lookup at [A] falls through to the attacker-controlled `.gitmodules` and returns the shell command [ref_id=1]. Calling `Submodule::update()` on this state returns `Ok(Some(Update::Command("..."))` instead of the expected `Err(CommandForbiddenInModulesConfiguration)`, enabling arbitrary command execution [ref_id=1].

Affected code

The vulnerable method is `gix_submodule::File::update()` in `gix-submodule/src/access.rs` (lines 168–193). The function reads the `update` field via `self.config.string()` which iterates sections newest-to-oldest, falling through to the `.gitmodules` source if the override section lacks an `update` key. The guard at line 185 checks only whether *any* section with the same submodule name exists from a non-`.gitmodules` source, not whether the actual `update` value came from that trusted source [ref_id=1]. The code was introduced in commit `6a2e6a4` [patch_id=2562550].

What the fix does

The patch [patch_id=2562550] introduces a new `.gitmodule` file abstraction in `gix-submodule/src/config.rs` and `gix-submodule/src/access.rs` with typed accessors for all submodule fields. The `update()` method now validates that `Update::Command` values are only accepted when the value originates from a section with foreign (non-`.gitmodules`) metadata — it checks whether the specific section that provided the `update` value is from an override source, not merely whether any section for that submodule name exists. The test `valid_in_overrides` confirms that `!dangerous` commands are accepted from overrides, while `validate_upon_retrieval` confirms that `!dangerous` in `.gitmodules` alone returns `Err(CommandForbiddenInModulesConfiguration)` [patch_id=2562550]. The advisory [ref_id=1] notes that the fix closes the mismatch between the value lookup and the trust check.

Preconditions

  • configVictim must have cloned the repository and run git submodule init (or equivalent) to create a submodule section in .git/config
  • inputAttacker must be able to push a commit that modifies .gitmodules to add update = !command
  • inputVictim must pull the malicious commit (or otherwise update .gitmodules to the attacker's version)
  • configDownstream code must call Submodule::update() and trust that Update::Command is safe to execute

Reproduction

## Reproduction

The advisory [ref_id=1] provides two reproduction methods.

### Option 1: Unit test

Drop into `gix-submodule/tests/file/mod.rs` inside `mod update`:

```rust #[test] fn security_bypass_via_partial_override() { use std::str::FromStr;

// Attacker-controlled .gitmodules let gitmodules = "[submodule.a]\n url = https://example.com/a\n update = !touch /tmp/pwned";

// Post-`git submodule init` state: only `url` copied to .git/config let repo_config = gix_config::File::from_str("[submodule.a]\n url = https://example.com/a").unwrap();

let module = gix_submodule::File::from_bytes(gitmodules.as_bytes(), None, &repo_config).unwrap();

let result = module.update("a".into()); // VULNERABLE: prints `Ok(Some(Command("touch /tmp/pwned")))` // SECURE: should be `Err(CommandForbiddenInModulesConfiguration { .. })` eprintln!("{:?}", result); } ```

Run with `cargo test -p gix-submodule security_bypass -- --nocapture`.

### Option 2: End-to-end shell script

```bash #!/bin/bash set -e cd /tmp rm -rf evil-repo victim sub-origin 2>/dev/null || true

# --- Setup --- mkdir sub-origin && cd sub-origin git init -q && git commit -q --allow-empty -m init cd /tmp

# --- [1] Attacker creates repo with BENIGN submodule --- mkdir evil-repo && cd evil-repo git init -q git -c protocol.file.allow=always submodule add /tmp/sub-origin sub git commit -q -m "add submodule (benign)" cd /tmp

# --- [2] Victim clones and inits --- git -c protocol.file.allow=always clone -q /tmp/evil-repo victim cd victim git submodule init cd /tmp

# --- [3] Attacker adds malicious update to .gitmodules --- cd evil-repo cat >> .gitmodules

Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.