From 55fa4941bc99810aea774b9f0c117fdbd9c74702 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Wed, 2 Sep 2026 22:37:40 +0200 Subject: [PATCH] fix(peer): gate bulk requests on the compact index before loading manifests Security audit finding Codex #2 ("rejected bulk requests can populate the persistent manifest cache"). `admit_outbound_transfer` loaded the full catalog manifest for the requested game ID first and only then compared the content ID and checked whether the game is locally serveable. Because manifests are cached for the life of the process, one anonymous LAN client could make a node parse and retain the entire catalog's manifest corpus with requests for games it does not even have, and every such request paid a disk read under the admission lock. The catalog already exposes a compact content index that answers identity and streamed-install support without I/O. Admission now checks that index and in-memory local readiness first; only requests that pass both load the manifest. Accepted requests behave exactly as before, including the streamed-install support check. Test plan: `just test`. Manual: with two peer-cli containers, chunk downloads and Stream Install still complete; a request naming an unknown game ID or wrong content ID is declined with the same log message as before. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg --- .../lanspread-peer/src/services/transfer.rs | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/crates/lanspread-peer/src/services/transfer.rs b/crates/lanspread-peer/src/services/transfer.rs index faeb9f5..13d9f56 100644 --- a/crates/lanspread-peer/src/services/transfer.rs +++ b/crates/lanspread-peer/src/services/transfer.rs @@ -62,6 +62,30 @@ fn control_codec() -> LengthDelimitedCodec { .new_codec() } +/// Cheap identity gate backed by the compact content index: no manifest body +/// is read or retained for a game ID or content ID this catalog does not +/// publish. +fn content_identity_matches( + ctx: &PeerCtx, + game_id: &str, + content_id: ContentId, + request_label: &str, +) -> bool { + match ctx.catalog.content_identity(game_id) { + Some(identity) if identity.content_id == content_id => true, + Some(_) => { + log::warn!( + "Declining {request_label} for {game_id}: requested content {content_id} does not match the local catalog" + ); + false + } + None => { + log::warn!("Declining {request_label} for unknown catalog game {game_id}"); + false + } + } +} + fn load_expected_catalog_manifest( ctx: &PeerCtx, game_id: &str, @@ -255,6 +279,12 @@ struct AdmittedOutboundTransfer { /// Validates content identity before readiness checks, filesystem opens, /// transfer registration, or provider work. `SetGameDir` holds the same /// admission barrier while draining the prior directory epoch. +/// +/// Ordering matters for cost: the compact content index answers identity +/// and streamed-install support without touching disk, and local readiness +/// is an in-memory lookup. Only a request that passes both is allowed to +/// load (and thereby cache) the full catalog manifest, so anonymous requests +/// for games this node does not serve cannot populate the manifest cache. async fn admit_outbound_transfer( ctx: &PeerCtx, game_id: &str, @@ -273,16 +303,13 @@ async fn admit_outbound_transfer( offset, length, } => { - let manifest = load_expected_catalog_manifest(ctx, game_id)?; - if manifest.content_id() != content_id { - log::warn!( - "Declining catalog chunk for {game_id}: requested content {content_id} does not match the local catalog" - ); + if !content_identity_matches(ctx, game_id, content_id, "catalog chunk") { return None; } if !can_serve_game(ctx, &game_dir, game_id).await { return None; } + let manifest = load_expected_catalog_manifest(ctx, game_id)?; let authorized_entry = authorize_catalog_file_request( &manifest, game_id, @@ -306,16 +333,13 @@ async fn admit_outbound_transfer( AdmittedOutboundPayload::CatalogFile { file } } OutboundTransferRequest::StreamInstall { content_id } => { - let manifest = load_expected_catalog_manifest(ctx, game_id)?; - if manifest.content_id() != content_id { - log::warn!( - "Declining StreamInstall for {game_id}: requested content {content_id} does not match the local catalog" - ); + if !content_identity_matches(ctx, game_id, content_id, "StreamInstall") + || !can_serve_game(ctx, &game_dir, game_id).await + { return None; } - if !can_serve_game(ctx, &game_dir, game_id).await - || !manifest.supports_streamed_install() - { + let manifest = load_expected_catalog_manifest(ctx, game_id)?; + if !manifest.supports_streamed_install() { return None; } AdmittedOutboundPayload::StreamInstall { game_dir, manifest }