Skip to content

fix(git): allow large smart HTTP pushes and fetches (#2880, #4423) - #4401

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

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

Conversation

@MajorTal

@MajorTal MajorTal commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Split per review: this PR is only the Git smart HTTP probe. The request-time policy for the API and media routers lives in #7753, which also fixes the post-commit cancellation Carl found.

What this fixes

#2880. When a push is bigger than http.postBuffer, Git first sends a tiny unauthenticated POST with only the 4-byte flush packet 0000. We answered 401, Git gave up, large pushes were impossible. The fix admits exactly that probe: no Authorization, no Content-Encoding, no Transfer-Encoding, exactly one Content-Length: 4, body exactly 0000. Anything else falls back to the normal auth path.

#4423. The same probe fires before big fetches. Same middleware, parameterized over the (request MIME, result MIME) pair and mounted on upload-pack too.

Probe body deadline. Admitting the probe means reading four unauthenticated body bytes. That read has its own 10 s deadline that fails closed into normal auth, so a client that sends the probe headers and withholds the body cannot park a task. Git and curl send the headers and the 0000 in the same segment, so a real probe never waits on it. This is the only timeout in this PR.

Commits

Four, rebased on current main, all signed off:

  1. fix(git): allow large smart HTTP pushes
  2. fix(git): admit the compatibility probe on upload-pack too
  3. fix(git): bound the compatibility probe's body read
  4. ci(unit): run the git probe regression tests in the unit lane

Changes since the last push: the probe deadline went from 60 s to 10 s, the fallthrough branch now documents the invariant it relies on (both handlers extract GitAuth from the request parts before touching the body, so a candidate without Authorization is answered 401 without decoding), the four probe tests are wired into just test-unit (no term in its nextest expression matched them, so hosted CI compiled them and ran none), and two commits that carried an npub as git author are now authored normally.

Tests

  • Exactness and path-independence matrix across both services, 2 path shapes and 11 near misses each, plus a cross-service case proving each route rejects the other's request MIME.
  • Probe body deadline: withheld body answers 401 at the bound; a complete probe still passes.
  • Live regression (ignored, needs relay + MinIO): pushes an incompressible 2 MiB pack through Apple Git 2.50.1, observes Content-Length: 4 then Transfer-Encoding: chunked, clones the bytes back byte for byte.

Review done before this push

We ran Block's own gates and reviews locally on this exact head, macOS Apple Silicon:

  • just fmt-check, just clippy (workspace, -D warnings), just test-unit (cargo-nextest), cargo-deny check, just file-size-check, just security-review-check: clean.
  • The Postgres lane (scripts/postgres-test-run.sh against Postgres and Redis containers): 390 passed.
  • A reviewer run with the repository's Codex security review prompt over the exact range: no findings.
  • An adversarial review shaped like Carl's: no blocking findings. It drove the real middleware over a loopback hyper server with raw HTTP/1.1 (exact probe, duplicate and malformed Content-Length, over- and under-delivered bodies, chunked, mixed-case headers, Expect: 100-continue) and confirmed the predicate and the fail-closed path. Its non-blocking items are the changes listed above.

Hosted Rust CI has not run on this head; it needs a maintainer to approve the workflow for this fork branch. The Codex security review needs a fresh authorization for the new range.

Fixes #2880
Fixes #4423

🤖 Generated with Claude Code

tlongwell-block pushed a commit to MajorTal/buzz that referenced this pull request Aug 3, 2026
…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>
MajorTal pushed a commit to MajorTal/buzz that referenced this pull request Aug 10, 2026
…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>
Signed-off-by: Tal Weiss <major.tal@gmail.com>
@MajorTal
MajorTal force-pushed the codex/issue-2880-large-push-probe branch from 230ac85 to 83f1d18 Compare August 10, 2026 09:57
MajorTal pushed a commit to MajorTal/buzz that referenced this pull request Aug 13, 2026
…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>
Signed-off-by: Tal Weiss <major.tal@gmail.com>
@MajorTal
MajorTal force-pushed the codex/issue-2880-large-push-probe branch from 83f1d18 to dc2ead6 Compare August 13, 2026 14:09
@MajorTal
MajorTal marked this pull request as ready for review August 19, 2026 08:24
@MajorTal
MajorTal requested a review from a team as a code owner August 19, 2026 08:24
@MajorTal
MajorTal force-pushed the codex/issue-2880-large-push-probe branch from dc2ead6 to 1b96b09 Compare August 26, 2026 08:05
MajorTal pushed a commit to MajorTal/buzz that referenced this pull request Aug 26, 2026
…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>
Signed-off-by: Tal Weiss <major.tal@gmail.com>
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 5511b56fcf0047f9d0d4887dc75125b6933a6bbd...8e518101250fc06e800b52fb60b17018e6e5c047.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review 8e518101250fc06e800b52fb60b17018e6e5c047 to authorize a new review.
Any previous review applies only to its recorded range.

@MajorTal

Copy link
Copy Markdown
Contributor Author

@block/buzz-oss-team - poke on this one, gently. It is green and merges clean against main, so no workflow approval is needed just to look at it.

Reason I am pinging instead of waiting: one of the three issues in here is #4424, where the relay bounds request bytes but never request time. Valid headers, then withhold the body, and the task parks forever. Unauthenticated. I sat 200 sockets on it to confirm.

I also just rewrote the description. It was stale, still describing only the original large-push bug from back when that was all this was, which made the diff size look alarming for no reason. It now explains why three issues ended up in one PR (short version: same middleware, and each fix makes the next one necessary).

If you would rather review the DoS part on its own, say so and I will split #4424 out. Either way works, I just do not want it sitting.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested. Reviewed 4ab4f786085a23fe6126529861840eff6048ceee...36fab5314173da1d6c34f170ee6eccce41a22dcb.

P1: The new API wall deadline can strand committed events without audit or delivery

The inline finding describes a concrete postcommit cancellation path, not just an ambiguous timeout response. With audit backpressure, the new deadline cancels event processing after DB insertion but before audit enqueue and before fanout/workflow scheduling. Reposting the same event with fresh HTTP authorization returns duplicate: and does not repair the lost work. Ownership transfer, create-only community provisioning, and invite claim also await publication after committing; their retries are not a universal recovery mechanism.

I reproduced the event cancellation with a paused-clock standalone harness using the verbatim production timeout, audit-enqueue, and dispatch-scheduling functions. DB insertion/duplicate semantics and downstream delivery were stubbed, so this is a control-flow reproduction, not live relay/Postgres integration:

BASE: original=200 OK retry=200 OK stored=true audit=Some("00") dispatched=true
HEAD: original=408 Request Timeout retry=200 OK stored=true audit=None dispatched=false

Scope and smallest safe correction

The exact four-byte probe shim on both Git services is a narrow compatibility fix; I found no auth bypass or repository-data disclosure in that changed path. Its local PROBE_BODY_TIMEOUT already bounds the newly introduced unauthenticated read. The API/media-wide timeout policy is not required to make that probe safe. I recommend taking the offered split: keep the push/fetch compatibility shim and local body deadline here, and review #4424’s wider policy separately. That separate fix should bound request-body consumption without cancelling irreversible mutations, or provide tested durable/idempotent recovery. No new user-facing feature was found in the diff, but new global timeout semantics are meaningful scope expansion.

Media disposition

I did not establish a second blocking media defect. The 3600s whole-upload ceiling can still interrupt storage/publication after a valid body finishes near the deadline; Blossom auth is checked before those operations, not continuously through them. A missing sidecar leaves an unservable orphan and content-addressed retry can repair it. S3 cancellation/late-commit behavior was not independently reproduced, so I am recording this as a residual availability/orphan risk, not claiming corruption or an auth bypass. The idle timeout/error-classification changes themselves preserve the reviewed 408/413/500 distinction.

Validation and limits

  • Inspected all ten changed files and traced Git auth/authorization, body-limit layering, API postcommit paths, media body/error/storage lifecycle, and retry behavior. Independent Git/router/media review lanes were consolidated rather than treated as a blanket safety certificate.
  • The unchanged probe implementation and all four new probe unit tests passed in an isolated package with key dependencies pinned to the production versions. The cancellation harness reproduced the regression. Neither result is a full buzz-relay build/test run.
  • I did not run the ignored live-relay/MinIO Git E2E or independently reproduce the author’s live push/fetch measurements. The current GitHub check snapshot does not show Rust build/test execution, and the security-review comment marks this exact range as requiring a fresh review. Green metadata checks are not evidence those validations ran.

Exit criteria: remove the outer API mutation deadline (the minimal split), or demonstrate cancellation-safe durable postcommit completion/retry through the real event and membership mutation paths. Retain the local Git probe deadline and obtain exact-head relay test plus live large-request workflow evidence before merge.

Comment thread crates/buzz-relay/src/router.rs Outdated
Comment on lines +103 to +106
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
timeout,
))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not time out the entire API mutation future

