Summary
Repo.blame() / Repo.blame_incremental() guard forwarded revision options against unsafe_git_revision_options, but that denylist only contains the file-WRITE options --output/-o. git blame also 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 --output WRITE), 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). The rev string is passed to _option_candidates([rev], kwargs) and placed BEFORE the -- separator (base.py:841). The canonical name of --contents=... is contents, which is not on the denylist, so no UnsafeOptionError is 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
result = repo.blame("--contents=/etc/passwd", "a.txt")
# result rows carry the victim file's line text
Attack Chain
- Entry: app calls
repo.blame(rev, file) with attacker rev="--contents=/etc/passwd" (or kwarg contents="/etc/passwd", or -S).
- Check:
Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options) @ base.py:841. Guard: denylist = ["--output","-o"] only. Bypass proof: canonical name contents ∉ denylist → no error.
- Sink:
self.git.blame(rev, "--", file, p=True, ...). argv (observed): ['git','blame','-p','--contents=<secret>','HEAD','--','a.txt'].
- Impact: blame result rows carry the victim file's line text.
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). -S kwarg 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) to unsafe_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
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