fix: remediate consumer findings CF-001..004 (alkhttp ledger)

- CF-004: services_schema_handler now applies the same visibility +
  AccessControl gates as invoke() (identity resolution mirrors invoke:
  handler_identity under internal). Restricted ops return spec-404 NOT_FOUND
  — matches "restricted ops don't exist" and leaks nothing about the
  restricted surface. Closes the unauthenticated /call-path disclosure.
- CF-003: publish_schema compiled at registration time (both
  OperationRegistry::register and OperationRegistryBuilder::store);
  un-compilable schemas are a registration error — an unvalidated ingest
  path can no longer be constructed. Compiled validator cached per-op
  (publish_validator) and consumed by dispatch; per-request compile gone.
  BEHAVIOR CHANGE: register/builder reject un-compilable publish_schema.
- CF-002: demux TooLarge skip streams through a fixed 64 KiB buffer
  instead of allocating the peer-declared length (u32, up to ~4 GiB);
  cumulative 256 MiB skipped-bytes budget tears down dribbling peers.
  Existing resync test passes unchanged.
- CF-001: new retryable CallError::connection_closed (CONNECTION_CLOSED)
  applied only where the call is provably undelivered — request-frame
  write failures on all consumer paths (call/subscribe/publish, both
  stream modes; publish pump tags write stages). Mid-publish failures and
  producer-side fail_all stay non-retryable INTERNAL (delivery ambiguous).
  New code string is additive; retryable flag is the machine-readable
  signal.

Verification: cargo test (558 pass, 15 new), clippy --all-targets -D
warnings, fmt --check, wasm32-unknown-unknown check.
This commit is contained in:
glm-5.3-flash committed 2026-08-31 08:22:55 +00:00
1 parent a2d72f9737
commit 8cb2a6eb6d
8 files changed
+613 -167

No files matched your search

+27 -4
View File
@@ -128,6 +128,15 @@ impl ChannelsAdapter {
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
policy: Option<&Arc<dyn ChannelLifecyclePolicy>>,
) {
// Stack buffer for skipping oversized payloads — sized once, never
// from peer-declared lengths (a peer-declared `length` is untrusted
// up to u32::MAX ≈ 4 GiB; pre-sizing off it is an allocation-shape
// DoS). A cumulative cap tears down dribbling peers that loop
// TooLarge headers to burn bandwidth indefinitely.
const SKIP_BUF_LEN: usize = 64 * 1024;
const MAX_CONSECUTIVE_SKIPPED_BYTES: u64 = 256 * 1024 * 1024;
let mut skip_buf = vec![0u8; SKIP_BUF_LEN];
let mut skipped_since_last_valid: u64 = 0;
let mut reader = reader;
let mut header_buf = [0u8; CHUNK_HEADER_LEN];
loop {
@@ -141,10 +150,23 @@ impl ChannelsAdapter {
max = super::wire::MAX_CHUNK_LEN,
"demux: chunk too large, skipping payload bytes"
);
let mut discard = vec![0u8; length as usize];
if let Err(e) = reader.read_exact(&mut discard).await {
warn!(error = %e, "demux: failed to skip oversized payload");
break;
let mut remaining = length as u64;
while remaining > 0 {
let take = remaining.min(SKIP_BUF_LEN as u64) as usize;
if let Err(e) = reader.read_exact(&mut skip_buf[..take]).await {
warn!(error = %e, "demux: failed to skip oversized payload");
return;
}
remaining -= take as u64;
}
skipped_since_last_valid += length as u64;
if skipped_since_last_valid > MAX_CONSECUTIVE_SKIPPED_BYTES {
warn!(
skipped_since_last_valid,
max = MAX_CONSECUTIVE_SKIPPED_BYTES,
"demux: skipped-bytes budget exceeded; tearing down connection"
);
return;
}
continue;
}
@@ -170,6 +192,7 @@ impl ChannelsAdapter {
}
};
manager.route_payload(header.channel_id, payload).await;
skipped_since_last_valid = 0;
}
Err(e) => {
if e.kind() == std::io::ErrorKind::UnexpectedEof {