This helper is applied to /events and other writes, but their postcommit work is not cancellation-safe. ingest_event inserts the event (handlers/ingest.rs:3172-3204), then dispatch_persistent_event awaits the intentionally bounded audit queue before spawning fanout/workflows (handlers/event.rs:358-374,574-597). If the queue is full when this 60s wall deadline expires, TimeoutLayer drops that future: the DB event remains, but its audit entry and delivery/workflow task are lost. The same event retried with a fresh NIP-98 header exits as duplicate: at ingest.rs:3206-3211, before either can be repaired. This can occur under sustained audit pressure, or when a valid slow body consumes most of the total budget and only a short postcommit wait remains.

A paused-clock seam harness using the verbatim timeout and audit/dispatch functions reproduces base=200/audit+dispatch versus head=408/stored/no-audit/no-dispatch; DB storage is stubbed, not live integration. The new collector tests prove cancellation, not mutation recovery.

Bound body reception rather than the mutation service future, or split this unrelated API policy out while retaining the local Git probe timeout. Simply returning 408 after commit is not a safe failure mode here.

Signed-off-by: Tal Weiss <major.tal@gmail.com>
@MajorTal
MajorTal force-pushed the codex/issue-2880-large-push-probe branch from 36fab53 to 45a34b0 Compare September 19, 2026 10:33
@MajorTal MajorTal changed the title fix(git): allow large smart HTTP pushes (#2880) fix(git): allow large smart HTTP pushes and fetches (#2880, #4423) Sep 19, 2026
MajorTal and others added 3 commits September 19, 2026 14:17
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>
Signed-off-by: Tal Weiss <major.tal@gmail.com>
Admitting the 0000 probe means reading four unauthenticated body bytes.
Give that read its own 60 s deadline that fails closed into the normal
authenticated path, so a client that sends the probe headers and then
withholds the body cannot park a task. The wider request-time policy for
the API and media routers is a separate change (block#4424).

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tal Weiss <major.tal@gmail.com>
The unit lane selects buzz-relay tests by explicit nextest expression, and
none of its terms matched api::git::transport::track_c_tests, so the four
compatibility-probe tests were compiled by clippy but executed by no hosted
job. They need no database or storage, so they belong in the infra-free lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tal Weiss <major.tal@gmail.com>
@MajorTal
MajorTal force-pushed the codex/issue-2880-large-push-probe branch from 45a34b0 to 8e51810 Compare September 19, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants