Closes #4.
Why
server/githttp served anonymous upload-pack with no cap on the request body
(it refused a negative Content-Length and accepted anything else), ran
git http-backend with no request context or deadline, refused only paths that
did not start with git-upload-pack — passing everything after that prefix to
the CGI as PATH_INFO — and server/server.go set ReadHeaderTimeout and
IdleTimeout but no ReadTimeout and no MaxHeaderBytes.
Read-only is not cheap. Every one of those is reachable without an account.
What
The request is bounded before it is read. A POST must carry a known,
non-zero Content-Length under 2 MiB. Fetch negotiation is want/have lines
and a single protocol-v2 round trip, so this is generous by two orders of
magnitude — the cap is only possible because this is the read side; a push could
never be bounded this way.
Only the two endpoints exist. GET info/refs?service=git-upload-pack and
POST git-upload-pack, each with exactly one method. Anything else is a 404
before it reaches the CGI, so PATH_INFO containment no longer depends on git’s
own handling of dot segments. An info/refs request without that exact service
value is 404 too; receive-pack still answers 403 with the SSH hint, so a
stray git push over HTTPS still gets told why.
The backend belongs to the request. exec.CommandContext on a context
derived from r.Context() with a 10-minute budget (git_http_timeout_seconds),
WaitDelay to bound the reap, and an explicit kill when writing the response
fails. Closing the request body when the context ends is what unblocks the
stdin copier: without it, the copier sits in Read on a dead connection and
holds the process open behind it.
Load is bounded and counted. A transport semaphore (16 by default) and a
per-client bound (8), refused as 503 + Retry-After rather than queued — a
queue is one more thing that grows with load. The client address is the
connection’s own unless the operator names a proxy header; last entry wins,
because that is the one the nearest proxy appended. Both are config:
git_http_max_concurrent, git_http_max_per_client, git_http_client_ip_header.
Server-level limits. ReadTimeout: 60s and MaxHeaderBytes: 64 KiB on both
listeners. WriteTimeout stays unset on purpose — a clone, a release tarball, an
LFS object and a site file are streamed, and a wall-clock limit on writes would
truncate legitimate large downloads. Those streams are bounded where they are
produced instead: the git transport by its own deadline, archives by
archiveConc, downloads by the size caps from #3.
An off switch. git_http = false stops registering the /git/ routes
altogether, for an instance whose repositories are all internal. Documented,
including that LFS downloads share those URLs and go with them.
Tests
- oversized body →
413; chunked →411(existing); zero-length →413 - the endpoint/method/service matrix, including
git-upload-pack/../../...,../demo.git/info/refs, dumb-HTTP paths, andreceive-pack→403 - concurrency and per-client bounds →
503withRetry-After, and the slot is returned on refusal - proxy header: ignored when unnamed, last entry used when named
- a client that disconnects mid-fetch returns in ~0.1s instead of waiting for a timeout (fails on the old code, which blocks until the backend exits)
- a fetch that never finishes is stopped on its budget and counted
gofmt, go vet ./..., go test ./..., and go test -race on the touched
packages are clean; the end-to-end anonymous HTTP clone test still passes.