{"id":"GHSA-q54r-jf2p-rfr2","aliases":[],"url":"https://o3.security/vulnerability/GHSA-q54r-jf2p-rfr2","summary":"gitoxide gix: untrusted .git/config disables filesystem .git-protection path checks via unfiltered protect_options()","details":"## Summary\n\nIn the `gix` crate, `Cache::protect_options()` in `gix/src/config/cache/access.rs` reads the three filesystem path-protection settings (`core.protectHFS`, `core.protectNTFS`, and `gitoxide.core.protectWindows`) with the unfiltered `gix_config::File::boolean()` accessor instead of the trust-aware `boolean_filter()` accessor used by every other security-critical config read in the same file. When a repository is opened with reduced trust (an ownership mismatch, which maps `.git/config` sections to `Trust::Reduced`), these protection settings should not be honored from the untrusted repository-local config. Because the trust filter is skipped, an attacker who controls a repository's `.git/config` on a shared filesystem can set `core.protectNTFS = false` and/or `core.protectHFS = false`, disabling the `.git` path-confusion checks that run during checkout, index creation, merge, and tree editing. On case-insensitive filesystems this lets crafted tree entries resolve to the `.git` directory and overwrite hooks or config.\n\n## Root Cause\n\nRepositories opened under `Trust::Reduced` install a section filter (`is_trusted`) so that security-relevant settings coming from an untrusted repository-local `.git/config` are ignored. The `Cache` struct stores this filter and applies it throughout `gix/src/config/cache/access.rs` for diff drivers, merge drivers, URL rewrites, scheme permissions, lock timeout, and checkout workers. `protect_options()` is the exception: it reads the path-protection booleans through `self.resolved.boolean(...)`, which delegates to `boolean_filter(key, |_| true)` and therefore accepts values from every section regardless of trust. As a result, an untrusted repository-local setting of `core.protectHFS`/`core.protectNTFS`/`gitoxide.core.protectWindows` overrides the safe defaults, defeating the trust boundary for exactly the controls that are meant to prevent `.git` path-confusion attacks.\n\n```text\ngix/src/config/cache/access.rs:300  Cache::protect_options() : vulnerable function\ngix/src/config/cache/access.rs:306  self.resolved.boolean(gitoxide.core.protectWindows)  (unfiltered)\ngix/src/config/cache/access.rs:310  self.resolved.boolean(core.protectHFS)              (unfiltered)\ngix/src/config/cache/access.rs:314  self.resolved.boolean(core.protectNTFS)             (unfiltered)\ngix-config/src/file/access/comfort.rs:89  boolean() delegates to boolean_filter(key, |_| true)\ngix/src/config/mod.rs:61  is_trusted() : the filter that would reject untrusted repo-local sections\ngix-validate/src/path.rs:98  component() : path validation that honors the resulting options\n```\n\n## Impact\n\nAn attacker who owns a repository on a filesystem shared with the victim (a multi-user development host, a shared or network-mounted checkout, or a CI workspace reused across jobs) edits that repository's `.git/config` to disable one or both of `core.protectHFS` and `core.protectNTFS`. When the victim opens the repository with a `gix`-based tool, the ownership mismatch yields `Trust::Reduced`, which should neutralize these untrusted settings, but `protect_options()` honors them anyway. The attacker also commits tree entries whose names are filesystem-specific encodings of `.git` (for example NTFS short names such as `GIT~1`, NTFS alternate-data-stream or trailing-dot/space variants, or HFS+ forms using ignorable code points). When the victim performs a checkout, index build, merge, or tree edit, path validation no longer rejects these names, so on a case-insensitive filesystem (Windows NTFS, macOS HFS+) they collapse to the real `.git` directory. This permits overwriting repository hooks and configuration, which plausibly leads to arbitrary command execution in the victim's context on the next Git operation that runs a hook. The confidentiality, integrity, and availability impact follows from control over the victim's `.git` directory and any resulting hook execution; hook execution was not freshly executed here and is characterized as the plausible consequence of the demonstrated path-protection bypass.\n## Proof of Concept\n\nThe following unit test places the config cache in the exact state produced after a submodule or repository is initialized (only `url` copied to the local config) and shows that the attacker's path-protection override from the repository-local config is honored even though it should be filtered under reduced trust. The same `boolean()` vs `boolean_filter()` discrepancy is directly observable in source.\n\n```rust\n// Demonstrates that core.protectNTFS from an untrusted repository-local\n// section is honored by the unfiltered read in protect_options().\nuse std::str::FromStr;\n\n// Trusted (system/global) config: protection on by default.\n// Untrusted repository-local config (.git/config) disables NTFS protection.\nlet repo_local = gix_config::File::from_str(\"[core]\\n    protectNTFS = false\").unwrap();\n\n// Reading core.protectNTFS with the trust-aware filter would reject the\n// repository-local section; reading it unfiltered (as protect_options does)\n// returns Some(false), disabling the .git-confusion check.\nlet filtered = repo_local.boolean_filter(\"core.protectNTFS\", |_| false); // trusted-only\nlet unfiltered = repo_local.boolean(\"core.protectNTFS\");                  // what protect_options uses\neprintln!(\"filtered (trusted only) = {:?}\", filtered);   // None  -> safe default kept\neprintln!(\"unfiltered (vulnerable) = {:?}\", unfiltered); // Some(Ok(false)) -> protection disabled\n```\n\n```text\nfiltered (trusted only) = None\nunfiltered (vulnerable) = Some(Ok(false))\n```\n\nSource confirmation that `boolean()` performs no trust filtering:\n\n```rust\n// gix-config/src/file/access/comfort.rs:89\npub fn boolean(&self, key: impl AsKey) -> Result<Option<bool>> {\n    self.boolean_filter(key, |_| true)\n}\n```\n\n## Attack Chain\n\n1. **Exposure:** The attacker owns a repository on a filesystem shared with the victim; the victim opens it with a `gix`-based tool, and the ownership mismatch sets the repository's config sections to `Trust::Reduced` (`gix/src/open/options.rs:215` installs the `is_trusted` section filter).\n2. **Control:** The attacker writes `core.protectNTFS = false` (and/or `core.protectHFS = false`) into the repository-local `.git/config`, plus tree entries whose names are filesystem-specific encodings of `.git`.\n3. **Path:** During checkout, index build, merge, or tree edit, each call site invokes `protect_options()` (`gix/src/clone/checkout.rs:103`, `gix/src/repository/index.rs:206`, `gix/src/repository/merge.rs:153` and `:214`, `gix/src/object/tree/editor.rs:24`, `gix/src/config/cache/access.rs:362`).\n4. **Guard:** The `is_trusted` filter (`gix/src/config/mod.rs:61`) would reject the untrusted repository-local section, but `protect_options()` reads via `self.resolved.boolean(...)` (`gix/src/config/cache/access.rs:306,310,314`), which delegates to `boolean_filter(key, |_| true)` and ignores the filter.\n5. **Primitive:** `gix_validate::path::component()` (`gix-validate/src/path.rs:98`) receives `protect_hfs=false` and/or `protect_ntfs=false`, so the HFS+/NTFS `.git`-confusion checks are skipped. When only one protection is disabled, the combined-case fallback at `gix-validate/src/path.rs:137` does not run, and the remaining single-filesystem check does not cover the other filesystem's encodings.\n6. **Result:** Crafted tree entries that encode `.git` pass validation and are written to disk; on a case-insensitive filesystem they resolve into the real `.git` directory, overwriting hooks and configuration and plausibly yielding code execution in the victim's context.\n\n## Bypass Evidence\n\nThe protection under test is the reduced-trust section filter that is supposed to stop untrusted repository-local config from altering security settings. The strongest counter-argument is the case-insensitive `.git` fallback in `gix-validate/src/path.rs:137`, which rejects a plain `.git` match when both HFS+ and NTFS protections are off. That fallback is insufficient for two reasons verified in source: it only runs when BOTH `protect_hfs` and `protect_ntfs` are false (`!(protect_hfs | protect_ntfs)` at line 137), so disabling a single protection leaves it inactive; and even when it runs, its exact `eq_ignore_ascii_case(b\".git\")` test does not cover filesystem-specific encodings such as NTFS short names (`GIT~1`), NTFS trailing-dot/space or alternate-data-stream forms, or HFS+ ignorable-code-point variants, which are precisely what the dedicated `is_dot_hfs`/`is_dot_git_ntfs` checks (now skipped) exist to catch. The `boolean()` accessor was confirmed to apply no trust filtering (`gix-config/src/file/access/comfort.rs:89`), and the sibling reads in the same file consistently apply `filter_config_section`, so the unfiltered read is a genuine deviation rather than an intentional exemption. This issue is distinct from the previously disclosed submodule command-execution bypass (CVE-2026-40034 / GHSA-f26g-jm89-4g65): that advisory's fix changed only the `gix-submodule` crate and `gix/src/repository/submodule.rs` and never touched `protect_options()`, so this code path and its remediation boundary are not covered by it.\n\n## Affected Versions\n\n- **Ecosystem:** `rust`\n- **Package:** `gix` (crates.io)\n- **Confirmed affected range:** `>= 0.88.0, <= 0.89.0`\n- **Latest release checked:** `0.89.0` ([crates.io](https://crates.io/crates/gix/0.89.0))\n- **Fix status:** `0.90.0`\n\n`protect_options()` was introduced in `gix` 0.88.0 and did not exist in earlier releases; before then the path-validation options were not configurable from repository config in this way. The unfiltered read is present from introduction through the latest published release (0.89.0).\n\n## Suggested Fix\n\nRead the path-protection settings through the same trust-aware accessor used by the other security-critical reads in `gix/src/config/cache/access.rs`: replace each `self.resolved.boolean(...)` call in `protect_options()` with `self.resolved.boolean_filter(..., &mut self.filter_config_section.clone())` so that untrusted repository-local sections are excluded under `Trust::Reduced` and the safe defaults are retained. The fix must apply to all three keys (`core.protectHFS`, `core.protectNTFS`, `gitoxide.core.protectWindows`) so that no single protection can be disabled from untrusted config. As an interim mitigation, operators can avoid opening untrusted or other-user-owned repositories with `gix`-based tools on shared or case-insensitive filesystems; this does not remove the underlying bypass.\n\n---\n\nReported by zx.","published":"2026-10-23T20:52:28Z","modified":"2026-10-23T21:00:06.833400057Z","cvss":{"score":7.3,"severity":"HIGH","vector":"CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"crates.io","name":"gix","fixedVersion":"0.90.0"}],"fix":{"url":"https://github.com/GitoxideLabs/gitoxide/pull/3068","label":"GitoxideLabs/gitoxide#3068"},"references":[{"type":"WEB","url":"https://github.com/GitoxideLabs/gitoxide/security/advisories/GHSA-q54r-jf2p-rfr2"},{"type":"WEB","url":"https://github.com/GitoxideLabs/gitoxide/pull/3068"},{"type":"WEB","url":"https://github.com/GitoxideLabs/gitoxide/commit/743957880154aa1726b30b8b5779fd10c78a058c"},{"type":"PACKAGE","url":"https://github.com/GitoxideLabs/gitoxide"},{"type":"WEB","url":"https://github.com/GitoxideLabs/gitoxide/releases/tag/gix-v0.90.0"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-23T21:00:06.833400057Z"}}