high

CVE-2026-78675

PyPI · GitPython

Summary

GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)

Severity
high
CVSS
8.4
EPSS
0.1% (p2)
CWE
CWE-73, CWE-200
Also known as
GHSA-7833-fr7j-v32q
Published
2026-09-08
Updated
2026-09-08

Advisory details

[HIGH] Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)

Reachability

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 .gitmodulesthe 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.

Root cause

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.)

Exploit path

  1. Attacker crafts a repository whose .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).
  2. Victim performs the extremely common, entirely read-only operation of enumerating a cloned repo's submodules: list(repo.submodules) (or any for sm in repo.submodules) — no update(), init(), or checkout of any kind required.
  3. 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.
  4. This exception surfaces wherever the host application observes exceptions from GitPython — CI logs, error pages, exception trackers, or any dependency-scanner/code-review-bot/hosting-platform tool built on repo.submodules — disclosing the targeted file's first line to the attacker (directly, or indirectly via any channel that echoes the error).

Impact

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).

Preconditions

Evidence

False-positive check (adversarial re-read)

References

Related advisories

Is 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 repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: 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.