GitHub Security Advisory: GHSA-f26g-jm89-4g65
Package: gix (RUST)
Severity: HIGH (CVSS 7.8)
CWE: CWE-77: Improper Neutralization of Special Elements used in a Command ('Command Injection'), CWE-183: Permissive List of Allowed Inputs, CWE-349: Acceptance of Extraneous Untrusted Data With Trusted Data, CWE-501: Trust Boundary Violation
Vulnerable versions: >= 0.31.0, < 0.83.0
Patched in: 0.83.0

Summary: gitoxide: CommandForbiddenInModulesConfiguration Bypass in gix_submodule::File::update() Enables Arbitrary Command Execution via .gitmodules

Details:
### Summary

[`gix_submodule::File::update()`](https://github.com/GitoxideLabs/gitoxide/blob/main/gix-submodule/src/access.rs#L168) is the API that gates whether an attacker-supplied `.gitmodules` file may set `update = !<shell command>`. The function is designed to return `Err(CommandForbiddenInModulesConfiguration)` unless the `!command` value came from a trusted local source (`.git/config`). Git CVE [CVE-2019-19604](https://nvd.nist.gov/vuln/detail/cve-2019-19604) illustrates why this check is necessary.

However, the guard is implemented incorrectly: it checks whether any section with the same submodule name exists from a non-`.gitmodules` source; it does not verify that the `update` value came from that section.

Once a submodule has been initialized (any workflow that writes `submodule.<name>.url` to `.git/config`), and the attacker subsequently adds `update = !cmd` to `.gitmodules`, the guard passes while the command value falls through to the attacker-controlled file.

On an identical repository state, `git submodule update` aborts with `fatal: invalid value for 'submodule.sub.update'`, while `gix::Submodule::update()` returns `Ok(Some(Update::Command("touch /tmp/pwned")))`.

The vulnerable code was introduced in https://github.com/GitoxideLabs/gitoxide/commit/6a2e6a436f76c8bbf2487f9967413a51356667a0.

### Details

The vulnerable method is `gix_submodule::File::update`: https://github.com/GitoxideLabs/gitoxide/blob/main/gix-submodule/src/access.rs#L168-L193:

```rust
pub fn update(&self, name: &BStr) -> Result<Option<Update>, config::update::Error> {
    let value: Update = match self.config.string(format!("submodule.{name}.update")) {
        //                    ^^^^^^^^^^^^^^^^^^
        //  [A] Reads the value. gix_config::File::string() iterates sections
        //      newest-to-oldest; if the override section lacks `update`, it
        //      falls through to .gitmodules and returns the attacker value.
        //
        // https://github.com/GitoxideLabs/gitoxide/blob/main/gix-config/src/file/access/raw.rs#L76
        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) && !std::ptr::eq(s.meta(), ours));
            //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            //  [B] Checks only that SOME section with this name exists from a
            //      non-.gitmodules source. Does NOT check where [A]'s value
            //      came from.
        if !has_value_from_foreign_section {
            return Err(config::update::Error: