mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 18:08:02 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb200e461b | ||
|
|
6fb74a99d3 | ||
|
|
6d547baae2 | ||
|
|
e8393fdc80 | ||
|
|
534bda6ba5 |
No files matched your search
@@ -2520,7 +2520,10 @@ mod tests {
|
||||
"/ipfs/{cid}",
|
||||
axum::routing::get(crate::api::ipfs::get_by_cid),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,17 @@ use gitlawb_core::http_sig::{
|
||||
};
|
||||
use gitlawb_core::identity::verify;
|
||||
|
||||
/// Per-route-group ceiling on how much request body [`require_signature`] will
|
||||
/// buffer BEFORE it has authenticated anything.
|
||||
///
|
||||
/// This is middleware state rather than a constant because a single number cannot
|
||||
/// serve both a 2 GB git push and a 2 MB profile update: the git write group
|
||||
/// passes `GITLAWB_MAX_PACK_BYTES`, every other signed group passes
|
||||
/// [`crate::server::SIGNED_BODY_LIMIT`]. Making it a required piece of state means
|
||||
/// a new signed route group cannot be wired up without choosing a limit.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SignatureBodyLimit(pub usize);
|
||||
|
||||
/// The hint for an unresolvable DID, chosen by method. The hint is only true
|
||||
/// for a method we have no resolver for: a did:key that parsed and then failed
|
||||
/// on its key material (a small-order key, wrong multicodec, wrong length)
|
||||
@@ -69,20 +80,52 @@ fn bounded_did_echo(key_id: &str) -> String {
|
||||
/// 4. Resolves the did:key to an Ed25519 VerifyingKey
|
||||
/// 5. Rebuilds the signing string and verifies the Ed25519 signature
|
||||
/// 6. Verifies Content-Digest matches the request body
|
||||
pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
// Buffer the body so we can verify content-digest and pass it downstream
|
||||
pub async fn require_signature(
|
||||
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
verify_signature(limit, request, next).await
|
||||
}
|
||||
|
||||
/// The signature check itself, shared by [`require_signature`] and the optional
|
||||
/// variant. `limit` bounds the pre-auth buffer; see [`SignatureBodyLimit`].
|
||||
async fn verify_signature(limit: usize, request: Request, next: Next) -> Response {
|
||||
// Buffer the body so we can verify content-digest and pass it downstream.
|
||||
//
|
||||
// The buffer is bounded, because this runs BEFORE any identity check: an
|
||||
// unauthenticated caller with no DID, no signature and no repo reaches this
|
||||
// line. Nothing upstream supplies the bound. On the git write routes
|
||||
// `RequestBodyLimitLayer` sits on the inner router, and a later `.layer()` is
|
||||
// the OUTER service, so this middleware runs first and sees the raw body. On
|
||||
// every other signed group axum's 2 MB `DefaultBodyLimit` is enforced by the
|
||||
// `Bytes`/`Json` extractors through a request extension, which collecting the
|
||||
// body here bypasses. `Limited` stops reading at the limit rather than reading
|
||||
// everything and then measuring it, which is the property that matters.
|
||||
let (parts, body) = request.into_parts();
|
||||
let body_bytes =
|
||||
match body.collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(_) => return (
|
||||
let body_bytes = match http_body_util::Limited::new(body, limit).collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
// Over the limit: a distinct status from the 401 an unsigned request gets
|
||||
// and the 400 below, so a client can tell "too big" from "not authenticated"
|
||||
// and from "your body did not arrive".
|
||||
Err(e) if e.is::<http_body_util::LengthLimitError>() => return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(json!({
|
||||
"error": "body_too_large",
|
||||
"message": format!("request body exceeds the {limit}-byte limit for this route"),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "unreadable_body", "message": "could not read request body" }),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let sig_input = parts
|
||||
.headers
|
||||
@@ -284,11 +327,17 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
/// Optional variant for rolling upgrades: verify and inject `AuthenticatedDid` when
|
||||
/// RFC 9421 signature headers are present, but allow legacy unsigned requests to
|
||||
/// continue when no signature attempt was made.
|
||||
pub async fn optional_signature(request: Request, next: Next) -> Response {
|
||||
pub async fn optional_signature(
|
||||
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let has_signature_headers = request.headers().contains_key("signature-input")
|
||||
|| request.headers().contains_key("signature");
|
||||
if has_signature_headers {
|
||||
return require_signature(request, next).await;
|
||||
// Same pre-auth buffer, same bound: presenting signature headers is not
|
||||
// authentication, so this path is as reachable as the required one.
|
||||
return verify_signature(limit, request, next).await;
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
@@ -769,7 +818,10 @@ mod tests {
|
||||
StatusCode::OK
|
||||
}),
|
||||
)
|
||||
.layer(middleware::from_fn(require_signature));
|
||||
.layer(middleware::from_fn_with_state(
|
||||
SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
require_signature,
|
||||
));
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
|
||||
@@ -42,17 +42,37 @@ async fn graphql_playground() -> impl IntoResponse {
|
||||
))
|
||||
}
|
||||
|
||||
/// Pre-auth body-buffer ceiling for every signed route group except the git write
|
||||
/// routes. Deliberately equal to axum's `DefaultBodyLimit` (2 MB), which is what
|
||||
/// these groups' `Bytes`/`Json` extractors already enforce: that default works
|
||||
/// through a request extension the extractors consult, and `require_signature`
|
||||
/// collects the raw body itself, so it never saw the cap. Matching the number
|
||||
/// keeps this change behavior-preserving on the response path (nothing that used
|
||||
/// to be accepted starts failing) and purely a memory fix.
|
||||
pub const SIGNED_BODY_LIMIT: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// Applies the standard auth middleware pair to a router: HTTP Signature verification
|
||||
/// followed by UCAN chain validation. The two layers run in this order for every
|
||||
/// matched request: `require_signature` first (sets `AuthenticatedDid`), then
|
||||
/// `require_ucan_chain` (reads it).
|
||||
fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState> {
|
||||
///
|
||||
/// `body_limit` bounds the request body `require_signature` buffers before it has
|
||||
/// authenticated anything (see [`auth::SignatureBodyLimit`]): `max_pack_bytes` for
|
||||
/// the git write group, [`SIGNED_BODY_LIMIT`] for every other group.
|
||||
fn add_auth_layers(
|
||||
router: Router<AppState>,
|
||||
state: AppState,
|
||||
body_limit: usize,
|
||||
) -> Router<AppState> {
|
||||
router
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
auth::require_ucan_chain,
|
||||
))
|
||||
.layer(middleware::from_fn(auth::require_signature))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(body_limit),
|
||||
auth::require_signature,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
@@ -63,7 +83,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// Attach the verified DID to /graphql when a signature is present. The
|
||||
// layer covers only routes added before it, so /graphql/ws (added after,
|
||||
// read-only subscriptions) stays open.
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
|
||||
|
||||
// ── Task routes (write — require HTTP Signature) ───────────────────────
|
||||
@@ -74,6 +97,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
|
||||
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Task routes (read — open) ──────────────────────────────────────────
|
||||
@@ -107,6 +131,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
|
||||
.layer(axum::Extension(limiter)),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(create_ip_limiter));
|
||||
@@ -181,6 +206,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
axum::routing::delete(agents::deregister_agent),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
|
||||
@@ -205,6 +231,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit)),
|
||||
state.clone(),
|
||||
pack_limit,
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(push_limiter));
|
||||
@@ -225,7 +252,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
};
|
||||
let ipfs_routes = Router::new()
|
||||
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(ipfs_limiter))
|
||||
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
|
||||
@@ -258,6 +288,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(bounties::dispute_bounty),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Bounty routes (read — open) ──────────────────────────────────────
|
||||
@@ -273,12 +304,16 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/agents/{did}/bounties",
|
||||
get(bounties::agent_bounty_stats),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
|
||||
// ── Profile routes (write — require HTTP Signature) ─────────────────
|
||||
let profile_write_routes = add_auth_layers(
|
||||
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
|
||||
@@ -293,6 +328,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(issues::create_issue_comment),
|
||||
),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Peer discovery routes ─────────────────────────────────────────────
|
||||
@@ -311,6 +347,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
let sync_trigger_routes = add_auth_layers(
|
||||
Router::new().route("/api/v1/sync/trigger", post(peers::trigger_sync)),
|
||||
state.clone(),
|
||||
SIGNED_BODY_LIMIT,
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
@@ -327,9 +364,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/v1/peers/announce", post(peers::announce))
|
||||
.route("/api/v1/sync/notify", post(peers::notify_sync));
|
||||
peer_write_routes = if state.config.require_signed_peer_writes {
|
||||
add_auth_layers(peer_write_routes, state.clone())
|
||||
add_auth_layers(peer_write_routes, state.clone(), SIGNED_BODY_LIMIT)
|
||||
} else {
|
||||
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
|
||||
peer_write_routes.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
};
|
||||
let peer_write_routes = peer_write_routes
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
@@ -428,7 +468,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
get(replicas::list_replicas),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
|
||||
// git-upload-pack (clone/fetch) — same raised body limit as receive-pack so
|
||||
// large pack responses from the server don't get truncated on the client side.
|
||||
@@ -460,7 +503,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit))
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
// `pack_limit`, not SIGNED_BODY_LIMIT: git-upload-pack POSTs live here and
|
||||
// the group has deliberately raised its ceiling, so the pre-auth buffer for
|
||||
// a signed fetch negotiation must match rather than narrow it.
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(pack_limit),
|
||||
auth::optional_signature,
|
||||
));
|
||||
|
||||
// ── Meta ──────────────────────────────────────────────────────────────
|
||||
let meta_routes = Router::new()
|
||||
|
||||
@@ -188,6 +188,7 @@ mod tests {
|
||||
use crate::db::{AgentTask, RepoRecord};
|
||||
use axum::http::StatusCode;
|
||||
use chrono::Utc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tower::ServiceExt;
|
||||
fn seed_repo(owner_did: &str, name: &str) -> RepoRecord {
|
||||
let now = Utc::now();
|
||||
@@ -3859,7 +3860,10 @@ mod tests {
|
||||
"/api/v1/repos/{owner}/{repo}/hooks",
|
||||
axum::routing::get(crate::api::webhooks::list_webhooks),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state);
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
@@ -4101,7 +4105,10 @@ mod tests {
|
||||
"/{owner}/{repo}/info/refs",
|
||||
axum::routing::get(crate::api::repos::git_info_refs),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let path = |service: &str| format!("/{short}/ir-priv.git/info/refs?service={service}");
|
||||
@@ -4200,7 +4207,10 @@ mod tests {
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::require_signature))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::require_signature,
|
||||
))
|
||||
.with_state(state);
|
||||
|
||||
let req = Request::builder()
|
||||
@@ -4256,7 +4266,10 @@ mod tests {
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::require_signature))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::require_signature,
|
||||
))
|
||||
.with_state(state);
|
||||
|
||||
let path = format!("/{short}/defrepo.git/git-receive-pack");
|
||||
@@ -4313,7 +4326,10 @@ mod tests {
|
||||
"/{owner}/{repo}/git-upload-pack",
|
||||
axum::routing::post(crate::api::repos::git_upload_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
// A non-empty body (git-remote-gitlawb skips the POST when the body is empty).
|
||||
@@ -4464,7 +4480,10 @@ mod tests {
|
||||
"/{owner}/{repo}/info/refs",
|
||||
axum::routing::get(crate::api::repos::git_info_refs),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
async fn body_of(resp: axum::response::Response) -> String {
|
||||
@@ -13740,7 +13759,10 @@ mod tests {
|
||||
"/ipfs/{cid}",
|
||||
axum::routing::get(crate::api::ipfs::get_by_cid),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
}
|
||||
async fn cid_parts(resp: axum::response::Response) -> (StatusCode, String) {
|
||||
@@ -18607,4 +18629,576 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Pre-auth body buffering in `require_signature` ───────────────────────
|
||||
//
|
||||
// `require_signature` buffers the whole body BEFORE it verifies anything, so
|
||||
// an unauthenticated caller could make the node hold an arbitrary amount of
|
||||
// memory just by opening a request. Nothing upstream bounded it: on the git
|
||||
// write routes `RequestBodyLimitLayer` sits on the INNER router (a later
|
||||
// `.layer()` is the OUTER service, so auth runs first and sees the raw body),
|
||||
// and on the other eight signed groups axum's 2 MB `DefaultBodyLimit` only
|
||||
// works through the extractors, which this middleware bypasses by collecting
|
||||
// the body itself.
|
||||
//
|
||||
// Every test below asserts on bytes ACTUALLY POLLED out of the body, not just
|
||||
// the status code: a status-only assertion passes whether or not the body was
|
||||
// drained, which is exactly the failure mode being fixed.
|
||||
|
||||
/// A streaming body of `total` bytes in `chunk`-sized frames that counts the
|
||||
/// bytes a consumer actually pulls out of it. The counter is the measurement
|
||||
/// the drain assertions rest on.
|
||||
fn counting_body(total: usize, chunk: usize) -> (Body, std::sync::Arc<AtomicUsize>) {
|
||||
let polled = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let counter = polled.clone();
|
||||
let stream = futures::stream::unfold(0usize, move |sent| {
|
||||
let counter = counter.clone();
|
||||
async move {
|
||||
if sent >= total {
|
||||
return None;
|
||||
}
|
||||
let n = chunk.min(total - sent);
|
||||
counter.fetch_add(n, Ordering::SeqCst);
|
||||
Some((
|
||||
Ok::<_, std::convert::Infallible>(bytes::Bytes::from(vec![0u8; n])),
|
||||
sent + n,
|
||||
))
|
||||
}
|
||||
});
|
||||
(Body::from_stream(stream), polled)
|
||||
}
|
||||
|
||||
/// Frame size for the counting bodies. `Limited` rejects on the frame that
|
||||
/// carries the total past the limit, and that frame has already been polled,
|
||||
/// so the drain assertions allow exactly one frame of slack over the limit.
|
||||
const DRAIN_CHUNK: usize = 8 * 1024;
|
||||
|
||||
/// A test state whose git routes are capped at `max_pack_bytes`, so an
|
||||
/// oversize push body is a few hundred KiB rather than gigabytes.
|
||||
fn state_with_pack_limit(mut state: AppState, max_pack_bytes: usize) -> AppState {
|
||||
use clap::Parser;
|
||||
state.config = Arc::new(crate::config::Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--max-pack-bytes",
|
||||
&max_pack_bytes.to_string(),
|
||||
]));
|
||||
state
|
||||
}
|
||||
|
||||
/// Scenario 1, the regression test for the probe that found this: an UNSIGNED
|
||||
/// POST to `/{owner}/{repo}/git-receive-pack` with a body far larger than the
|
||||
/// configured `GITLAWB_MAX_PACK_BYTES`. Before the fix this drained all 4 MiB
|
||||
/// into memory and then returned 401 (no DID, no signature, no repo needed).
|
||||
#[tokio::test]
|
||||
async fn unsigned_oversize_push_is_not_drained_into_memory() {
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
let state = state_with_pack_limit(test_state_lazy(), LIMIT);
|
||||
let router = crate::server::build_router(state);
|
||||
|
||||
let (body, polled) = counting_body(4 * 1024 * 1024, DRAIN_CHUNK);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/zowner/repo/git-receive-pack")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
let drained = polled.load(Ordering::SeqCst);
|
||||
assert!(
|
||||
drained <= LIMIT + DRAIN_CHUNK,
|
||||
"unsigned push must stop reading at the pack limit, drained {drained} of 4 MiB \
|
||||
against a {LIMIT}-byte limit"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"an over-limit body is rejected as too large"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scenario 2: the same hole on a non-git signed group. These eight groups
|
||||
/// have no `RequestBodyLimitLayer` at all and rely on axum's 2 MB default,
|
||||
/// which the middleware's own `collect()` bypasses.
|
||||
#[tokio::test]
|
||||
async fn unsigned_oversize_profile_write_is_not_drained_into_memory() {
|
||||
let router = crate::server::build_router(test_state_lazy());
|
||||
|
||||
let (body, polled) = counting_body(6 * 1024 * 1024, DRAIN_CHUNK);
|
||||
let req = Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/v1/profile")
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
let drained = polled.load(Ordering::SeqCst);
|
||||
assert!(
|
||||
drained <= crate::server::SIGNED_BODY_LIMIT + DRAIN_CHUNK,
|
||||
"non-git signed routes stop reading at the 2 MB default, drained {drained}"
|
||||
);
|
||||
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
/// Scenario 5: the over-limit rejection is distinguishable. It is neither the
|
||||
/// 401 an unsigned request gets nor the 400 an unreadable body gets, so a
|
||||
/// client can tell "too big" from "not authenticated".
|
||||
#[tokio::test]
|
||||
async fn over_limit_is_413_not_401_or_400() {
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
let state = state_with_pack_limit(test_state_lazy(), LIMIT);
|
||||
let router = crate::server::build_router(state);
|
||||
|
||||
let (body, _polled) = counting_body(LIMIT * 4, DRAIN_CHUNK);
|
||||
let resp = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/zowner/repo/git-receive-pack")
|
||||
.body(body)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = resp.status();
|
||||
assert_ne!(status, StatusCode::UNAUTHORIZED, "size, not identity");
|
||||
assert_ne!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"size, not an unreadable body"
|
||||
);
|
||||
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
/// Scenario 6, degenerate cases: an empty body and a body exactly at the limit
|
||||
/// are both under the cap, so they fall through to the existing signature
|
||||
/// check and get the same 401 they always did.
|
||||
#[tokio::test]
|
||||
async fn empty_and_exactly_at_limit_bodies_behave_as_before() {
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
let state = state_with_pack_limit(test_state_lazy(), LIMIT);
|
||||
|
||||
let resp = crate::server::build_router(state.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/zowner/repo/git-receive-pack")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"an empty unsigned body still fails at the signature check, not the limit"
|
||||
);
|
||||
|
||||
let (body, polled) = counting_body(LIMIT, DRAIN_CHUNK);
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/zowner/repo/git-receive-pack")
|
||||
.body(body)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
polled.load(Ordering::SeqCst),
|
||||
LIMIT,
|
||||
"a body exactly at the limit is read in full"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"exactly at the limit is allowed through to the signature check"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scenario 3, the discriminator: without it the fix could "pass" by rejecting
|
||||
/// everything. A correctly signed push just under `max_pack_bytes` must clear
|
||||
/// the limit AND the signature check with its body intact, which means the
|
||||
/// full body was read (content-digest is computed over it) and the request
|
||||
/// reached the handler. The handler then 404s because no repo was seeded,
|
||||
/// which is the first thing `git_receive_pack` does after auth.
|
||||
#[sqlx::test]
|
||||
async fn signed_push_just_under_the_pack_limit_still_reaches_the_handler(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
let state = state_with_pack_limit(test_state(pool).await, LIMIT);
|
||||
let kp = Keypair::generate();
|
||||
let short = kp
|
||||
.did()
|
||||
.to_string()
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// Just under the cap, and signed over the exact bytes so content-digest
|
||||
// verification proves the middleware handed the body through unmodified.
|
||||
let payload = vec![0u8; LIMIT - 1];
|
||||
let path = format!("/{short}/pushrepo/git-receive-pack");
|
||||
let signed = sign_request(&kp, "POST", &path, &payload);
|
||||
let len = payload.len();
|
||||
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::from(payload))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"a {len}-byte push under a {LIMIT}-byte cap must not be rejected as too large"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"auth passed with the body intact; the handler 404s on the unseeded repo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scenario 4: a correctly signed, in-limit request on a non-git signed route
|
||||
/// still succeeds end to end, so the 2 MB constant did not narrow anything
|
||||
/// that used to work.
|
||||
#[sqlx::test]
|
||||
async fn signed_in_limit_profile_write_still_succeeds(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did().to_string();
|
||||
let payload =
|
||||
serde_json::to_vec(&serde_json::json!({ "display_name": "limit-probe" })).unwrap();
|
||||
let signed = sign_request(&kp, "PUT", "/api/v1/profile", &payload);
|
||||
|
||||
let resp = crate::server::build_router(test_state(pool).await)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/api/v1/profile")
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::from(payload))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"signed in-limit write succeeds"
|
||||
);
|
||||
let body = json_body(resp).await;
|
||||
assert_eq!(body["did"], did);
|
||||
assert_eq!(body["display_name"], "limit-probe");
|
||||
}
|
||||
|
||||
// ── The buffered body must reach git byte-for-byte ──────────────────────
|
||||
//
|
||||
// Scenario 3 above proves a large signed push clears the limit and the
|
||||
// signature check, but it stops at the handler's repo lookup. That leaves one
|
||||
// hole: middleware that truncated or reordered the buffered body past some
|
||||
// threshold would still pass it, because nothing downstream ever consumed the
|
||||
// bytes. The two tests below close it by driving a REAL `git push` request
|
||||
// into a REAL bare repo and asserting on the refs that land on disk.
|
||||
|
||||
/// Removes a fixed path on drop, so a failing assertion still cleans up.
|
||||
/// `repo_store::for_testing` pins the on-disk layout to `/tmp/<slug>/<name>.git`,
|
||||
/// so these paths cannot be `tempfile::TempDir` randoms.
|
||||
struct PushDirGuard(std::path::PathBuf);
|
||||
|
||||
impl Drop for PushDirGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`real_push_fixture`] hands back: the exact request bytes a git client
|
||||
/// sends, the commit that push carries, and the empty bare repo it targets.
|
||||
struct PushFixture {
|
||||
body: Vec<u8>,
|
||||
new_sha: String,
|
||||
bare: std::path::PathBuf,
|
||||
_guards: Vec<PushDirGuard>,
|
||||
}
|
||||
|
||||
/// Build a real push: a source repo holding one commit with `payload` bytes of
|
||||
/// incompressible content, an EMPTY bare repo at the exact path `repo_store`
|
||||
/// will write to, and the `git-receive-pack` request body a real `git push`
|
||||
/// produces for it.
|
||||
///
|
||||
/// The body is captured off the wire from a throwaway local server rather than
|
||||
/// hand-assembled, so the limit tests drive the bytes a client actually sends
|
||||
/// (pkt-line command list, flush, then the pack) instead of an approximation
|
||||
/// that real `git receive-pack` might reject for unrelated reasons.
|
||||
async fn real_push_fixture(owner_did: &str, name: &str, payload: usize) -> PushFixture {
|
||||
use std::process::Command;
|
||||
|
||||
let run = |args: &[&str], cwd: &std::path::Path| {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
|
||||
let slug = owner_did.replace([':', '/'], "_");
|
||||
let mut guards = Vec::new();
|
||||
|
||||
// Source repo with one commit. The content is incompressible so the pack
|
||||
// stays close to `payload`: a file of zeros would deflate to nothing and
|
||||
// the request would never approach the limit under test.
|
||||
let src = std::env::temp_dir().join(format!("gl-push-src-{slug}-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
guards.push(PushDirGuard(src.clone()));
|
||||
run(&["init", "-q", "-b", "main"], &src);
|
||||
run(&["config", "user.email", "t@t"], &src);
|
||||
run(&["config", "user.name", "t"], &src);
|
||||
let mut blob = Vec::with_capacity(payload);
|
||||
let mut x: u64 = 0x2545_f491_4f6c_dd1d;
|
||||
while blob.len() < payload {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
blob.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
blob.truncate(payload);
|
||||
std::fs::write(src.join("big.bin"), &blob).unwrap();
|
||||
run(&["add", "big.bin"], &src);
|
||||
run(&["commit", "-qm", "big"], &src);
|
||||
let new_sha = run(&["rev-parse", "HEAD"], &src);
|
||||
|
||||
// Empty bare repo at the path repo_store.acquire_write() resolves to.
|
||||
let bare = std::path::PathBuf::from("/tmp")
|
||||
.join(&slug)
|
||||
.join(format!("{name}.git"));
|
||||
let _ = std::fs::remove_dir_all(&bare);
|
||||
std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
|
||||
run(&["init", "-q", "--bare", bare.to_str().unwrap()], &src);
|
||||
guards.push(PushDirGuard(bare.clone()));
|
||||
|
||||
// Capture server: advertises the real (empty) bare repo so the client
|
||||
// computes a genuine old→new command list, and records the POST body.
|
||||
let adv_repo = bare.clone();
|
||||
let captured: Arc<std::sync::Mutex<Vec<u8>>> = Arc::default();
|
||||
let sink = captured.clone();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/repo.git/info/refs",
|
||||
axum::routing::get(move || {
|
||||
let repo = adv_repo.clone();
|
||||
async move {
|
||||
// `info_refs` gained a git binary, a service timeout and an
|
||||
// admission guard on main while this branch was out; this
|
||||
// capture server wants none of them bounded, so it passes the
|
||||
// default binary, a generous timeout and no admission.
|
||||
crate::git::smart_http::info_refs(
|
||||
"git",
|
||||
"git-receive-pack",
|
||||
&repo,
|
||||
std::time::Duration::from_secs(600),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("advertise refs")
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/repo.git/git-receive-pack",
|
||||
axum::routing::post(move |body: bytes::Bytes| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
*sink.lock().unwrap() = body.to_vec();
|
||||
StatusCode::OK
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
|
||||
let push_src = src.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// The capture server answers with an empty body, so git reports a
|
||||
// disconnect once it has sent the request. The request is all we want
|
||||
// here, so the exit status is deliberately not asserted.
|
||||
let _ = Command::new("git")
|
||||
.args([
|
||||
"push",
|
||||
&format!("http://127.0.0.1:{port}/repo.git"),
|
||||
"HEAD:refs/heads/main",
|
||||
])
|
||||
.current_dir(&push_src)
|
||||
.output()
|
||||
.expect("git push runs");
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
server.abort();
|
||||
|
||||
let body = captured.lock().unwrap().clone();
|
||||
assert!(
|
||||
body.windows(4).any(|w| w == b"PACK"),
|
||||
"the captured request must carry a real pack, got {} bytes",
|
||||
body.len()
|
||||
);
|
||||
PushFixture {
|
||||
body,
|
||||
new_sha,
|
||||
bare,
|
||||
_guards: guards,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a ref out of a bare repo, or `None` when it does not exist.
|
||||
fn bare_ref(bare: &std::path::Path, name: &str) -> Option<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["--git-dir", bare.to_str().unwrap(), "rev-parse", name])
|
||||
.output()
|
||||
.expect("git runs");
|
||||
out.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// The discriminator with teeth: a signed push of a real ~300 KiB pack against
|
||||
/// a limit just above it must not only return 200, it must LAND. Asserting on
|
||||
/// the ref that appears in the bare repo is what catches a middleware that
|
||||
/// buffers the body but hands a truncated or reordered copy downstream, which
|
||||
/// a status-only assertion cannot see.
|
||||
#[sqlx::test]
|
||||
async fn signed_large_push_under_the_limit_lands_on_disk(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
let fixture = real_push_fixture(&owner_did, "bigpush-ok", 300 * 1024).await;
|
||||
let len = fixture.body.len();
|
||||
assert!(
|
||||
len > 256 * 1024,
|
||||
"the fixture push must be many frames long to exercise the limit, got {len} bytes"
|
||||
);
|
||||
|
||||
// The cap sits just above this push, so passing means the limit admitted a
|
||||
// body it was sized for, not that the limit was slack.
|
||||
let state = state_with_pack_limit(test_state(pool).await, len + 1024);
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(&owner_did, "bigpush-ok"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let path = format!("/{short}/bigpush-ok.git/git-receive-pack");
|
||||
let signed = sign_request(&kp, "POST", &path, &fixture.body);
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::from(fixture.body.clone()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"a {len}-byte push under a {}-byte cap must be served",
|
||||
len + 1024
|
||||
);
|
||||
assert_eq!(
|
||||
bare_ref(&fixture.bare, "refs/heads/main").as_deref(),
|
||||
Some(fixture.new_sha.as_str()),
|
||||
"the push must take effect: refs/heads/main points at the pushed commit"
|
||||
);
|
||||
assert!(
|
||||
bare_ref(&fixture.bare, &format!("{}^{{tree}}", fixture.new_sha)).is_some(),
|
||||
"the pushed objects must be present, not just the ref"
|
||||
);
|
||||
}
|
||||
|
||||
/// The negative twin: the same real push against a cap one byte below it is
|
||||
/// rejected 413, and the repo is untouched. A partial write here would mean
|
||||
/// the middleware forwarded a prefix of an over-limit body.
|
||||
#[sqlx::test]
|
||||
async fn signed_large_push_over_the_limit_is_rejected_and_writes_nothing(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
let fixture = real_push_fixture(&owner_did, "bigpush-over", 300 * 1024).await;
|
||||
let len = fixture.body.len();
|
||||
|
||||
let state = state_with_pack_limit(test_state(pool).await, len - 1);
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(&owner_did, "bigpush-over"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let path = format!("/{short}/bigpush-over.git/git-receive-pack");
|
||||
let signed = sign_request(&kp, "POST", &path, &fixture.body);
|
||||
let resp = crate::server::build_router(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::from(fixture.body.clone()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"a {len}-byte push against a {}-byte cap is too large",
|
||||
len - 1
|
||||
);
|
||||
assert_eq!(
|
||||
bare_ref(&fixture.bare, "refs/heads/main"),
|
||||
None,
|
||||
"a rejected push must leave no ref behind"
|
||||
);
|
||||
// Peel to `^{commit}`: `rev-parse` echoes a bare 40-hex SHA back without
|
||||
// checking that the object exists, so only the peeled form is evidence.
|
||||
assert_eq!(
|
||||
bare_ref(&fixture.bare, &format!("{}^{{commit}}", fixture.new_sha)),
|
||||
None,
|
||||
"a rejected push must leave no objects behind"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user