chore(deps): update dependency gitpython to v3.1.59 [security] - #5644
Closed
renovate[bot] wants to merge 1 commit into
Closed
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
Contributor
Author
Renovate Ignore NotificationBecause you closed this PR without merging, Renovate will ignore this update ( If you accidentally closed this PR, or if you changed your mind: rename this PR to get a fresh replacement PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR contains the following updates:
==3.1.58→==3.1.59GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination
CVE-2026-78677 / GHSA-8mcc-hrx5-hvxc
More information
Details
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs.9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
Repo.clone_from(url, to_path, **kwargs)(andRepo.clone()) forward arbitrary keyword arguments to the underlyinggit cloneinvocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist,Repo.unsafe_git_clone_options, viaGit.check_unsafe_options()— unless the caller passesallow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template,--upload-pack,--config,--exec,--output,--index-output,--pathspec-from-file, etc.).git clonealso accepts--separate-git-dir=<path>, which redirects the repository's entire.gitmetadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code:Repo.unsafe_git_init_options(line 145-150) blocks--separate-git-dirforRepo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". TheRepo._clone()/clone()/clone_from()docstring (line 1450-1452) is even more explicit:i.e. the maintainers' own documentation states that
allow_unsafe_options=False(the default) is supposed to block--separate-git-dirfor clone. ButRepo.unsafe_git_clone_optionsdoes not contain it:So any application that forwards a
separate_git_dir(orseparate-git-dir) kwarg intoRepo.clone_from()/Repo.clone()— e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling--template/--upload-pack/--configentries in this same list — gets no protection at all for--separate-git-dir, even with the defaultallow_unsafe_options=False.Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage):
unsafe_git_init_optionscorrectly lists--separate-git-dir;unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible forGHSA-539m-9xh6-q6rr(archivedenylist missing--add-file/--add-virtual-file) andGHSA-6p8h-3wgx-97gf(clonedenylist missing--template, since fixed).Exploit path
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse.Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity
GHSA-hmq2-w58f-27jc("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:hooks/directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to..git, a shared cache path, a predictable temp location), the clone silently populates/overwritesconfig,HEAD,hooks/*,refs/*,packed-refs, andindexthere — an integrity violation of a resource outside the intended destination.gitagainst that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for--templateinGHSA-9rj7-rf2p-w77r.Preconditions
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted.Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.False-positive check (adversarial re-read)
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths).--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path._known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap.clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry.Remediation
Add
"--separate-git-dir"(and its-alias if git ever adds one — currently there is none) toRepo.unsafe_git_clone_optionsingit/repo/base.py, matchingunsafe_git_init_options. SinceRepo._clone()already special-casesseparate_git_dirforGit.polish_url()normalization, the fix is a one-line addition to the existing list, consistent with howGHSA-6p8h-3wgx-97gfadded--templateto the same list.Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (
gitpython-001-poc.py)Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
GitPython: Incomplete unsafe_git_revision_options denylist omits --contents/-S, enabling arbitrary file read via Repo.blame()
CVE-2026-78678 / GHSA-5xxx-qhh7-9287
More information
Details
Summary
Repo.blame()/Repo.blame_incremental()guard forwarded revision options againstunsafe_git_revision_options, but that denylist only contains the file-WRITE options--output/-o.git blamealso honors--contents <file>and-S <file>, which cause the file's lines to be echoed into the blame result — an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of--contents=<path>passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame--outputWRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory).Root Cause
unsafe_git_revision_options = ["--output","-o"](git/repo/base.py:188). Therevstring is passed to_option_candidates([rev], kwargs)and placed BEFORE the--separator (base.py:841). The canonical name of--contents=...iscontents, which is not on the denylist, so noUnsafeOptionErroris raised. The trailing--protects only the pathspec, not the option before the revision.Impact
Arbitrary local file read at the privileges of the host process; the file's line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default
allow_unsafe_options=False.Proof of Concept
Attack Chain
repo.blame(rev, file)with attackerrev="--contents=/etc/passwd"(or kwargcontents="/etc/passwd", or-S).Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options)@ base.py:841. Guard: denylist =["--output","-o"]only. Bypass proof: canonical namecontents∉ denylist → no error.self.git.blame(rev, "--", file, p=True, ...). argv (observed):['git','blame','-p','--contents=<secret>','HEAD','--','a.txt'].Bypass Evidence
Independently reproduced (independent test harness, default
allow_unsafe_options=False):blame('--contents=<secret>','a.txt')→ guard PASSED; result rows =['GATE_SECRET_LINE_A','GATE_SECRET_LINE_B']. Control:blame('--output=…')still BLOCKED (guard active on this path).-Skwarg argv also reaches git unguarded.Affected Versions
GitPython <= 3.1.58(denylist present verbatim on the latest release tag).Suggested Fix
Prefer an allowlist of blame options; at minimum add
--contents/-S(and any other path-taking blame options) tounsafe_git_revision_options, and make the membership rule "the option takes a filesystem path" rather than "the option writes output".Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
GitPython: Dormant multi-line git-config values are corrupted into live injected directives (e.g. core.hooksPath) on any unrelated GitConfigParser write, enabling RCE
CVE-2026-78676 / GHSA-284h-m62q-gf8w
More information
Details
git/config.py—GitConfigParser._read()(multi-line value decoding, lines 444-541, esp.string_decode()at line 460 and its call sites at 519/541) andGitConfigParser._write()/write_section()(serialization, lines ~694-712, esp. line 708)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
GitPython added
UNSAFE_CONFIG_CHARS_RE/_value_to_string_safe()/_assure_config_name_safe()guards (commitsc417af46,1ed1b924,a495ccd3, and PR #2176) to reject a Python string containing a raw\r/\n/NUL byte, or syntax-bearing characters, when it is passed as an argument toset(),set_value(),add_value(), oradd_section(). This closed the four config-injection GHSAs above.That guard is applied only on the write-argument surface. It is never consulted for values that entered
GitConfigParser._sectionsvia_read()— i.e. values that came from parsing an on-disk config file. And_read()legitimately supports standard, spec-compliant git config syntax for multi-line values: a quoted value that is not closed on the same physical line continues onto the next physical line (git's own backslash-continuation syntax), andstring_decode()(.decode('unicode_escape')) decodes a literal two-character\nescape sequence inside such a value into a real embedded LF character in the resulting Python string. No raw control byte is ever written to disk to achieve this — it's the same syntax realgititself uses and accepts.The bug is in what happens when that
GitConfigParseris later flushed:write_section()(line ~694) calls the unsafeself._value_to_string(v)— not_value_to_string_safe()— and "handles" any embedded newline in the value with.replace("\n", "\n\t")(line 708), emitting a bare, unquoted<real newline><tab>in the output file with no re-quoting and no backslash-continuation marker. Real git does not treat an indentation-only continuation the way GitPython's writer assumes — a value only continues across physical lines when the previous line ends in a literal\immediately before the newline. So the momentwrite_section()re-serializes a previously-decoded multi-line value this way, the second half of that value becomes an independent, new config line the next time anyone (GitPython or realgit) parses the file. If an attacker chooses the dormant value's content to be<anything>\nhooksPath = <attacker path>, that second line is parsed as a brand-newcore.hooksPath = <attacker path>directive — live, real Git configuration, not a value.core.hooksPathis honored by essentially every hook-firing git operation (commit,checkout,merge,push,rebase, ...), giving arbitrary code execution the next time the host application performs any hook-triggering operation.Root cause
GitConfigParser's injection guard is asymmetric: it hardens every write-argument entry point (the fix for the four sibling GHSAs) but never hardens the read → corrupt-on-rewrite round trip. A value that is 100% legitimate and inert as parsed from disk becomes a newly-injected directive purely through GitPython's own broken re-serialization logic (write_section()using the unsafe value-to-string path plus a continuation scheme real git doesn't recognize). Thec417af46commit message even states its intent explicitly: "This preserves existing read behavior for config files that already contain multiline values while preventing GitPython from writing new unsafe values" — i.e. the maintainers consciously scoped the fix to the write-argument surface and did not address what happens when an already-resident multi-line value gets rewritten.Exploit path
.git/config(or any file merged into it via[include], see below) already contains a dormant, syntactically-legitimate multi-line quoted value, e.g.:\r,\n, or NUL byte appears on disk — this is standard git quoting + backslash-continuation. Realgit config --get core.hookspathreturns nothing at this point (inert);git config --get core.zzzreturns the decoded stringA\nhooksPath = ../evil-hooks, identically to GitPython's own reader.git.Repo(path),read_only=Falseimplicitly for a normalconfig_writer()use) and performs any single, unrelated, legitimate config write on the sameGitConfigParserinstance — e.g.repo.config_writer().set_value("user", "name", "Test User"). This is one of the most ordinary operations a GitPython-based tool performs.GitConfigParser._write()/write_section()re-serializes every resident value, including the dormantzzzentry, using the unsafe path. The file on disk now contains, verbatim:git config --get core.hookspathnow returns../evil-hooks— a key that did not exist before step 2, created purely by GitPython's own write.git commit) executes../evil-hooks/pre-commit(or whatever hook name the operation looks for), i.e. arbitrary attacker-chosen code execution.Impact
Arbitrary code execution, on par with (and more directly triggered than) the already-accepted, High-severity
GHSA-mv93-w799-cj2w/GHSA-v87r-6q3f-2j67"Newline injection... enables RCE via core.hooksPath" advisories, and requiring no unsafe caller argument at all — only an attacker-influenced config file plus one ordinary, unrelated write.Preconditions
<anything>\n<injected-key> = <injected-value>. Realistic delivery:.gitdirectory shipped with a repository — vendored/template repos, CI workspace/layer caches that preserve.git, "repo" tarball/zip distributions that include.git/config. The poisoned value sits directly in.git/config.[include]pattern ([include] path = ../<repo-tracked-file>, pointing at a file inside the working tree) —GitConfigParser.read()merges included files' sections into the same_sectionsdict used for writing, so a malicious public repository can ship the poisoned value inside a normal tracked file and have it activated the first time any GitPython-based tool performs any unrelated config write after clone (this requires the victim's own.git/configto already reference the include, e.g. via project setup tooling that addsinclude.path).GHSA-v87r-6q3f-2j67(their writeup cites MLRun'sproject.push()).Evidence
git/config.py:460(string_decode), invoked atgit/config.py:519and:541inside_read()'s multi-line handling — decodesunicode_escape, turning a literal\nescape into a real embedded LF.git/config.py:~694-712(_write()/write_section()) — usesself._value_to_string(v)(unsafe variant) and.replace("\n", "\n\t")with no re-quoting.c417af46(the CR/LF/NUL guard commit) touches only the setter path and explicitly states it preserves existing read behavior for multi-line values, per its own commit message.git log -S"string_decode",-S"write_section",-S'replace("\n", "\n\t")'ongit/config.pyshow these code paths have only ever been touched by non-security formatting/refactor commits (a5fc1d86,b825dc74,cb68eef0,21ec5299), never by a security fix.gitpython-002-poc.py, embedded below) reproduces the full chain end-to-end against this exact checkout: dormant value → one unrelatedconfig_writer()write →core.hookspathbecomes live per realgit config --get→ a subsequentgit commitexecutes the injected hook and writes a benign marker file.False-positive check (adversarial re-read)
UNSAFE_CONFIG_CHARS_RE/VALID_CONFIG_OPTION_NAME_RE/the section quote-state-machine. This finding requires no such caller argument: the payload is smuggled entirely inside a config file using standard, valid git escaping that the guard never inspects, and only becomes dangerous through GitPython's own unguarded re-serialization of a value it already holds. Confirmed via_known-advisories.json(26 entries, none withdrawn) — none describe this read→corrupt-on-rewrite mechanism..git/configis rewritten by realgit config user.name Test2(a control test), the multi-linezzzentry is preserved byte-for-byte in its original quoted/continuation form — only GitPython's writer corrupts it.hooksPath = ...line before it's trusted? No — once on disk, it is indistinguishable from a directive the user set intentionally;core.hooksPathis honored unconditionally by git's hook-invocation machinery.GHSA-v87r-6q3f-2j67.config_writer()write →core.hookspathlive per real git → hook fires ongit commit, marker file written).Remediation
Either (a) make
write_section()/_write()use_value_to_string_safe()(or equivalent re-quoting) for every resident value, including those that originated from_read(), so an embedded newline is always re-emitted as a properly quoted+backslash-continued value rather than a bare new line, or (b) reject/neutralize embedded control characters in values at read time before they can reach_sectionsat all if the parser is opened inread_only=Falsemode, or (c) canonicalize output using git's owngit config --file <path> --replace-allsemantics instead of a hand-rolled writer. Option (a) is the most surgical fix and matches the spirit of_value_to_string_safe()already used on the setter path.Confidence
High. Root cause independently re-derived and confirmed by direct code reading; full exploit chain (dormant value → benign unrelated write → live
core.hookspath→ hook execution with a benign marker) reproduced twice, independently, against the current HEAD.Proof-of-Concept source (
gitpython-002-poc.py)Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)
CVE-2026-78675 / GHSA-7833-fr7j-v32q
More information
Details
[HIGH] Arbitrary local file content disclosure via
[include]directive in untrusted.gitmodules(SubmoduleConfigParsernever disablesmerge_includes)git/objects/submodule/base.py,Submodule._config_parser()(~line 273) constructingSubmoduleConfigParser(fp_module, read_only=read_only);git/config.py,GitConfigParser.__init__(merge_includesdefault),GitConfigParser.read()/_included_paths()(include-path resolution, ~lines 630-685),GitConfigParser._read()(~line 493-498,MissingSectionHeaderError)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
GitConfigParser.__init__defaultsmerge_includes=True: any config file it parses has its[include](and, when arepo=is supplied,[includeIf ...]) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit41ecc6a4("Disable merge_includes in config writers"), which passesmerge_includes=FalsewhenRepo.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()— viaSubmoduleConfigParser(fp_module, read_only=read_only), passing neithermerge_includes=Falsenorrepo=. TheTrueclass default is therefore inherited unchanged, andfp_modulehere 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 withosp.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 isos.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,.envfiles, credential files, logs, JSON/YAML) — it raisesconfigparser.MissingSectionHeaderError(fpname, lineno, line). Python's stdlib formats this exception'sstr()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), notconfigparser.Error, so this exception propagates straight out of the ordinary, read-onlyrepo.submodulescall.Root cause
Parity gap between two config-parser construction sites for the exact same footgun:
Repo.config_writer()was hardened againstmerge_includesin 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 atgit/objects/submodule/base.pyfor.git/modules/<name>/config— a different, locally-generated file — has correctly passedmerge_includes=Falsesince 2022, underscoring that the omission for.gitmodulesreads looks like an oversight rather than a considered exception.)Exploit path
.gitmodulescontains a legitimate-looking[submodule ...]section plus:../../../../etc/passwd-style path works too).list(repo.submodules)(or anyfor sm in repo.submodules) — noupdate(),init(), or checkout of any kind required.SubmoduleConfigParser(inheritingmerge_includes=True) follows the[include]directive, opens/etc/passwd, andGitConfigParser._read()raisesMissingSectionHeaderErrorwhose 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).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 —.envfiles (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 blindGHSA-cwvm-v4w8-q58c("Blind local file inclusion", CVSS 4.0,git/refs/symbolic.pyref-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
.gitmodulesis 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 submoduleupdate/init/checkout.Evidence
git/config.py—GitConfigParser.__init__defaultsmerge_includes=True.git/objects/submodule/base.py:273—SubmoduleConfigParser(fp_module, read_only=read_only)passes neithermerge_includesnorrepo=;git blameshows this call unchanged since the class was introduced, andgit show 41ecc6a4confirms that commit touched onlygit/repo/base.py'sRepo.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) — raisescp.MissingSectionHeaderError(fpname, lineno, line)with the raw file line embedded, matching Python stdlibconfigparser's own__str__behavior.Submodule.iter_items()catches only(IOError, BadName)—configparser.Error(the base ofMissingSectionHeaderError) 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.False-positive check (adversarial re-read)
GHSA-hmq2-w58f-27jc? No — that advisory is about the.gitmodulessubmodule name driving_module_abspath/os.makedirs()(creating a git repository/module directory outside the working tree, a write/RCE-adjacent primitive via a completely different function). This finding is about the[include]directive in the same file reaching a config-parser read primitive — a different mechanism, different function, different impact class (content disclosure, not directory creation).GHSA-cwvm-v4w8-q58c(blind LFI)? No — that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives ingit/refs/symbolic.py's ref-name resolution feedingRepo.commit/tree/index.diff— an entirely different module and code path. This finding discloses actual file content viagit/config.py's include-directive resolution.clone_from+list(repo.submodules)workflow.Submodule.iter_items()'s exception handling, which catches onlyIOError/BadName;configparser.MissingSectionHeaderErrorpropagates uncaught./etc/passwd.Remediation
Pass
merge_includes=Falsewhen constructingSubmoduleConfigParserinSubmodule._config_parser()(git/objects/submodule/base.py), mirroring the existing fix inRepo.config_writer()(commit41ecc6a4) —.gitmodulescontent is always attacker-controlled and should never be allowed to pull ininclude/includeIfdirectives. As defense in depth,GitConfigParser.read()'s include-path resolution should enforce that resolved include paths stay within the repository's own directory tree, and parsing-error messages (MissingSectionHeaderError/ParsingError) should avoid embedding raw file content when parsing a file the caller did not explicitly ask to open.Confidence
High. Root cause confirmed by direct code reading across both
git/config.pyandgit/objects/submodule/base.py, cross-checked against the fix commit that hardened the sibling code path but not this one; exploit chain reproduced independently, twice, against the current HEAD (a throwaway secret file and/etc/passwd).Proof-of-Concept source (
gitpython-003-poc.py)Severity
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
GitPython: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251)
CVE-2026-78679 / GHSA-3wxw-xv34-2frg
More information
Details
Summary
TagReference.create()forwards a caller-influenced positionalreferencevalue intogit tagwithout it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit3af0c251(the fix for GHSA-3f7w-8rr8-f37f's tag instance).Root Cause
The fix
3af0c251addedunsafe_git_tag_options = ["--file","-F"]and a guard call, but the guard isGit.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...)atgit/refs/tag.py:139— it passes an EMPTY args list and inspects kwargs only. The dangerous valuespathandreferenceare POSITIONALS (args = (path, reference), tag.py:156), placed before any--. A user-influencedreference="--file=<path>"therefore reachesgit tagas the exact--fileoption the fix intended to block, creating an annotated tag whose message is the file's contents.Impact
Arbitrary local file read at the privileges of the host process; contents returned in-band via
tagref.tag.message. Requires the embedding application to forward a caller-influencedreferencevalue intoTagReference.create()(pure VALUE control — the CVE-2026-42215 threat model). Defaultallow_unsafe_options=False.Proof of Concept
Attack Chain
TagReference.create(repo, name, reference=<user>)withreference="--file=/home/app/.ssh/id_rsa".Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"])@ tag.py:137-141. Guard: denylist includes--file/-F. Bypass proof:_option_candidatesreceivesargs=[]→ the positionalreferenceis never a candidate (the kwarg spellingfile="…"IS blocked; only the positional escapes).repo.git.tag(*args, **kwargs)@ tag.py:158 → no--. argv (observed):['git','tag','-f','vpwn','--file=<secret>'].tagref.tag.message== file contents (arbitrary file read).Bypass Evidence
Independently reproduced (independent test harness, git 2.43.0, default
allow_unsafe_options=False):TagReference.create(repo,'vp','--file=<secret>')→ PASSED;tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'. Control:TagReference.create(..., file='<secret>')→UnsafeOptionError: --file is not allowed. Fix-commit read:3af0c251adds_option_candidates([], kwargs)(empty args → positional never a candidate).Affected Versions
GitPython <= 3.1.58(sink present verbatim on the latest release tag;git diff 3.1.57..HEADtouches only test files).Suggested Fix
Include the positional
reference(andpath) in the option-candidate list passed tocheck_unsafe_options, or place a--separator before the positional arguments inTagReference.create().Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences