PyPI · GitPython
GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)
[include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)git/objects/submodule/base.py, Submodule._config_parser() (SubmoduleConfigParser(fp_module, read_only=read_only); git/config.py, GitConfigParser.__init__ (merge_includes default), GitConfigParser.read()/_included_paths() (include-path resolution, ~lines 630-685), GitConfigParser._read() (MissingSectionHeaderError)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)GitConfigParser.__init__ defaults merge_includes=True: any config file it parses has its [include] (and, when a repo= is supplied, [includeIf ...]) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit 41ecc6a4 ("Disable merge_includes in config writers"), which passes merge_includes=False when Repo.config_writer() builds its parser (git/repo/base.py).
That fix never touched Submodule._config_parser(). This method builds the parser used for every read of a repo's submodule configuration — repo.submodules, Submodule.iter_items(), Submodule.config() — via SubmoduleConfigParser(fp_module, read_only=read_only), passing neither merge_includes=False nor repo=. The True class default is therefore inherited unchanged, and fp_module here is .gitmodules — the single most attacker-controlled config file in the entire codebase, since it ships verbatim as tracked content inside any cloned repository.
GitConfigParser.read()'s include-path resolution (line 662-680) performs no containment check: osp.isabs(include_path) short-circuits the path join entirely for an absolute path, and a relative path is joined with osp.join(osp.dirname(file_path), include_path) / osp.normpath()'d with no check that the result stays under the repository. `is expanded viaosp.expanduser. The only gate before opening is os.access(include_path, os.R_OK)` — a readability check, not a path restriction.
Once opened, GitConfigParser._read() parses the target file as git-config INI. If the first non-blank/non-comment line is not a [section] header — true of virtually any non-gitconfig file (source code, /etc/passwd, .env files, credential files, logs, JSON/YAML) — it raises configparser.MissingSectionHeaderError(fpname, lineno, line). Python's stdlib formats this exception's str() as "File contains no section headers.\nfile: %r, line: %d\n%r" % (fpname, lineno, line) — it embeds the verbatim content of that file's first line in the exception message. Submodule.iter_items() catches only (IOError, BadName), not configparser.Error, so this exception propagates straight out of the ordinary, read-only repo.submodules call.
Parity gap between two config-parser construction sites for the exact same footgun: Repo.config_writer() was hardened against merge_includes in 2023 (41ecc6a4); Submodule._config_parser() — which parses .gitmodules, content that is always attacker-controlled the moment a repository is cloned from an untrusted source — was never given the same treatment. (The submodule write-mode config parser at git/objects/submodule/base.py for .git/modules/<name>/config — a different, locally-generated file — has correctly passed merge_includes=False since 2022, underscoring that the omission for .gitmodules reads looks like an oversight rather than a considered exception.)
.gitmodules contains a legitimate-looking [submodule ...] section plus:[include]
path = /etc/passwd
(an absolute path bypasses any traversal reasoning entirely; a relative ../../../../etc/passwd-style path works too).list(repo.submodules) (or any for sm in repo.submodules) — no update(), init(), or checkout of any kind required.SubmoduleConfigParser (inheriting merge_includes=True) follows the [include] directive, opens /etc/passwd, and GitConfigParser._read() raises MissingSectionHeaderError whose message embeds /etc/passwd's first line verbatim.repo.submodules — disclosing the targeted file's first line to the attacker (directly, or indirectly via any channel that echoes the error).Non-blind local file content disclosure (first line) of any file readable by the victim process, triggered purely by attacker-controlled repository content and one routine, read-only GitPython call. Bounded to one line per triggering file (parsing aborts at the first MissingSectionHeaderError), but that line very often is the secret — .env files (DATABASE_URL=..., API_KEY=...), single-line credential/token files, /etc/passwd's root entry for host fingerprinting. The primitive additionally serves as a generic error-based file-existence oracle for arbitrary host paths. This is materially stronger than the already-fixed, explicitly blind GHSA-cwvm-v4w8-q58c ("Blind local file inclusion", CVSS 4.0, git/refs/symbolic.py ref-name resolution) — that advisory's own writeup states it cannot disclose content; this one does, verbatim, via a different module (git/config.py's include resolution).
.gitmodules is attacker-controlled — the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, "audit this repo" utilities — exactly the class of application GitPython itself is built for).repo.submodules — one of the most ordinary GitPython operations, requiring no submodule update/init/checkout.git/config.py — GitConfigParser.__init__ defaults merge_includes=True.git/objects/submodule/base.py:273 — SubmoduleConfigParser(fp_module, read_only=read_only) passes neither merge_includes nor repo=; git blame shows this call unchanged since the class was introduced, and git show 41ecc6a4 confirms that commit touched only git/repo/base.py's Repo.config_writer(), never this call site.git/config.py _included_paths()/read() (~630-685) — absolute include paths bypass the join/normpath entirely (osp.isabs() short-circuit); no repository-boundary containment check exists anywhere in this path.git/config.py _read() (~493-498) — raises cp.MissingSectionHeaderError(fpname, lineno, line) with the raw file line embedded, matching Python stdlib configparser's own __str__ behavior.Submodule.iter_items() catches only (IOError, BadName) — configparser.Error (the base of MissingSectionHeaderError) is not swallowed.gitpython-003-poc.py, embedded below) reproduces this end-to-end against this exact checkout via the public API only (Repo.clone_from + list(repo.submodules), default arguments, no monkeypatching), against both a throwaway secret file and /etc/passwd.GHSA-hmq2-w58f-27jc? No — that advisory is about the .gitmodules submodule name driving _module_abspath/os.makedirs() (creating a git repository/module directory outside the working tree, a write/RCE-adjacent primitive via a completelIs your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.