Skip to content

fix(git): allow large smart HTTP pushes (#2880) - #4401

Draft
MajorTal wants to merge 5 commits into
block:mainfrom
MajorTal:codex/issue-2880-large-push-probe
Draft

fix(git): allow large smart HTTP pushes (#2880)#4401
MajorTal wants to merge 5 commits into
block:mainfrom
MajorTal:codex/issue-2880-large-push-probe

Conversation

@MajorTal

@MajorTal MajorTal commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Yo good people, Tal here, human. This (is another) bug that bugged me while dogfooding Buzz, so I went down the Git smart-HTTP rabbit hole and tried to leave behind a very small fix with a very real test. Hope it is in good order :)

What changed

  • Admit only Git's exact unauthenticated four-byte git-receive-pack compatibility probe.
  • Return an empty application/x-git-receive-pack-result response without resolving a repository, hydrating Git state, touching storage, or mutating anything.
  • Keep every near miss on the existing Nostr authentication path.
  • Add a live regression using an incompressible 2 MiB pack that forces Git across http.postBuffer, verifies the four-byte probe and authenticated chunked request, and clones the bytes back exactly.

Root cause

For packs above http.postBuffer, Git first sends an unauthenticated POST /git-receive-pack containing only the flush packet 0000. The relay challenged that probe with 401, so Git aborted before sending the real authenticated chunked pack.

The shim is deliberately narrow and path-independent. It matches only:

  • POST .../git-receive-pack
  • no Authorization, Content-Encoding, or Transfer-Encoding
  • exactly one Content-Type: application/x-git-receive-pack-request
  • exactly one Content-Length: 4
  • exact body 0000

Validation

Fixes #2880

Signed-off-by: Tal Weiss <major.tal@gmail.com>
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d added 4 commits August 2, 2026 20:22
Git sends the same unauthenticated four-byte flush-packet probe before
any smart HTTP POST whose body exceeds http.postBuffer — not just large
pushes. Fetch negotiations cross the 1 MiB decoded threshold when the
client's divergent local history contributes enough have lines (an
in-limit repo plus ~21k unpushed divergent branches suffices; measured
live at MAX_MANIFEST_REFS = 10_000 with divergent client history:
1.46 MiB decoded negotiation, probe fired, fetch got 401).

Parameterize the receive-pack probe middleware over the service's
(request MIME, result MIME) pair — the predicate was already otherwise
service-agnostic — and mount it on the upload-pack POST route as well.
All other predicate legs are unchanged: no Authorization /
Content-Encoding / Transfer-Encoding headers, a single exact
Content-Length of 4, and an exact 0000 body; near misses fall through
to the normal authenticated path.

Tests: the existing exactness/path-independence matrix now runs against
both services (2 path shapes + 11 near-misses each), plus a
cross-service case proving each route rejects the other service's
request MIME.

Fixes block#4423

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
The relay previously bounded request *bytes* (RequestBodyLimitLayer)
but never request *time*: a client that sent valid headers and then
withheld the body parked a relay task indefinitely (measured live with
200 parked sockets). Three narrow deadlines close the measured seam:

- API router: 60s tower_http TimeoutLayer (408 on expiry via
  with_status_code — tower-http's layer returns the configured status
  directly rather than surfacing Elapsed), ordered outside the body
  limit so a stalled body is cancelled by the deadline instead of
  sitting inside body-limit middleware. WebSocket routes are handshake
  bounded only; the established session escapes the response future
  after the 101 upgrade (proven by test).
- Media router: 300s with the same ordering and semantics — bounds the
  future until response headers, not streaming response bodies.
- Git compatibility probe: 60s bound on the pre-auth four-byte body
  collection, failing closed into the normal authentication path. No
  outer deadline is added around authenticated Git/CAS execution:
  PACK_OPS_TIMEOUT (300s) is not a total request budget — finalize_push
  and CAS publication carry additional deadlines, and an outer layer
  could cancel a valid push during publication. Git-wide budgeting and
  hyper header-read deadlines stay open on block#4424.

Scope: core unauthenticated request-body surfaces plus the Git probe.
The admin router, git policy router, SPA fallback, and health listener
are unchanged.

