Skip to content

fix(node): bound the request body the signature middleware buffers before it verifies - #262

Open
beardthelion wants to merge 2 commits into
mainfrom
fix/bound-preauth-signature-body
Open

fix(node): bound the request body the signature middleware buffers before it verifies#262
beardthelion wants to merge 2 commits into
mainfrom
fix/bound-preauth-signature-body

Conversation

@beardthelion

@beardthelion beardthelion commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

require_signature buffers the whole request body with an unlimited body.collect() before it parses a single header, and nothing upstream caps it.

On the git write routes the pack-size RequestBodyLimitLayer (2 GB by default, 500 MB in production) is applied to the inner router while add_auth_layers wraps it. A later .layer() is the outer service, so the middleware runs first and sees the raw body; the limit never applies to it. The other signed groups carry no limit layer at all and rely on axum's 2 MB DefaultBodyLimit, which the extractors enforce through a request extension this middleware never consults, so that default is bypassed as well.

Measured against the real router, with GITLAWB_MAX_PACK_BYTES set to 64 KiB and a 4 MiB body on an unsigned push:

limit=65536  status=401 Unauthorized  bytes_drained=4194304

The full body is read, then the request is rejected for having no signature. No DID, no signature and no repo are needed to get there. In front of it sits only a per-IP arrivals-per-hour counter, and there is no timeout layer and no connection cap on axum::serve.

The reach is wider than the git routes: optional_signature forwards into the same code whenever a caller merely presents signature headers, so /graphql, /ipfs/{cid} and the other optional-signature groups have it too. Fifteen route groups in total.

The fix

The collect now runs through http_body_util::Limited, so reading stops at the limit rather than after the fact, and an over-limit request gets 413 instead of the 400 reserved for a genuinely unreadable body.

The limit is per route group. add_auth_layers takes it and hands it to the middleware as state; the git groups keep max_pack_bytes so large pushes are unaffected, and the rest get a constant equal to axum's default, which is already what their extractors enforce. That makes the change behavior-preserving on the response path and purely a memory fix. Signature verification, the content-digest check, clock skew, and the request reconstruction are untouched.

Verification

The regression test asserts bytes actually polled out of a streaming body rather than the status code, because a status assertion passes whether or not the body was drained.

There is also an end-to-end pair driving a real push: a source repo with a 300 KiB blob and the byte-for-byte receive-pack body captured off the wire from an actual git push. Under the limit it asserts the ref moved and the tree resolves, not merely a 200. That distinction is load-bearing, since a truncated body still returns 200 with the failure inside report-status; only the on-disk assertion catches it. Running the whole suite under a truncation mutation leaves exactly one test failing. Over the limit it asserts 413 with neither the ref nor the commit present, so a rejected push cannot half-write.

Each guard was checked by reverting it: neutralizing the limit turns the drain tests red, handing every group the git limit turns the non-git test red, and returning 400 instead of 413 turns the status test red.

518 tests pass, fmt and clippy are clean.

Residual

This bounds per-request buffering only. It does not bound how many such requests are in flight, or how long each may take to send its body, so total resident memory is still proportional to concurrent connections times the limit.

To be precise about what does exist, since the first version of this paragraph was wrong: the Fly configs do cap connections (hard_limit = 500), and they do set idle_timeout = 120. Neither helps. The connection cap is sized above what a 1024 MB machine can hold, and a body dripping a byte every few seconds is never idle. There is no request-duration or body-read timeout in process, and rate_limit_by_ip counts arrivals per window rather than bounding concurrency.

Filed as #263 with the measurements.

Summary by CodeRabbit

  • New Features

    • Added configurable request-body limits for signed and optionally signed routes.
    • Added support for larger, properly signed Git operations within the configured pack-size limit.
  • Bug Fixes

    • Oversized request bodies now return a clear 413 Payload Too Large response.
    • Improved distinction between oversized and unreadable request bodies.
    • Prevented rejected oversized operations from modifying stored repository data.

…fore it verifies

`require_signature` buffered the whole request body with an unlimited
`body.collect()` before parsing a single header, and nothing upstream capped it.
On the git write routes the 2 GB `RequestBodyLimitLayer` is applied to the inner
router while `add_auth_layers` wraps it, and a later `.layer()` is the outer
service, so the middleware ran first and saw the raw body. The other signed
groups carry no limit layer at all and lean on axum's 2 MB `DefaultBodyLimit`,
which the extractors enforce through a request extension this middleware never
consults, so that default was bypassed too. `optional_signature` forwards to the
same code whenever a caller merely presents signature headers, so the reach
extended to `/graphql`, `/ipfs/{cid}` and the other optional-signature groups.

Measured against the real router: an unsigned push with `GITLAWB_MAX_PACK_BYTES`
set to 64 KiB drained the full 4 MiB body and only then returned 401. No DID, no
signature, and no repo were required, and in front of it sits only a per-IP
arrivals-per-hour counter, no timeout layer and no connection cap.

The collect now runs through `http_body_util::Limited`, so reading stops at the
limit instead of after the fact, and an over-limit request gets 413 rather than
the 400 reserved for a genuinely unreadable body. The limit is per route group:
`add_auth_layers` takes it and passes it to the middleware as state, the git
groups keep `max_pack_bytes` so large pushes are unaffected, and the rest get a
constant equal to axum's default because that is already what their extractors
enforce. Verification, content-digest, clock-skew and the request reconstruction
are untouched.

The regression test asserts bytes actually polled out of a streaming body, not
the status code, because a status assertion passes whether or not the body was
drained. Neutralizing the limit turns it red; handing every group the git limit
turns the non-git test red; returning 400 instead of 413 turns the status test
red.

This bounds per-request buffering only. Total resident memory is still
O(connections x limit); a read timeout and a connection cap are what close that
and are deliberately not in this change.
… over-limit one writes nothing

The body-limit guard was covered only by tests that reject. Nothing proved it
still admits what it should, so the fix could have passed every test by refusing
everything, and nothing would have caught a middleware that truncated or
corrupted the body it hands downstream.

These drive a real push: a source repo with a 300 KiB incompressible blob, an
empty bare repo at the path the store resolves, and the byte-for-byte
receive-pack request body captured off the wire from an actual `git push`. The
body is captured rather than generated, because `git send-pack --stateless-rpc`
emits remote-curl's extra RPC framing (a wrapping length plus a stray flush
before PACK) and `git receive-pack --stateless-rpc` rejects it with a pack
signature mismatch.

The positive case sets the cap just above the body and asserts the ref moved and
the tree resolves, not merely that the status was 200. That distinction is
load-bearing: under a truncation mutation the status stays 200, because git
reports the failure inside report-status, and only the on-disk assertion goes
red. Running the whole suite under that mutation leaves exactly one test failing,
so this is the only thing standing between a corrupted body and a green build.

The negative case sets the cap just below the body and asserts 413 with neither
the ref nor the commit present afterwards, so a rejected push cannot half-write.
Its existence check peels to ^{commit}, since rev-parse echoes any well-formed
40-hex string back and would have passed vacuously.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Signature authentication now uses configurable bounded request-body buffering. Routes pass shared or Git pack-specific limits through Axum state, and integration tests cover oversized requests, bounded draining, status codes, and Git push outcomes.

Changes

Signature body limits

Layer / File(s) Summary
Bounded signature verification
crates/gitlawb-node/src/auth/mod.rs
Adds SignatureBodyLimit, bounds body buffering, distinguishes oversized and unreadable bodies, and shares verification between required and optional signatures.
Route middleware limit wiring
crates/gitlawb-node/src/server.rs
Threads SIGNED_BODY_LIMIT or pack_limit through signed route groups and optional-signature middleware.
Body-limit and Git integration coverage
crates/gitlawb-node/src/test_support.rs
Updates test routers and adds coverage for draining limits, response statuses, boundaries, and Git push persistence.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • Gitlawb/node#196: Modifies overlapping optional_signature router middleware wiring.
  • Gitlawb/node#261: Touches adjacent RFC 9421 signature verification and replay-ledger handling.

Suggested labels: kind:security, crate:node, subsystem:identity, kind:bug, kind:test

