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
This commit is contained in:
2026-09-02 22:37:40 +02:00
parent a86a2d1a0a
commit 55fa4941bc
+37 -13
View File
@@ -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 }