{"id":"GHSA-q54r-jf2p-rfr2","kind":"vulnerability","aliases":["GHSA-q54r-jf2p-rfr2"],"related":[],"title":"gitoxide gix: untrusted .git/config disables filesystem .git-protection path checks via unfiltered protect_options()","description":"## 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.","summary":"The gix Rust crate ignores repository trust when reading path-protection settings, so an untrusted .git/config can turn off the checks that reject .git look-alike path names. An attacker who controls a repository on a shared case-insensitive filesystem can use crafted tree entries to overwrite hooks and config in the victim's .git directory, which the advisory says plausibly leads to code execution.","severity":"high","cvss":7.3,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"gix","fixed":["0.90.0"],"affected":">= 0.88.0, <= 0.89.0","ecosystem":"cargo"}],"ecosystems":["cargo"],"tags":["cargo","ci","cwe-349","gix","package","rust","shared-filesystem"],"not_affected_if":["gix-version-0.90.0-or-later","gix-version-below-0.88.0","no-gix-dependency","single-user-machine-only-own-repos"],"how_to_check":"Check whether a project depends on gix: run cargo tree -i gix and look for a version between 0.88.0 and 0.89.0.\nIf gix is used, confirm whether repositories it opens can be owned by another user (shared host, network mount, reused CI workspace).","fix":"Upgrade gix to 0.90.0 or later (cargo update -p gix).\nUntil upgraded, avoid opening untrusted or other-user-owned repositories with gix-based tools on shared or case-insensitive filesystems.","status":"open","published_at":"2026-10-23T20:52:28+00:00","first_seen_at":"2026-10-23T20:59:05+00:00","triaged_at":"2026-10-23T21:00:20+00:00","updated_at":"2026-10-23T21:00:20+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T21:00:20Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade gix to 0.90.0 or later (cargo update -p gix).\nUntil upgraded, avoid opening untrusted or other-user-owned repositories with gix-based tools on shared or case-insensitive filesystems.","reach":"local","impact":"high","summary":"The gix Rust crate ignores repository trust when reading path-protection settings, so an untrusted .git/config can turn off the checks that reject .git look-alike path names. An attacker who controls a repository on a shared case-insensitive filesystem can use crafted tree entries to overwrite hooks and config in the victim's .git directory, which the advisory says plausibly leads to code execution.","category":"package","who_cares":["rust","gix","cargo","shared-filesystem","ci"],"confidence":0.8,"needs_auth":"yes","worst_case":"Repository hooks and config overwritten, possibly leading to code execution.","analyst_note":"","how_to_check":"Check whether a project depends on gix: run cargo tree -i gix and look for a version between 0.88.0 and 0.89.0.\nIf gix is used, confirm whether repositories it opens can be owned by another user (shared host, network mount, reused CI workspace).\nunknown","needs_analyst":false,"not_affected_if":["gix-version-below-0.88.0","gix-version-0.90.0-or-later","no-gix-dependency","single-user-machine-only-own-repos"],"user_interaction":"yes","needs_special_setup":"yes","severity_if_unknown":"high"},"cost_usd":0.000928,"input_tokens":6663,"output_tokens":524,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-349':30B '/space':543C '/src/file/access/comfort.rs':397C '/src/path.rs':423C '300':373C '306':380C '310':385C '314':390C '61':407C '89':398C '98':424C 'accept':328C 'accessor':134C,143C 'across':465C 'advisori':98B 'alik':64B 'also':512C 'altern':536C 'alternate-data-stream':535C 'and/or':211C 'anyway':509C 'appli':287C 'arbitrari':600C 'attack':68B,196C,370C,435C,511C 'avail':621C 'awar':140C 'base':490C 'boolean':133C,141C,316C,322C,399C,402C 'boundari':356C 'build':559C 'cach':110C,281C,374C 'cargo':27B 'case':77B,233C,575C 'case-insensit':76B,232C,574C 'caus':252C 'character':646C 'check':11A,58B,220C 'checkout':224C,303C,459C,557C 'ci':28B,462C 'code':103B,550C 'collaps':583C 'come':270C 'command':601C 'commit':513C 'compon':425C 'confidenti':618C 'config':89B,131C,151C,188C,250C,396C 'configur':595C 'confus':219C,369C 'context':607C 'control':70B,198C,360C,625C 'core.protecthfs':123C,212C,346C,387C,478C 'core.protectntfs':124C,209C,347C,392C,480C 'craft':82B,238C 'crate':40B,109C 'creation':226C 'critic':150C 'cwe':29B 'data':537C 'default':352C 'defeat':353C 'deleg':320C,400C 'develop':451C 'diff':292C 'directori':95B,245C,588C,631C 'disabl':5A,214C,473C 'dot':542C 'driver':293C,295C 'edit':230C,467C,563C 'encod':522C 'entri':84B,240C,515C 'everi':146C,331C 'exact':358C 'exampl':526C 'except':309C 'execut':104B,602C,636C,638C,642C 'fals':210C,213C 'file':132C,156C 'filesystem':6A,36B,79B,118C,206C,235C,442C,520C,577C 'filesystem-specif':519C 'filter':142C,192C,261C,285C,323C,403C,411C 'follow':623C 'form':547C 'fresh':641C 'function':378C 'ghsa':17A,22A 'ghsa-q54r-jf2p-rfr2':16A,21A 'git':8A,61B,94B,216C,244C,366C,524C,532C,587C,611C,630C 'git-protect':7A 'git/config':4A,53B,170C,202C,277C,471C 'gitoxid':1A 'gitoxide.core.protectwindows':126C,348C,382C 'gix':2A,26A,31B,38B,108C,130C,395C,421C,489C 'gix-config':394C 'gix-valid':420C 'gix/src/config/cache/access.rs':114C,290C,372C,379C,384C,389C 'gix/src/config/mod.rs':406C 'hfs':546C,581C 'honor':181C,429C,507C 'hook':87B,248C,593C,616C,635C,637C 'host':452C 'ignor':41B,279C,549C 'impact':433C,622C 'index':225C,558C 'insensit':78B,234C,576C 'instal':258C 'instead':135C 'integr':619C 'jf2p':19A,24A 'job':466C 'key':324C,404C 'lead':101B,598C 'let':237C 'local':187C,276C,343C,418C 'lock':300C 'longer':567C 'look':63B 'look-alik':62B 'maco':580C 'map':169C 'meant':363C 'merg':227C,294C,560C 'mismatch':167C,494C 'mount':458C 'multi':449C 'multi-us':448C 'name':66B,517C,529C,570C 'network':457C 'network-mount':456C 'neutral':500C 'next':610C 'ntfs':527C,534C,579C 'one':474C 'open':161C,254C,484C 'oper':612C 'option':15A,112C,306C,376C,432C,506C 'overrid':349C 'overwrit':86B,247C,591C 'own':437C 'ownership':166C,493C 'packag':32B 'path':10A,47B,65B,120C,218C,314C,368C,426C,564C 'path-confus':217C,367C 'path-protect':46B,119C,313C 'perform':555C 'permiss':299C 'permit':590C 'plausibl':100B,597C 'point':551C 'prevent':365C 'protect':9A,14A,48B,111C,121C,176C,305C,315C,375C,505C 'q54r':18A,23A 'read':45B,115C,152C,311C 'real':586C 'reduc':163C,174C,257C,497C 'regardless':333C 'reject':60B,414C,568C 'relev':268C 'repo':417C 'repo-loc':416C 'repositori':42B,72B,159C,186C,200C,253C,275C,342C,439C,469C,486C,592C 'repository-loc':185C,274C,341C 'resolv':241C 'result':338C,431C,634C 'reus':464C 'rewrit':297C 'rfr2':20A,25A 'root':251C 'run':222C,614C 'rust':33B,39B 'safe':351C 'say':99B 'scheme':298C 'section':171C,260C,332C,419C 'secur':149C,267C 'security-crit':148C 'security-relev':266C 'self.resolved.boolean':318C,381C,386C,391C 'set':49B,122C,177C,208C,269C,344C,503C 'share':35B,75B,205C,443C,454C 'shared-filesystem':34B 'short':528C 'skip':194C 'specif':521C 'store':283C 'stream':538C 'struct':282C 'summari':105C 'text':371C 'therefor':327C 'three':117C 'throughout':289C 'timeout':301C 'tool':491C 'trail':541C 'trailing-dot':540C 'tree':83B,229C,239C,514C,562C 'true':325C,405C 'trust':43B,139C,164C,173C,191C,256C,263C,335C,355C,409C,496C 'trust-awar':138C 'turn':55B 'unfilt':13A,129C,383C,388C,393C 'untrust':3A,52B,184C,273C,340C,415C,502C 'url':296C 'use':81B,144C,548C 'user':450C 'valid':422C,427C,565C 'valu':329C 'variant':544C 'via':12A 'victim':92B,446C,483C,554C,605C,628C 'vulner':377C 'whose':516C 'window':578C 'worker':304C 'workspac':463C 'would':413C 'yield':495C '~1':533C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-23T20:52:28+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-q54r-jf2p-rfr2","url":"https://github.com/advisories/GHSA-q54r-jf2p-rfr2","severity":"high","published_at":"2026-10-23T20:52:28+00:00","modified_at":"2026-10-23T20:52:29+00:00","first_seen_at":"2026-10-23T20:59:05+00:00","fetched_at":"2026-10-23T20:59:05+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}