Suggested reviewers: kevincodex1, gravirei, vasanthdev2004

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SignatureMiddleware
  participant LimitedBody
  participant RouteHandler
  Client->>SignatureMiddleware: Send signed or unsigned request
  SignatureMiddleware->>LimitedBody: Buffer body up to configured limit
  LimitedBody-->>SignatureMiddleware: Body, 413, or 400 result
  SignatureMiddleware->>RouteHandler: Forward verified request
  RouteHandler-->>Client: Application response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately describes the main change: bounding signature-middleware body buffering.
Description check ✅ Passed The description clearly explains the problem, fix, verification, and residual limits, so it is substantially complete.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bound-preauth-signature-body

Comment @coderabbitai help to get the list of available commands.

@beardthelion
beardthelion requested a review from jatmn July 28, 2026 02:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/gitlawb-node/src/server.rs (1)

225-237: 🩺 Stability & Availability | 🔵 Trivial

Memory ceiling on the push path is now max_pack_bytes × in-flight unauthenticated pushes.

Bounding the pre-auth buffer is the right fix, but with the 2 GB default a handful of concurrent unsigned receive-pack POSTs can still pin a lot of RAM before any identity check. The per-IP limiter in front helps only for repeat offenders from one source. Worth pairing this with a global in-flight concurrency cap (e.g. tower::limit::GlobalConcurrencyLimitLayer on the git write group) or a request read timeout, tracked separately if it's out of scope here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/server.rs` around lines 225 - 237, The git write
routes lack a global in-flight concurrency safeguard, allowing concurrent
unauthenticated pushes to consume excessive memory. Update the git_write_routes
middleware stack around add_auth_layers to apply a global concurrency limit (for
example, tower’s GlobalConcurrencyLimitLayer) to the entire git write group,
while preserving the existing body limit, pack_limit, and per-IP rate-limiting
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/gitlawb-node/src/server.rs`:
- Around line 225-237: The git write routes lack a global in-flight concurrency
safeguard, allowing concurrent unauthenticated pushes to consume excessive
memory. Update the git_write_routes middleware stack around add_auth_layers to
apply a global concurrency limit (for example, tower’s
GlobalConcurrencyLimitLayer) to the entire git write group, while preserving the
existing body limit, pack_limit, and per-IP rate-limiting behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 640e8a1a-6854-45da-888c-605aa10cb57f

📥 Commits

Reviewing files that changed from the base of the PR and between 111cff7 and e8393fd.

📒 Files selected for processing (3)
  • crates/gitlawb-node/src/auth/mod.rs
  • crates/gitlawb-node/src/server.rs
  • crates/gitlawb-node/src/test_support.rs

@beardthelion beardthelion added crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior subsystem:identity DID/UCAN, http-sig auth, push authorization labels Jul 28, 2026

@jatmn jatmn 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.

Thanks for the contribution. I do not see any actionable issues from my review.

Merge and checks

  • Mergeability: MERGEABLE; GitHub merge state is BLOCKED (policy/review gate, not a conflict).
  • Required PR checks on head e8393fdc8008da2a07dd81da73b00d8f7616e2ba: fmt + clippy, test (stable), test (beta), build --release, cargo audit, MSRV, Docker smoke, and the rest of the PR Checks workflow are green. Analyze (swift) was still pending on the CodeQL workflow at review time; it is unrelated to this Rust-only diff.

What I verified

  • The core fix is sound: per-group SignatureBodyLimit wired through add_auth_layers and optional_signature, http_body_util::Limited on pre-auth collect(), and 413 body_too_large for over-limit streams close the unbounded pre-auth drain on both git and non-git signed surfaces.
  • Production wiring matches the stated intent: non-git signed groups use SIGNED_BODY_LIMIT (2_097_152, matching axum’s default), git write/read groups use pack_limit.
  • All eight new body-limit tests passed locally, including the real receive-pack e2e fixtures. cargo clippy -p gitlawb-node -- -D warnings is clean.

Out of scope / follow-up (not blockers)

  • Residual memory still scales with concurrent connections × per-route limit; read timeout and global in-flight cap are appropriately deferred.
  • Header-before-buffer fast reject for incomplete signature attempts (one header present, or missing headers on require_signature routes) is real hardening in the same area but pre-existing ordering this PR does not worsen; worth a separate follow-up if desired.
  • Overlap with #261 (replay ledger) will need merge coordination but does not conflict with this limit wiring.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior subsystem:identity DID/UCAN, http-sig auth, push authorization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants