← All posts

A ref named --output - defending a shell-out git API from option injection

How Rezee's git read layer stops a repository ref from becoming a git flag - the real arbitrary-file-write exploit, the two guards in denji's gitdata.go, a four-config attack matrix showing what each layer independently catches, and the one command where --end-of-options provides zero protection.

Jun 26, 2026 · 9 min read · Kash Gohil

Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs. To render a file, a diff, or a blame view, its git service (denji) shells out to the git binary in a bare repo and parses the output. That design is deliberate - git is the world's most correct git implementation, so we don't reimplement it. But it puts user-controlled strings - a branch name, a file path - onto a git command line, and git command lines have a sharp edge: an argument that starts with - is a flag. A ref named --output=/etc/cron.d/x is not a branch. This post is how denji stops that, proven against real git rather than asserted.

The harness (scripts/bench/git-option-injection in our repo) runs hostile inputs against a real repository under four defensive configurations and records, for each, whether the attack was blocked, neutralised, or actually landed its side effect.

What's the actual exploit?

Not theoretical. git log accepts an --output=<file> flag that redirects its output to a file - so if an attacker controls the argument that denji intends as a ref, and nothing intervenes, they get an arbitrary file write. Here it is landing, against a real repo:

$ git -C repo.git log -1 --format=%H '--output=/tmp/PWNED'
exit=0
$ ls -la /tmp/PWNED
-rw-r--r--  1 app  app  41  /tmp/PWNED
$ cat /tmp/PWNED
dcd865705fee1cb42d94623f32a1df8662f8ac43

The "ref" --output=/tmp/PWNED was read as a flag; git happily wrote the commit hash to a path the attacker chose. Point that at a writable config file, a CI hook, or an SSH authorized_keys and the file-write becomes code execution. git log alone also has --open-files-in-pager; other read commands have their own dangerous flags. The class is "user string in argv position," and the value of a fix is that it doesn't depend on enumerating every dangerous flag - which is a game you lose.

What does denji actually do?

Two guards, stated in the package doc of services/denji/internal/gitdata/gitdata.go:

// The functions take user-supplied refs and paths, so they guard against
// git "option injection" - a ref like "--upload-pack=evil" must never be
// treated as a flag. Two defences are used: refs/paths may not begin with
// "-", and "--end-of-options" is passed before any user value so git stops
// looking for flags.

The first guard is four lines and runs before git is invoked at all:

func guardRef(ref string) error {
	if ref == "" || strings.HasPrefix(ref, "-") {
		return ErrInvalidArg
	}
	return nil
}

func guardPath(path string) error {
	if strings.HasPrefix(path, "-") || strings.ContainsRune(path, 0) {
		return ErrInvalidArg
	}
	return nil
}

ErrInvalidArg maps to HTTP 400. (The path guard also rejects embedded NUL, which would truncate the argument at the syscall boundary.) The second guard is positional, on the command itself - note --end-of-options sits between git's own flags and the user's value:

out, err := run(dir, "ls-tree", "--long", "--end-of-options", ref+":"+path)

--end-of-options is a git convention meaning "everything after this is an operand, never a flag." So even a value that is --output=/tmp/PWNED gets read as a (nonexistent) ref. Same attack, same repo, with the guard denji ships:

$ git -C repo.git log -1 --format=%H --end-of-options '--output=/tmp/PWNED'
fatal: option '--output=/tmp/PWNED' must come before non-option arguments
exit=128
$ ls /tmp/PWNED
No such file or directory

Why two guards? Doesn't one suffice?

This is the question worth measuring, because on the surface the dash-reject guard alone catches every leading-dash attack, which makes --end-of-options look redundant. So we tested each layer independently - including the config a careless future contributor would create: --end-of-options present but someone forgot to call guardRef.

Guard x attack: what each defence layer catches Guard x attack: what each defence layer catches no guard dash-reject --end-of-options both (shipped) --output=… LANDED blocked neutralised blocked --output-directory=… ran as flag blocked neutralised blocked --help ran as flag blocked neutralised blocked --all ran as flag blocked neutralised blocked --source ran as flag blocked neutralised blocked