Tests inject millisecond deadlines (no production-constant sleeps):
stalled body gets an empty 408 within the bound and never completes the
handler; a dropped stalled future never invokes the handler (probe and
router variants); an established WebSocket session survives the request
deadline; probe near-misses still reach the normal path immediately.

Addresses the minimal core of block#4424

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…timeout

The 300s media TimeoutLayer bounded the whole request future, so any
legitimate slow-but-progressing upload needing longer than 300s (a
500 MiB body below ~14 Mbit/s sustained) was cut off with a terminal
408 — the CLI does not retry 408. Found by Sami
(RESEARCH/PR4401_MEDIA_DEADLINE_SLOW_UPLOAD.md).

Uploads and reads are two timeout semantics, so the media router is
split by route class and merged:

- Upload routes get a RequestBodyTimeoutLayer(60s): the deadline
  resets on every body frame, so a progressing upload of any duration
  completes while a withheld body (the parked-task attack, block#4424)
  still fails closed — plus a generous 3600s wall-clock ceiling
  (matching the Blossom auth window) backstopping the post-body phase.
  The ceiling's trickle-permit cost is measured and documented on the
  constant.
- Read routes (GET/HEAD /media/{sha256_ext}) carry no request body,
  so they keep a tight 60s wall-clock deadline — without it a hung
  storage read parks a task forever (measured by Sami: idle-only
  un-bounds every non-body stall). TimeoutLayer only covers until
  response headers, so streaming blob downloads are not truncated.

The idle timeout surfaces as a body read error, not a synthesized
response, so buzz-media gains classify_body_error: a typed check for
tower_http::timeout::TimeoutError first, then the length-limit
Display patterns. All three body-consumption paths in upload_blob map
IdleTimeout -> new MediaError::RequestBodyTimeout (408), LengthLimit
-> FileTooLarge (413), Other -> Io (500) — previously an idle trip
surfaced as 500 pre-sniff (paging as a storage fault) or 413 on the
non-video collect path.

Verified live per TESTING.md: a paced 512 B/s upload (387s wall,
exceeding the old 300s bound) completes and round-trips byte-exact;
withheld upload body answers 408 at exactly 60s with the idle-deadline
message; API withheld body still answers 408 at 60s; git push/clone/
ff-push/pull smoke passes at this tree.

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…d bound

Four review-gate items from PR block#4401 round 3 (Wren's binding hold at
0845a09, amended by Sami's alias and read-bound findings):

1. New buzz-media regression test: a real tower_http TimeoutError,
   wrapped the way axum wraps body errors, driven through
   process_video_upload's StreamReader read loop must surface as
   MediaError::RequestBodyTimeout / 408 — never Io / 500. Honest scope
   per Sami's mutation pass (M3): this pins the conversion chain, not
   the sniff/routing decision, which lives upstream in upload_blob and
   is stated as uncovered rather than implied-covered. Mutating either
   conversion arm (the read-loop TimedOut arm or the stream map's
   IdleTimeout arm) fails the test.

2. MEDIA_READ_TIMEOUT restored 300s: commit 4 silently tightened media
   reads 300s -> 60s while relaxing uploads (found by Sami). 300s
   preserves the bound reads already had and covers the multi-call
   pre-header path: the read handler awaits several sequential storage
   calls, each independently allowed up to 60s by rust-s3's per-call
   default, so a 60s request deadline could cancel a sequence whose
   individual calls are all within their own budgets.

3. Route-precedence regression test for the legacy /media/upload alias:
   the literal lives in the upload sub-router while /media/{sha256_ext}
   lives in the read sub-router, merged. If axum ever resolved the
   literal under the param capture, the alias would inherit the tight
   read wall-clock and the commit-3 regression would survive on exactly
   one route. A slow-but-progressing PUT /media/upload must complete
   under discriminating bounds (read deadline tighter than the upload
   duration), and GET /media/upload must answer 405 from the literal,
   not 408 from the param route's deadline.

4. Comment wording: "any duration"-shaped claims on the idle bound now
   qualify completion by the 3600s MEDIA_UPLOAD_CEILING instead of
   contradicting it.

No production code changes beyond the MEDIA_READ_TIMEOUT constant and
doc comments. Additive on 0845a09, no rebase.

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

git push fails because the NIP-98 credential is scoped to discovery GET and is not sent on git-receive-pack POST

1 participant