Reading the columns:

  • No guard: the baseline. --output= lands its file write; the others ran as live flags (--all silently changed which commits git read, --help hijacks into the pager, --source corrupts the parser's input). Amber isn't "safe" - it's "the flag executed and this harness just didn't measure that particular effect." Every cell here is a bug.
  • Dash-reject only: blocks all five before git runs. Clean 400, nothing executes. This is the workhorse.
  • --end-of-options only (dash guard forgotten): neutralises all five - git runs, but every flag is forced to be a ref, fails to resolve, and returns a harmless not-found. Nothing lands.
  • Both (shipped): blocked at the door, and belt-and-suspenders behind it.

The measured answer to "why two": they fail differently. The dash guard turns an attack into a 400 before spawning git; --end-of-options turns one into a 404 from git itself. The first is cheaper and clearer, but the second is what saves you when a new function forgets the first - defence in depth isn't a slogan here, it's the --end-of-options-only column being all-neutralised. Neither is redundant, because the harness proves each one holds the line alone.

The one place a guard doesn't work

Here's the detail that makes this more than "sanitize your inputs," and it's flagged directly in the code:

// One exception: `git rev-parse` echoes unrecognised arguments (including
// "--end-of-options") instead of consuming them, so those calls rely on the
// "no leading dash" guard plus --verify rather than --end-of-options.

rev-parse is special: given an argument it doesn't recognise, it prints it back rather than treating it as an operand - so --end-of-options doesn't get consumed as a barrier, it gets echoed like any other token. The harness confirms it: passing --end-of-options to rev-parse does not neutralise a following flag-shaped argument. On that one command, --end-of-options provides zero protection, and the dash-reject guard is load-bearing entirely alone.

So resolveCommit - the function that turns a ref into a SHA - can't lean on the positional guard at all:

func resolveCommit(dir, ref string) (string, error) {
	// Callers must guardRef(ref) first, since rev-parse can't use
	// --end-of-options (see the package comment).
	sha, err := runTrim(dir, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
	...
}

It compensates two ways: callers must guardRef first (the comment is a contract, and it's the reason the dash guard can't be "simplified away" in favour of --end-of-options everywhere), and --verify constrains rev-parse to resolving a single object or failing, rather than acting on flags. The lesson generalises past git: a defence-in-depth layer that works on 90% of your call sites is dangerous precisely because it tempts you to drop the layer that works on 100%. The dash guard is unglamorous and universal; the positional guard is elegant and has an exception. You keep both, and you keep them in that order.

The one thing that must NOT be guarded: blame

Consistency for its own sake is its own bug. git blame takes a rev and a path separated by --, and --end-of-options would swallow that separator - so blame deliberately omits it, and the code says so:

// --end-of-options is intentionally omitted: git blame takes both a rev and
// a path separated by "--", and --end-of-options would swallow the "--"

Blame is still safe, because guardRef and guardPath already rejected both operands' leading dashes before this point. It's a good example of why the two guards aren't interchangeable: the dash guard composes with --; the positional guard doesn't. Applying --end-of-options reflexively "for consistency" would have broken blame while adding no security the dash guard didn't already provide.

How do you reproduce this?

bun scripts/bench/git-option-injection/run.ts

It builds a real bare repo, then runs each hostile input under all four configurations, checking for real side effects (the --output= file write is detected by looking for the file). The dashdash-ref row - a legitimate ref main - is the control: it must run everywhere, confirming the guards reject attacks without rejecting valid refs. The rev-parse asymmetry is its own probe at the end.

What doesn't this prove?

  • The harness measures the file-write effect, not every effect. --all, --help, and --source are marked "ran as flag" under no-guard because they executed; whether each corrupts output, hangs on a pager, or leaks data isn't individually measured. The point is that they ran at all, which the dash guard prevents categorically.
  • It's a specific git version. --end-of-options and rev-parse's echo behaviour are stable git conventions, but "which flags are dangerous" grows with git; the whole reason to guard position rather than blocklist flags is to not depend on that list.
  • argv, not a shell. denji uses exec.Command, which passes arguments directly to git without a shell - so this is purely about git's own flag parsing, not shell metacharacter injection. A system that built a command string and handed it to sh -c would have a second, worse problem this post doesn't cover.
  • Reads only. gitdata.go is entirely read-only. Write paths (push, receive-pack) are a different threat model handled in denji's transport layer, not here.

FAQ

Why not just validate that refs match a safe pattern?

Because git ref names have a genuinely permissive grammar (slashes, dots, unicode, @, ^, ~ in revision expressions like main~3 and HEAD^{commit}), and a regex tight enough to be safe tends to reject valid refs while a regex permissive enough to accept them tends to miss an edge. "Doesn't start with a dash, has no NUL" is a small, auditable rule that composes with git's own resolution - which is a far more reliable validator of what's a real ref than any pattern we'd write. We let git decide validity and only guard the argv edge.

Isn't exec.Command (no shell) already safe from injection?

It's safe from shell injection - no ;, |, $() risk, because there's no shell. It is not safe from option injection, which happens inside git's own argument parser, one level below the shell. This is the distinction that bites people: they correctly avoid sh -c, conclude they're safe, and ship an arbitrary-file-write because a ref reached argv as --output=.

Does the 400-vs-404 difference matter to callers?

Yes, and it's deliberate. A leading-dash value is a client bug or an attack - it's never a real ref - so denji returns 400 (ErrInvalidArg, "you sent something malformed"). A value neutralised by --end-of-options looks to git like a ref that doesn't exist, so it surfaces as 404 (ErrNotFound). The two guards mapping to two status codes is a small honesty: the API tells you whether you sent garbage or asked for something absent.