diff --git a/Cargo.lock b/Cargo.lock index fbe24eee50..a2113324a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13167,6 +13167,7 @@ dependencies = [ "crossbeam-utils", "derivative", "dyn-clone", + "flate2", "futures 0.3.31", "hostname 0.4.0", "indexmap 2.7.0", @@ -13190,6 +13191,7 @@ dependencies = [ "tracing-test", "vector-config", "vrl", + "zstd 0.13.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7a9c14129b..5930cb3aa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -494,7 +494,7 @@ vrl.workspace = true rustls = "0.23" tokio-rustls = "0.26" wiremock = "0.6.2" -zstd = { version = "0.13.0", default-features = false } +zstd.workspace = true [patch.crates-io] mlua = { git = "https://github.com/janmejay-s1/mlua.git", branch = "mlua_0.10.2_list_iter_safety" } @@ -656,10 +656,10 @@ sources-metrics = [ sources-amqp = ["lapin"] sources-apache_metrics = ["sources-utils-http-client"] sources-aws_ecs_metrics = ["sources-utils-http-client"] -sources-aws_kinesis_firehose = ["dep:base64"] +sources-aws_kinesis_firehose = ["dep:base64", "sources-utils-http-encoding"] sources-aws_s3 = ["aws-core", "dep:aws-sdk-sqs", "dep:aws-sdk-s3", "dep:semver", "dep:async-compression", "sources-aws_sqs", "tokio-util/io"] sources-aws_sqs = ["aws-core", "dep:aws-sdk-sqs"] -sources-datadog_agent = ["sources-utils-http-error", "protobuf-build", "dep:prost"] +sources-datadog_agent = ["sources-utils-http-encoding", "sources-utils-http-error", "protobuf-build", "dep:prost"] sources-demo_logs = ["dep:fakedata"] sources-dnstap = ["sources-utils-net-tcp", "dep:base64", "dep:hickory-proto", "dep:dnsmsg-parser", "dep:dnstap-parser", "protobuf-build", "dep:prost"] sources-docker_logs = ["docker"] @@ -695,7 +695,7 @@ sources-prometheus-pushgateway = ["sinks-prometheus", "sources-utils-http", "vec sources-pulsar = ["dep:apache-avro", "dep:pulsar"] sources-redis = ["dep:redis"] sources-socket = ["sources-utils-net", "tokio-util/net"] -sources-splunk_hec = ["dep:roaring"] +sources-splunk_hec = ["dep:roaring", "sources-utils-http-encoding"] sources-statsd = ["sources-utils-net", "tokio-util/net"] sources-stdin = ["tokio-util/io"] sources-syslog = ["codecs-syslog", "sources-utils-net", "tokio-util/net"] @@ -891,6 +891,11 @@ sinks-webhdfs = ["dep:opendal"] # Identifies that the build is a nightly build nightly = [] +# Timing-sensitive tests. Kept out of the default suite so CI scheduling noise cannot fail a run; +# enable explicitly (dataplane-build does) to exercise them, and eventually in a dedicated fork of +# the test workflow. +performance-tests = [] + # Integration testing-related features all-integration-tests = [ "amqp-integration-tests", diff --git a/clippy.toml b/clippy.toml index 25937ac641..428ac438ae 100644 --- a/clippy.toml +++ b/clippy.toml @@ -4,11 +4,23 @@ cognitive-complexity-threshold = 75 # https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_method disallowed-methods = [ { path = "std::io::Write::write", reason = "This doesn't handle short writes, use `write_all` instead." }, + { path = "zstd::stream::copy_decode", reason = "Use `vector_common::decompression::CappedDecoder::zstd` (or `CappedDecoder::zstd_http` in HTTP contexts) to enforce the decompression size cap." }, + { path = "zstd::stream::decode_all", reason = "Use `vector_common::decompression::CappedDecoder::zstd` (or `CappedDecoder::zstd_http` in HTTP contexts) to enforce the decompression size cap." }, + { path = "zstd::bulk::decompress", reason = "Use `vector_common::decompression::CappedDecoder::zstd` (or `CappedDecoder::zstd_http` in HTTP contexts) to enforce the decompression size cap." }, + { path = "warp::body::bytes", reason = "Reads the whole request body into memory unbounded. Use `crate::sources::util::http::capped_body()` to cap the compressed body size at the global decompressed-size limit." }, ] +# Decompression limits are per-component configuration, not process state: take them from the +# component's context (`SourceContext` / `SinkContext` / `TransformContext` -> `GlobalOptions`) +# and pass them into the capped wrapper, rather than reading a global. disallowed-types = [ { path = "once_cell::sync::OnceCell", reason = "Use `std::sync::OnceLock` instead." }, { path = "once_cell::unsync::OnceCell", reason = "Use `std::cell::OnceCell` instead." }, { path = "once_cell::sync::Lazy", reason = "Use `std::sync::LazyLock` instead." }, { path = "once_cell::unsync::Lazy", reason = "Use `std::sync::LazyCell` instead." }, + { path = "flate2::read::GzDecoder", reason = "Use `vector_common::decompression::CappedDecoder::gzip` to enforce the decompression size cap." }, + { path = "flate2::read::MultiGzDecoder", reason = "Use `vector_common::decompression::CappedDecoder::gzip` to enforce the decompression size cap." }, + { path = "flate2::read::ZlibDecoder", reason = "Use `vector_common::decompression::CappedDecoder::zlib` to enforce the decompression size cap." }, + { path = "flate2::read::DeflateDecoder", reason = "Use `vector_common::decompression::CappedDecoder` for decompression with the size cap." }, + { path = "zstd::stream::read::Decoder", reason = "Use `vector_common::decompression::CappedDecoder::zstd` (or `CappedDecoder::zstd_http` in HTTP contexts) to enforce the decompression size cap." }, ] diff --git a/lib/codecs/src/actions/decoding/config.rs b/lib/codecs/src/actions/decoding/config.rs index d1e98dacd5..4d4892ae0e 100644 --- a/lib/codecs/src/actions/decoding/config.rs +++ b/lib/codecs/src/actions/decoding/config.rs @@ -1,5 +1,6 @@ use crate::decoding::{DeserializerConfig, FramingConfig}; use serde::{Deserialize, Serialize}; +use vector_common::decompression::CompressionLimits; use vector_core::config::LogNamespace; use super::Decoder; @@ -13,6 +14,12 @@ pub struct DecodingConfig { decoding: DeserializerConfig, /// The namespace used when decoding. log_namespace: LogNamespace, + /// Limits applied by framers that decompress. + /// + /// Defaults to the documented cap; a component with access to its context should override this + /// with `GlobalOptions`' value via [`Self::with_compression_limits`]. + #[serde(default, skip)] + compression_limits: CompressionLimits, } impl DecodingConfig { @@ -27,9 +34,22 @@ impl DecodingConfig { framing, decoding, log_namespace, + compression_limits: CompressionLimits::with_max_decompressed_size_bytes( + vector_common::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES, + ), } } + /// Sets the compression limits framers should decompress under. + /// + /// Take these from the component's context (`cx.globals.limits.compression`) so the deployment + /// controls the cap rather than a process-wide default. + #[must_use] + pub const fn with_compression_limits(mut self, limits: CompressionLimits) -> Self { + self.compression_limits = limits; + self + } + /// Get the decoding configuration. pub const fn config(&self) -> &DeserializerConfig { &self.decoding @@ -43,7 +63,7 @@ impl DecodingConfig { /// Builds a `Decoder` from the provided configuration. pub fn build(&self) -> vector_common::Result { // Build the framer. - let framer = self.framing.build(); + let framer = self.framing.build(self.compression_limits); // Build the deserializer. let deserializer = self.decoding.build()?; diff --git a/lib/codecs/src/decoding/framing/chunked_gelf.rs b/lib/codecs/src/decoding/framing/chunked_gelf.rs index 5166176190..2165467db9 100644 --- a/lib/codecs/src/decoding/framing/chunked_gelf.rs +++ b/lib/codecs/src/decoding/framing/chunked_gelf.rs @@ -2,11 +2,9 @@ use super::{BoxedFramingError, FramingError}; use crate::{BytesDecoder, StreamDecodingError}; use bytes::{Buf, Bytes, BytesMut}; use derivative::Derivative; -use flate2::read::{MultiGzDecoder, ZlibDecoder}; use snafu::{ensure, ResultExt, Snafu}; use std::any::Any; use std::collections::HashMap; -use std::io::Read; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio; @@ -14,33 +12,17 @@ use tokio::task::JoinHandle; use tokio_util::codec::Decoder; use tracing::{debug, trace, warn}; use vector_common::constants::{GZIP_MAGIC, ZLIB_MAGIC}; +use vector_common::decompression::{CappedDecoder, CompressionLimits}; use vector_config::configurable_component; const GELF_MAGIC: &[u8] = &[0x1e, 0x0f]; const GELF_MAX_TOTAL_CHUNKS: u8 = 128; const DEFAULT_TIMEOUT_SECS: f64 = 5.0; -/// Cap on concurrent incomplete messages, bounding the reassembly map. -/// Graylog Server itself has no such cap, so this is sized well above what a -/// legitimate sender holds in flight within the 5s reassembly window. -pub const DEFAULT_PENDING_MESSAGES_LIMIT: usize = 10_000; -/// Cap on one reassembled message. The protocol ceiling is 128 chunks -/// (`GELF_MAX_TOTAL_CHUNKS`) times the 65507-byte max UDP payload, so 8 MiB is -/// above anything the wire format can produce. Matches Graylog's own -/// `decompress_size_limit` default. -pub const DEFAULT_MAX_MESSAGE_LENGTH: usize = 8 * 1024 * 1024; const fn default_timeout_secs() -> f64 { DEFAULT_TIMEOUT_SECS } -const fn default_pending_messages_limit() -> Option { - Some(DEFAULT_PENDING_MESSAGES_LIMIT) -} - -const fn default_max_message_length() -> Option { - Some(DEFAULT_MAX_MESSAGE_LENGTH) -} - /// Config used to build a `ChunkedGelfDecoder`. #[configurable_component] #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -52,12 +34,13 @@ pub struct ChunkedGelfDecoderConfig { impl ChunkedGelfDecoderConfig { /// Build the `ChunkedGelfDecoder` from this configuration. - pub fn build(&self) -> ChunkedGelfDecoder { + pub fn build(&self, compression_limits: CompressionLimits) -> ChunkedGelfDecoder { ChunkedGelfDecoder::new( self.chunked_gelf.timeout_secs, self.chunked_gelf.pending_messages_limit, self.chunked_gelf.max_length, self.chunked_gelf.decompression, + compression_limits, ) } } @@ -75,22 +58,21 @@ pub struct ChunkedGelfDecoderOptions { /// The maximum number of pending incomplete messages. If this limit is reached, the decoder starts /// dropping chunks of new messages, ensuring the memory usage of the decoder's state is bounded. - /// Defaults to 10000. Set explicitly to raise or lower it. - #[serde(default = "default_pending_messages_limit")] - #[derivative(Default(value = "default_pending_messages_limit()"))] + /// If this option is not set, the decoder does not limit the number of pending messages and the memory usage + /// of its messages buffer can grow unbounded. This matches Graylog Server's behavior. + #[serde(default, skip_serializing_if = "vector_core::serde::is_default")] pub pending_messages_limit: Option, /// The maximum length of a single GELF message, in bytes. Messages longer than this length will - /// be dropped. Defaults to 8 MiB, which is above the protocol's own ceiling of 128 chunks per - /// message. + /// be dropped. If this option is not set, the decoder does not limit the length of messages and + /// the per-message memory is unbounded. /// /// Note that a message can be composed of multiple chunks and this limit is applied to the whole /// message, not to individual chunks. /// /// This limit takes only into account the message's payload and the GELF header bytes are excluded from the calculation. /// The message's payload is the concatenation of all the chunks' payloads. - #[serde(default = "default_max_message_length")] - #[derivative(Default(value = "default_max_message_length()"))] + #[serde(default, skip_serializing_if = "vector_core::serde::is_default")] pub max_length: Option, /// Decompression configuration for GELF messages. @@ -226,24 +208,20 @@ impl ChunkedGelfDecompression { Self::None } - pub fn decompress(&self, data: Bytes) -> Result { + pub fn decompress( + &self, + data: Bytes, + limits: &CompressionLimits, + ) -> Result { let decompressed = match self { - Self::Gzip => { - let mut decoder = MultiGzDecoder::new(data.reader()); - let mut decompressed = Vec::new(); - decoder - .read_to_end(&mut decompressed) - .context(GzipDecompressionSnafu)?; - Bytes::from(decompressed) - } - Self::Zlib => { - let mut decoder = ZlibDecoder::new(data.reader()); - let mut decompressed = Vec::new(); - decoder - .read_to_end(&mut decompressed) - .context(ZlibDecompressionSnafu)?; - Bytes::from(decompressed) - } + Self::Gzip => CappedDecoder::gzip(data.reader(), limits) + .decompress() + .map(Bytes::from) + .context(GzipDecompressionSnafu)?, + Self::Zlib => CappedDecoder::zlib(data.reader(), limits) + .decompress() + .map(Bytes::from) + .context(ZlibDecompressionSnafu)?, Self::None => data, }; Ok(decompressed) @@ -316,6 +294,8 @@ impl FramingError for ChunkedGelfDecoderError { /// and [Graylog's go-gelf library](https://github.com/Graylog2/go-gelf/blob/v1/gelf/reader.go). #[derive(Debug, Clone)] pub struct ChunkedGelfDecoder { + /// Limits to decompress a reassembled message under. + compression_limits: CompressionLimits, // We have to use this decoder to read all the bytes from the buffer first and don't let tokio // read it buffered, as tokio FramedRead will not always call the decode method with the // whole message. (see https://docs.rs/tokio-util/latest/src/tokio_util/codec/framed_impl.rs.html#26). @@ -336,10 +316,12 @@ impl ChunkedGelfDecoder { pending_messages_limit: Option, max_length: Option, decompression_config: ChunkedGelfDecompressionConfig, + compression_limits: CompressionLimits, ) -> Self { Self { bytes_decoder: BytesDecoder::new(), decompression_config, + compression_limits, state: Arc::new(Mutex::new(HashMap::new())), timeout: Duration::from_secs_f64(timeout_secs), pending_messages_limit, @@ -493,7 +475,7 @@ impl ChunkedGelfDecoder { .map(|message| { self.decompression_config .get_decompression(&message) - .decompress(message) + .decompress(message, &self.compression_limits) .context(DecompressionSnafu) }) .transpose() @@ -504,9 +486,10 @@ impl Default for ChunkedGelfDecoder { fn default() -> Self { Self::new( DEFAULT_TIMEOUT_SECS, - default_pending_messages_limit(), - default_max_message_length(), + None, + None, ChunkedGelfDecompressionConfig::Auto, + CompressionLimits::default(), ) } } @@ -1297,37 +1280,84 @@ mod tests { assert_eq!(detected_compression, ChunkedGelfDecompression::None); } - #[tokio::test] - async fn defaults_are_finite_and_above_the_protocol_ceiling() { - let options = ChunkedGelfDecoderOptions::default(); - assert_eq!( - options.pending_messages_limit, - Some(DEFAULT_PENDING_MESSAGES_LIMIT) + /// OBE-10706: a GELF payload used to be inflated with an unbounded `read_to_end`, so a small + /// datagram could drive an arbitrarily large allocation. + /// + /// `MultiGzDecoder` walks every concatenated member, so one cheap member repeated past the cap + /// is enough to exceed it — no single oversized member required. + #[test] + fn gzip_decompression_is_capped() { + use vector_common::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + let member = Compression::Gzip.compress(&vec![0u8; 1024 * 1024]); + let members = DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1; + let mut bomb = BytesMut::new(); + for _ in 0..members { + bomb.put_slice(&member); + } + let bomb = bomb.freeze(); + + assert!( + bomb.len() < 1024 * 1024, + "the bomb must stay small on the wire to be a meaningful test, got {} bytes", + bomb.len() ); - assert_eq!(options.max_length, Some(DEFAULT_MAX_MESSAGE_LENGTH)); - // 128 chunks x the 65507-byte max UDP payload is the most the wire format can carry, - // so the default can never reject a well-formed message. - let protocol_ceiling = GELF_MAX_TOTAL_CHUNKS as usize * 65_507; - assert!(DEFAULT_MAX_MESSAGE_LENGTH >= protocol_ceiling); + let error = ChunkedGelfDecompression::Gzip + .decompress(bomb, &CompressionLimits::default()) + .expect_err("a payload inflating past the cap must be rejected"); + + assert!(matches!( + error, + ChunkedGelfDecompressionError::GzipDecompression { .. } + )); } - #[tokio::test] - async fn limits_are_per_message_and_do_not_kill_the_stream() { - // Both are per-message conditions; tearing down the connection would let one bad sender - // drop every other message multiplexed over it. - assert!(ChunkedGelfDecoderError::MaxLengthExceed { - message_id: 1, - sequence_number: 0, - length: 10, - max_length: 5, - } - .can_continue()); - assert!(ChunkedGelfDecoderError::PendingMessagesLimitReached { - message_id: 1, - sequence_number: 0, - pending_messages_limit: 1, + /// The zlib arm needs its own bomb: unlike gzip, zlib has no concatenated-stream form, so the + /// payload must be a single oversized stream. Fed to the encoder in chunks to keep the test's + /// own memory bounded. + #[test] + fn zlib_decompression_is_capped() { + use std::io::Write as IoWrite; + + use vector_common::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1024 * 1024]; + for _ in 0..(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1) { + encoder.write_all(&chunk).unwrap(); } - .can_continue()); + let bomb = Bytes::from(encoder.finish().unwrap()); + + assert!( + bomb.len() < 1024 * 1024, + "the bomb must stay small on the wire to be a meaningful test, got {} bytes", + bomb.len() + ); + + let error = ChunkedGelfDecompression::Zlib + .decompress(bomb, &CompressionLimits::default()) + .expect_err("a payload inflating past the cap must be rejected"); + + assert!(matches!( + error, + ChunkedGelfDecompressionError::ZlibDecompression { .. } + )); + } + + /// The cap must not disturb ordinary traffic. Zlib shares the same `CappedDecoder` wrapper as + /// gzip, so exercising both here covers the wiring of each arm. + #[rstest] + #[case(Compression::Gzip)] + #[case(Compression::Zlib)] + fn decompression_under_the_cap_is_unaffected(#[case] compression: Compression) { + let payload = "the quick brown fox".repeat(1024); + let compressed = compression.compress(&payload); + + let decompressed = ChunkedGelfDecompression::from_magic(&compressed) + .decompress(compressed, &CompressionLimits::default()) + .expect("a payload within the cap must decompress"); + + assert_eq!(decompressed, Bytes::from(payload)); } } diff --git a/lib/codecs/src/decoding/mod.rs b/lib/codecs/src/decoding/mod.rs index 6e2e569135..eb65c59def 100644 --- a/lib/codecs/src/decoding/mod.rs +++ b/lib/codecs/src/decoding/mod.rs @@ -32,6 +32,7 @@ pub use framing::{ }; use smallvec::SmallVec; use std::fmt::Debug; +use vector_common::decompression::CompressionLimits; use vector_config::configurable_component; use vector_core::{ config::{DataType, LogNamespace}, @@ -175,7 +176,10 @@ impl From for FramingConfig { impl FramingConfig { /// Build the `Framer` from this configuration. - pub fn build(&self) -> Framer { + /// + /// `compression_limits` is only consumed by framers that decompress (currently + /// `chunked_gelf`); it is threaded through here so no framer has to reach for process state. + pub fn build(&self, compression_limits: CompressionLimits) -> Framer { match self { FramingConfig::Bytes => Framer::Bytes(BytesDecoderConfig.build()), FramingConfig::CharacterDelimited(config) => Framer::CharacterDelimited(config.build()), @@ -188,7 +192,9 @@ impl FramingConfig { ), FramingConfig::NewlineDelimited(config) => Framer::NewlineDelimited(config.build()), FramingConfig::OctetCounting(config) => Framer::OctetCounting(config.build()), - FramingConfig::ChunkedGelf(config) => Framer::ChunkedGelf(config.build()), + FramingConfig::ChunkedGelf(config) => { + Framer::ChunkedGelf(config.build(compression_limits)) + } FramingConfig::StrataSnappy(config) => Framer::StrataSnappy(config.build()), } } diff --git a/lib/vector-common/Cargo.toml b/lib/vector-common/Cargo.toml index fb865ddc96..359dece03b 100644 --- a/lib/vector-common/Cargo.toml +++ b/lib/vector-common/Cargo.toml @@ -41,6 +41,7 @@ bytes = { version = "1.9.0", default-features = false } chrono.workspace = true crossbeam-utils = { version = "0.8.20", default-features = false } derivative = { version = "2.2.0", default-features = false } +flate2.workspace = true futures.workspace = true indexmap.workspace = true metrics.workspace = true @@ -60,6 +61,7 @@ snafu.workspace = true regex.workspace = true tokio-util.workspace = true serde_with.workspace = true +zstd.workspace = true [dev-dependencies] futures = { version = "0.3.31", default-features = false, features = ["async-await"] } diff --git a/lib/vector-common/src/decompression.rs b/lib/vector-common/src/decompression.rs new file mode 100644 index 0000000000..9b58bdc84a --- /dev/null +++ b/lib/vector-common/src/decompression.rs @@ -0,0 +1,827 @@ +//! Shared decompression limits used to prevent decompression-bomb (`DoS`) attacks. +//! +//! A length or compressed payload read from an untrusted peer must never drive an unbounded +//! in-memory allocation. This module owns the global decompressed-size cap and the helpers that +//! enforce it, so every source and codec that decompresses untrusted input shares a single, +//! consistently-configured limit. +//! +//! # Usage +//! +//! Wrap any decompression at an untrusted boundary with the appropriate [`CappedDecoder`] +//! constructor and call [`CappedDecoder::decompress`]: +//! +//! ```rust,ignore +//! let data = CappedDecoder::gzip(reader).decompress()?; +//! let data = CappedDecoder::zlib(reader).decompress()?; +//! let data = CappedDecoder::zstd(reader)?.decompress()?; +//! ``` +//! +//! The constructors enforce the global decompressed-size cap so that a compression bomb cannot +//! drive unbounded allocation. + +// Raw decoder types (flate2 / zstd) are only allowed in this module, which wraps them safely. +#![expect( + clippy::disallowed_types, + reason = "this module implements CappedDecoder, the safe wrapper around raw decoders; raw types may only appear here" +)] + +use std::{ + fmt, + io::{self, Read}, +}; + +use vector_config::configurable_component; + +use flate2::read::{MultiGzDecoder, ZlibDecoder}; + +/// Default cap on the size of any decompressed payload. +/// +/// Prevents a compressed "bomb" from causing unbounded memory growth. +pub const DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES: usize = 100 * 1024 * 1024; + +/// Limits applied wherever Vector decompresses data it did not produce. +/// +/// Carried in `GlobalOptions`, so every component reaches it through its own context +/// (`SourceContext` / `SinkContext` / `TransformContext`) rather than reading process state. That +/// keeps the limit configurable per deployment and lets a test drive a decoder at any cap simply +/// by constructing this. +#[configurable_component] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CompressionLimits { + /// Maximum number of bytes a single payload may occupy once decompressed. + /// + /// Sources that decompress incoming payloads (gzip, zlib, zstd) enforce this so a compressed + /// "bomb" cannot exhaust memory. A payload exceeding it is rejected. + #[serde(default = "default_max_decompressed_size_bytes")] + pub max_decompressed_size_bytes: usize, +} + +const fn default_max_decompressed_size_bytes() -> usize { + DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES +} + +impl Default for CompressionLimits { + fn default() -> Self { + Self { + max_decompressed_size_bytes: DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES, + } + } +} + +impl CompressionLimits { + /// Builds limits with an explicit decompressed-size cap. Mostly useful in tests. + #[must_use] + pub const fn with_max_decompressed_size_bytes(max_decompressed_size_bytes: usize) -> Self { + Self { + max_decompressed_size_bytes, + } + } + + /// Largest compressed frame that could legitimately decompress within the cap, using zlib's + /// worst-case expansion of 13.5% + 11 bytes. + /// + /// Lets a caller reject an oversized declared payload before buffering it, without rejecting a + /// valid frame whose decompressed content stays within the cap. + /// + /// See ("the worst case ... can result in an expansion of at + /// most 13.5%, plus eleven bytes"). + #[must_use] + #[allow(clippy::cast_possible_truncation)] // derives from a usize; saturating math keeps it in range + pub const fn max_zlib_compressed_frame_size_bytes(&self) -> usize { + (self.max_decompressed_size_bytes as u64) + .saturating_mul(1135) + .saturating_div(1000) + .saturating_add(11) as usize + } + + /// Largest compressed frame that could legitimately decompress within the cap, using snappy's + /// worst-case expansion of `32 + n + n/6`. + /// + /// Snappy's raw API decompresses a whole buffer in one allocation, so there is nothing to + /// stream a cap against; the input has to be bounded before it is read. Mirrors + /// [`Self::max_zlib_compressed_frame_size_bytes`]. + /// + /// See (`MaxCompressedLength`). + #[must_use] + #[allow(clippy::cast_possible_truncation)] // derives from a usize; saturating math keeps it in range + pub const fn max_snappy_compressed_frame_size_bytes(&self) -> usize { + let max = self.max_decompressed_size_bytes as u64; + max.saturating_add(max.saturating_div(6)).saturating_add(32) as usize + } + + /// Smallest zstd `window_log_max` capable of representing the cap. + /// + /// A zstd frame declares a window the decoder must allocate *before* producing output, so an + /// output-size cap alone cannot bound it. Protocol-neutral: transports with a tighter, + /// spec-mandated window (HTTP, see [`Self::http_zstd_window_log`]) clamp further. + #[must_use] + #[allow(clippy::manual_clamp)] // `usize::clamp` is not const; the manual form keeps this const + pub const fn zstd_window_log(&self) -> Option { + const MIN_ZSTD_WINDOW_LOG: u32 = 10; + const MAX_ZSTD_WINDOW_LOG: u32 = 31; + + match self.max_decompressed_size_bytes.checked_sub(1) { + // A zero cap has no representable window; fall back to the smallest rather than + // leaving the allocation guard unset. + None => Some(MIN_ZSTD_WINDOW_LOG), + Some(max_index) => { + let window_log = usize::BITS - max_index.leading_zeros(); + let clamped = if window_log < MIN_ZSTD_WINDOW_LOG { + MIN_ZSTD_WINDOW_LOG + } else if window_log > MAX_ZSTD_WINDOW_LOG { + MAX_ZSTD_WINDOW_LOG + } else { + window_log + }; + Some(clamped) + } + } + } + + /// Like [`Self::zstd_window_log`] but clamped to the RFC 9659 HTTP ceiling + /// ([`HTTP_ZSTD_WINDOW_LOG_MAX`]). Use for HTTP `Content-Encoding: zstd`. + #[must_use] + pub const fn http_zstd_window_log(&self) -> Option { + match self.zstd_window_log() { + Some(window) if window > HTTP_ZSTD_WINDOW_LOG_MAX => Some(HTTP_ZSTD_WINDOW_LOG_MAX), + other => other, + } + } +} + +/// Operational limits carried in `GlobalOptions`. +/// +/// A single place to hang caps that components need but should not read from process state. Add +/// further groups here rather than introducing new globals. +#[configurable_component] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OperationalLimits { + /// Limits applied wherever Vector decompresses data it did not produce. + #[configurable(derived)] + #[serde(default)] + pub compression: CompressionLimits, +} + +/// Per-component override of [`CompressionLimits`]. +/// +/// Every field is optional so that "not set" stays distinct from "set to the default". Without +/// that distinction a component that says nothing would look like it were asking for the default +/// value, and could not be told apart from one that deliberately asked for it. +#[configurable_component] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CompressionLimitsOverride { + /// Overrides [`CompressionLimits::max_decompressed_size_bytes`] for this component. + /// + /// A value below the global limit always applies. A value above it is clamped back to the + /// global limit unless Vector is started with `--allow-component-limit-overrides`, so that a + /// ceiling chosen by whoever runs the process cannot be lifted by editing pipeline config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_decompressed_size_bytes: Option, +} + +/// Per-component override of [`OperationalLimits`]. +/// +/// Attached to every source, transform and sink. Unset fields inherit the global value. +#[configurable_component] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OperationalLimitsOverride { + /// Overrides the global decompression limits for this component. + #[configurable(derived)] + #[serde(default)] + pub compression: CompressionLimitsOverride, +} + +impl OperationalLimitsOverride { + /// Whether this component asked for anything at all. + #[must_use] + pub fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +/// A component asking for a limit looser than the global one allows. +/// +/// Reported so the same raise can be surfaced as a config warning (at startup, reload and +/// `vector validate`) and acted on when the topology is built, without the two disagreeing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LimitRaise { + /// Config path of the limit, relative to the component, for use in messages. + pub field: &'static str, + /// What the component asked for. + pub requested: usize, + /// What the global limit permits. + pub allowed: usize, +} + +impl fmt::Display for LimitRaise { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} = {}, above the global limit of {}", + self.field, self.requested, self.allowed + ) + } +} + +impl OperationalLimits { + /// Applies a component's override to these global limits. + /// + /// Returns the limits the component should actually run under, together with every raise it + /// asked for. A raise is granted only when `allow_raise` is set; otherwise it is clamped back + /// to the global value. Lowering is always granted — a component may be stricter than the + /// deployment, never looser than the operator permits. + /// + /// Raises are reported whether or not they were granted, so a caller can warn in both cases. + #[must_use] + pub fn resolve( + &self, + over: &OperationalLimitsOverride, + allow_raise: bool, + ) -> (Self, Vec) { + let mut resolved = *self; + let mut raises = Vec::new(); + + if let Some(requested) = over.compression.max_decompressed_size_bytes { + let allowed = self.compression.max_decompressed_size_bytes; + if requested > allowed { + raises.push(LimitRaise { + field: "limits.compression.max_decompressed_size_bytes", + requested, + allowed, + }); + } + resolved.compression.max_decompressed_size_bytes = + if requested > allowed && !allow_raise { + allowed + } else { + requested + }; + } + + (resolved, raises) + } +} + +/// RFC 9659 window ceiling for zstd under HTTP `Content-Encoding: zstd`: conformant senders use a +/// `Window_Size` of at most 8 MB (2^23) and decoders need only support up to that. Governs HTTP +/// content coding only; other transports (gRPC/OTLP, whose clients are not bound by RFC 9659 and +/// may legitimately use larger windows) are not clamped to it. +/// See . +pub const HTTP_ZSTD_WINDOW_LOG_MAX: u32 = 23; + +/// Error raised when a decompressed payload would exceed the configured size cap. +/// +/// Surfaced (wrapped in [`io::Error`]) by [`CappedDecoder::decompress`] and the [`CappedReader`] +/// returned by [`CappedDecoder::into_reader`]. Use [`DecompressedSizeLimitExceeded::is`] to detect +/// it and distinguish an oversized-input fault from an unrelated I/O error. +#[derive(Debug)] +pub struct DecompressedSizeLimitExceeded; + +impl fmt::Display for DecompressedSizeLimitExceeded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("decompressed size exceeds the configured limit") + } +} + +impl std::error::Error for DecompressedSizeLimitExceeded {} + +impl DecompressedSizeLimitExceeded { + /// Returns whether `error` was raised because decompression hit the size cap. + #[must_use] + pub fn is(error: &io::Error) -> bool { + fn is_marker(source: &(dyn std::error::Error + Send + Sync + 'static)) -> bool { + source.is::() + } + + error.get_ref().is_some_and(is_marker) + } +} + +/// A size-capped decompression reader. +/// +/// Wraps any `R: Read` (typically a raw decoder like `MultiGzDecoder` or `ZlibDecoder`) and +/// enforces the configured decompressed-size cap so that a compression bomb cannot drive +/// unbounded memory allocation. +/// +/// Construct via the typed class methods ([`CappedDecoder::gzip`], [`CappedDecoder::zlib`], +/// [`CappedDecoder::zstd`]) rather than by wrapping a raw decoder directly. Read the whole payload +/// into memory with [`CappedDecoder::decompress`], or stream it through [`CappedDecoder::into_reader`]. +pub struct CappedDecoder { + inner: io::Take, + limit: usize, +} + +impl CappedDecoder { + fn with_limit(reader: R, limit: usize) -> Self { + Self { + inner: reader.take((limit as u64).saturating_add(1)), + limit, + } + } + + /// Reads all decompressed bytes into a `Vec`, returning an error if the output exceeds the + /// configured cap. + /// + /// # Errors + /// + /// Returns an error if reading from the underlying decoder fails, or + /// [`DecompressedSizeLimitExceeded`] if the decompressed output exceeds the cap. + pub fn decompress(self) -> io::Result> { + let mut buf = Vec::new(); + self.into_reader().read_to_end(&mut buf)?; + Ok(buf) + } + + /// Converts the decoder into a streaming [`CappedReader`] that enforces the cap as bytes are + /// read, rather than buffering the whole payload up front. + /// + /// Prefer this over consuming a raw decoder directly: the returned reader errors out (instead + /// of silently truncating) the moment the decompressed output would exceed the cap, so a + /// streaming consumer such as [`io::copy`], `serde_json::from_reader`, or `BufReader` cannot + /// process a truncated-but-valid-looking payload. + pub fn into_reader(self) -> CappedReader { + CappedReader { + inner: self.inner, + limit: self.limit, + consumed: 0, + } + } +} + +/// A streaming, size-capped decompression reader returned by [`CappedDecoder::into_reader`]. +/// +/// Yields decompressed bytes incrementally and returns a [`DecompressedSizeLimitExceeded`] error +/// (wrapped in [`io::Error`]) as soon as the cumulative output would exceed the cap. +pub struct CappedReader { + inner: io::Take, + limit: usize, + consumed: usize, +} + +/// The reader type produced by [`CappedDecoder::zstd`] and friends via +/// [`CappedDecoder::into_reader`]. +/// +/// Naming this type otherwise requires spelling the raw `zstd` decoder, which the +/// `disallowed-types` lint forbids outside this module. Store this alias instead of the raw type +/// when a struct needs to hold a capped zstd reader. +pub type CappedZstdReader = CappedReader>>; + +impl Read for CappedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + // The underlying reader is bounded one byte past the cap, so reading beyond `limit` is the + // unambiguous signal that the payload is oversized. + let n = self.inner.read(buf)?; + self.consumed = self.consumed.saturating_add(n); + if self.consumed > self.limit { + return Err(io::Error::other(DecompressedSizeLimitExceeded)); + } + Ok(n) + } +} + +impl CappedDecoder> { + /// Creates a capped gzip decoder. + pub fn gzip(reader: S, limits: &CompressionLimits) -> Self { + Self::with_limit( + MultiGzDecoder::new(reader), + limits.max_decompressed_size_bytes, + ) + } +} + +impl CappedDecoder> { + /// Creates a capped zlib/deflate decoder. + pub fn zlib(reader: S, limits: &CompressionLimits) -> Self { + Self::with_limit(ZlibDecoder::new(reader), limits.max_decompressed_size_bytes) + } +} + +impl CappedDecoder>> { + /// Creates a capped zstd decoder. + /// + /// Also constrains the decoder's internal window allocation via `window_log_max` so a crafted + /// frame cannot request a large window before the decompressed-size cap trips. The window is + /// derived from the cap only ([`CompressionLimits::zstd_window_log`]); for HTTP + /// `Content-Encoding: zstd` use [`zstd_http`](Self::zstd_http), which applies the tighter + /// RFC 9659 ceiling. + /// + /// # Errors + /// + /// Returns an error if the zstd decoder cannot be initialized (e.g. invalid header). + pub fn zstd(reader: S, limits: &CompressionLimits) -> io::Result { + Self::zstd_with_window_log( + reader, + limits.max_decompressed_size_bytes, + limits.zstd_window_log(), + ) + } + + /// Creates a capped zstd decoder for HTTP `Content-Encoding: zstd`, clamping the decoder + /// window to the RFC 9659 8 MB ceiling ([`CompressionLimits::http_zstd_window_log`]). + /// + /// # Errors + /// + /// Returns an error if the zstd decoder cannot be initialized (e.g. invalid header). + pub fn zstd_http(reader: S, limits: &CompressionLimits) -> io::Result { + Self::zstd_with_window_log( + reader, + limits.max_decompressed_size_bytes, + limits.http_zstd_window_log(), + ) + } + + fn zstd_with_window_log( + reader: S, + limit: usize, + window_log_max: Option, + ) -> io::Result { + let mut decoder = zstd::stream::read::Decoder::new(reader)?; + if let Some(window_log_max) = window_log_max { + decoder.window_log_max(window_log_max)?; + } + Ok(Self::with_limit(decoder, limit)) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use flate2::{write::GzEncoder, write::ZlibEncoder, Compression}; + + use super::*; + + /// Compresses `len` zero bytes with gzip. Highly compressible, so the wire form is tiny + /// relative to the output — the shape of a decompression bomb. + fn gzip_bomb(len: usize) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(&vec![0u8; len]).unwrap(); + encoder.finish().unwrap() + } + + fn gzip_compress(payload: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(payload).unwrap(); + encoder.finish().unwrap() + } + + fn zlib_compress(payload: &[u8]) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(payload).unwrap(); + encoder.finish().unwrap() + } + + fn zlib_bomb(len: usize) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(&vec![0u8; len]).unwrap(); + encoder.finish().unwrap() + } + + #[test] + fn gzip_within_limit_decompresses() { + let payload = gzip_bomb(1024); + let out = CappedDecoder::gzip( + payload.as_slice(), + &CompressionLimits::with_max_decompressed_size_bytes(1024), + ) + .decompress() + .expect("payload exactly at the limit must decompress"); + assert_eq!(out.len(), 1024); + } + + #[test] + fn gzip_over_limit_is_rejected() { + let payload = gzip_bomb(64 * 1024); + let error = CappedDecoder::gzip( + payload.as_slice(), + &CompressionLimits::with_max_decompressed_size_bytes(1024), + ) + .decompress() + .expect_err("payload over the limit must be rejected"); + assert!( + DecompressedSizeLimitExceeded::is(&error), + "expected the size-limit marker, got {error}" + ); + } + + /// The load-bearing case for `MultiGzDecoder`: a single member's size does not bound the + /// attack, because the decoder walks every concatenated member. The cap must apply to the + /// summed output, not per member. + #[test] + fn gzip_concatenated_members_are_capped_in_aggregate() { + let member = gzip_bomb(1024); + let mut payload = Vec::new(); + for _ in 0..8 { + payload.extend_from_slice(&member); + } + + // Each member on its own is within the limit; together they are not. + let error = CappedDecoder::gzip( + payload.as_slice(), + &CompressionLimits::with_max_decompressed_size_bytes(4096), + ) + .decompress() + .expect_err("concatenated members must be capped in aggregate"); + assert!( + DecompressedSizeLimitExceeded::is(&error), + "expected the size-limit marker, got {error}" + ); + } + + #[test] + fn zlib_over_limit_is_rejected() { + let payload = zlib_bomb(64 * 1024); + let error = CappedDecoder::zlib( + payload.as_slice(), + &CompressionLimits::with_max_decompressed_size_bytes(1024), + ) + .decompress() + .expect_err("payload over the limit must be rejected"); + assert!(DecompressedSizeLimitExceeded::is(&error)); + } + + /// A single frame declaring a window larger than the cap is refused by the `window_log_max` + /// clamp, before any output buffer is allocated. + #[test] + fn zstd_oversized_window_is_rejected() { + let payload = zstd::encode_all(vec![0u8; 64 * 1024].as_slice(), 19).unwrap(); + let result = CappedDecoder::zstd( + payload.as_slice(), + &CompressionLimits::with_max_decompressed_size_bytes(1024), + ) + .expect("decoder init") + .decompress(); + assert!( + result.is_err(), + "a frame whose window exceeds the cap must not decode" + ); + } + + /// Concatenated frames each fit the window clamp, so the aggregate output is what the size cap + /// has to catch. + #[test] + fn zstd_over_limit_is_rejected() { + // Level 1 keeps each frame's declared window under the 1 MiB cap's 2^20 ceiling, so the + // window clamp stays out of the way and the size cap is what rejects the payload. + let frame = zstd::encode_all(vec![0u8; 256 * 1024].as_slice(), 1).unwrap(); + let mut payload = Vec::new(); + for _ in 0..8 { + payload.extend_from_slice(&frame); + } + + let error = CappedDecoder::zstd( + payload.as_slice(), + &CompressionLimits::with_max_decompressed_size_bytes(1024 * 1024), + ) + .expect("decoder init") + .decompress() + .expect_err("payload over the limit must be rejected"); + assert!( + DecompressedSizeLimitExceeded::is(&error), + "expected the size-limit marker, got {error}" + ); + } + + /// A streaming consumer must see an error rather than a truncated-but-plausible payload. + #[test] + fn streaming_reader_errors_instead_of_truncating() { + let payload = gzip_bomb(64 * 1024); + let mut reader = CappedDecoder::gzip( + payload.as_slice(), + &CompressionLimits::with_max_decompressed_size_bytes(1024), + ) + .into_reader(); + + let mut sink = Vec::new(); + let error = std::io::copy(&mut reader, &mut sink) + .expect_err("streaming past the limit must error, not silently truncate"); + assert!(DecompressedSizeLimitExceeded::is(&error)); + assert!( + sink.len() <= 1024, + "must not hand more than the limit to the consumer, got {}", + sink.len() + ); + } + + /// An unrelated I/O failure must not be mistaken for the size cap. + #[test] + fn unrelated_io_error_is_not_a_limit_error() { + let error = CappedDecoder::gzip( + b"not gzip at all".as_slice(), + &CompressionLimits::with_max_decompressed_size_bytes(1024), + ) + .decompress() + .expect_err("invalid gzip must fail"); + assert!(!DecompressedSizeLimitExceeded::is(&error)); + } + + #[test] + fn zstd_window_log_tracks_the_cap() { + // 100 MiB needs a 2^27 window; the HTTP variant is clamped to RFC 9659's 2^23. + assert_eq!( + CompressionLimits::with_max_decompressed_size_bytes(100 * 1024 * 1024) + .zstd_window_log(), + Some(27) + ); + assert_eq!( + CompressionLimits::with_max_decompressed_size_bytes(100 * 1024 * 1024) + .http_zstd_window_log(), + Some(HTTP_ZSTD_WINDOW_LOG_MAX) + ); + // A zero cap clamps to the tightest window rather than disabling the guard. + assert_eq!( + CompressionLimits::with_max_decompressed_size_bytes(0).zstd_window_log(), + Some(10) + ); + } + + #[test] + fn default_cap_is_used_when_unset() { + assert_eq!( + CompressionLimits::default().max_decompressed_size_bytes, + DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES + ); + } + + /// Cross-check every capped decoder against the raw decoder it wraps. + /// + /// The rest of the suite exercises `CappedDecoder` against itself, and the codebase's other + /// tests decode with the raw decoders — so nothing else would notice if a capped wrapper + /// decoded *correctly-sized but wrong* output (a short read treated as EOF, an off-by-one in + /// the `Take` bound, a dropped final block). Using the raw decoder as the reference is the + /// point: if the two ever disagree, the wrapper is at fault. + #[test] + fn capped_decoders_agree_with_the_raw_decoders_they_wrap() { + // Sizes chosen to straddle the internal buffer boundaries a short read would expose: + // empty, sub-block, exactly 8 KiB, and a size that is not a multiple of any block. + for size in [0usize, 1, 100, 8 * 1024, 8 * 1024 + 1, 70_000] { + // Mixed content, not zeroes: a run of zeroes can hide a truncation that repeats. + let payload: Vec = (0..size).map(|i| (i % 251) as u8).collect(); + + // gzip + let compressed = gzip_compress(&payload); + let mut expected = Vec::new(); + MultiGzDecoder::new(&compressed[..]) + .read_to_end(&mut expected) + .expect("raw gzip decode"); + let actual = CappedDecoder::gzip(&compressed[..], &CompressionLimits::default()) + .decompress() + .expect("capped gzip decode"); + assert_eq!(actual, expected, "gzip disagreed at {size} bytes"); + assert_eq!(actual, payload, "gzip round-trip lost data at {size} bytes"); + + // zlib + let compressed = zlib_compress(&payload); + let mut expected = Vec::new(); + ZlibDecoder::new(&compressed[..]) + .read_to_end(&mut expected) + .expect("raw zlib decode"); + let actual = CappedDecoder::zlib(&compressed[..], &CompressionLimits::default()) + .decompress() + .expect("capped zlib decode"); + assert_eq!(actual, expected, "zlib disagreed at {size} bytes"); + assert_eq!(actual, payload, "zlib round-trip lost data at {size} bytes"); + + // zstd + let compressed = zstd::stream::encode_all(&payload[..], 3).expect("zstd encode"); + let mut expected = Vec::new(); + zstd::stream::read::Decoder::new(&compressed[..]) + .expect("raw zstd decoder") + .read_to_end(&mut expected) + .expect("raw zstd decode"); + let actual = CappedDecoder::zstd(&compressed[..], &CompressionLimits::default()) + .expect("capped zstd decoder") + .decompress() + .expect("capped zstd decode"); + assert_eq!(actual, expected, "zstd disagreed at {size} bytes"); + assert_eq!(actual, payload, "zstd round-trip lost data at {size} bytes"); + } + } + + /// The streaming reader must agree with the buffered path, so a caller that streams through + /// `into_reader` cannot silently receive different bytes than one calling `decompress`. + #[test] + fn streaming_reader_agrees_with_the_buffered_path() { + let payload: Vec = (0..70_000).map(|i| (i % 251) as u8).collect(); + let compressed = gzip_compress(&payload); + + let buffered = CappedDecoder::gzip(&compressed[..], &CompressionLimits::default()) + .decompress() + .expect("buffered decode"); + + let mut streamed = Vec::new(); + CappedDecoder::gzip(&compressed[..], &CompressionLimits::default()) + .into_reader() + .read_to_end(&mut streamed) + .expect("streamed decode"); + + assert_eq!(streamed, buffered); + assert_eq!(streamed, payload); + } + + // ---- component limit overrides ------------------------------------------------------------ + + fn global(max: usize) -> OperationalLimits { + OperationalLimits { + compression: CompressionLimits::with_max_decompressed_size_bytes(max), + } + } + + fn asking(max: usize) -> OperationalLimitsOverride { + OperationalLimitsOverride { + compression: CompressionLimitsOverride { + max_decompressed_size_bytes: Some(max), + }, + } + } + + /// The common case: the component says nothing, so it runs under the deployment's limits and + /// there is nothing to warn about. + #[test] + fn an_empty_override_inherits_the_global_limits() { + let (resolved, raises) = global(1024).resolve(&OperationalLimitsOverride::default(), false); + + assert_eq!(resolved, global(1024)); + assert!(raises.is_empty()); + assert!(OperationalLimitsOverride::default().is_empty()); + } + + /// A component may always be stricter than the deployment. + #[test] + fn lowering_is_always_granted() { + for allow_raise in [false, true] { + let (resolved, raises) = global(1024).resolve(&asking(512), allow_raise); + + assert_eq!(resolved.compression.max_decompressed_size_bytes, 512); + assert!(raises.is_empty(), "lowering is not a raise"); + } + } + + /// The whole point of the clamp: pipeline config cannot lift a ceiling the operator set. + #[test] + fn raising_is_clamped_by_default() { + let (resolved, raises) = global(1024).resolve(&asking(4096), false); + + assert_eq!( + resolved.compression.max_decompressed_size_bytes, 1024, + "the global limit must survive a component asking for more" + ); + assert_eq!( + raises, + vec![LimitRaise { + field: "limits.compression.max_decompressed_size_bytes", + requested: 4096, + allowed: 1024, + }] + ); + } + + /// The escape hatch, which only whoever starts the process can open. + #[test] + fn raising_is_granted_when_explicitly_allowed() { + let (resolved, raises) = global(1024).resolve(&asking(4096), true); + + assert_eq!(resolved.compression.max_decompressed_size_bytes, 4096); + assert_eq!( + raises.len(), + 1, + "a granted raise is still reported, so it can be warned about" + ); + } + + /// Asking for exactly the global value is not a raise, so it must not warn. + #[test] + fn matching_the_global_limit_is_not_a_raise() { + let (resolved, raises) = global(1024).resolve(&asking(1024), false); + + assert_eq!(resolved, global(1024)); + assert!(raises.is_empty()); + } + + /// A component that omits the field must not be treated as having asked for the default. With + /// a global below the default, a naive merge would report a raise nobody requested. + #[test] + fn an_unset_field_is_not_read_as_a_request_for_the_default() { + let strict = global(1024); + assert!( + strict.compression.max_decompressed_size_bytes < DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES + ); + + let (resolved, raises) = strict.resolve(&OperationalLimitsOverride::default(), false); + + assert_eq!(resolved, strict); + assert!(raises.is_empty(), "silence is not a request"); + } + + /// An omitted override must deserialise to "unset", not to the default value. + #[test] + fn an_omitted_override_deserialises_as_unset() { + let empty: OperationalLimitsOverride = serde_json::from_str("{}").unwrap(); + assert!(empty.is_empty()); + assert_eq!(empty.compression.max_decompressed_size_bytes, None); + + let set: OperationalLimitsOverride = + serde_json::from_str(r#"{"compression":{"max_decompressed_size_bytes":512}}"#).unwrap(); + assert_eq!(set.compression.max_decompressed_size_bytes, Some(512)); + } +} diff --git a/lib/vector-common/src/lib.rs b/lib/vector-common/src/lib.rs index 9264895f84..756b55ae4b 100644 --- a/lib/vector-common/src/lib.rs +++ b/lib/vector-common/src/lib.rs @@ -24,6 +24,8 @@ pub mod config; pub mod constants; +pub mod decompression; + #[cfg(feature = "conversion")] pub use vrl::compiler::TimeZone; diff --git a/lib/vector-core/src/config/global_options.rs b/lib/vector-core/src/config/global_options.rs index 7c6ea2c074..65dfa7f5eb 100644 --- a/lib/vector-core/src/config/global_options.rs +++ b/lib/vector-core/src/config/global_options.rs @@ -1,6 +1,7 @@ use std::{fs::DirBuilder, path::PathBuf, time::Duration}; use snafu::{ResultExt, Snafu}; +use vector_common::decompression::OperationalLimits; use vector_common::TimeZone; use crate::chkpts::StoreConfig as CheckpointConfig; use vector_config::configurable_component; @@ -76,6 +77,11 @@ pub struct GlobalOptions { #[serde(default, skip_serializing_if = "crate::serde::is_default")] pub proxy: ProxyConfig, + /// Operational limits applied across components. + #[configurable(derived)] + #[serde(default, skip_serializing_if = "crate::serde::is_default")] + pub limits: OperationalLimits, + /// Controls how acknowledgements are handled for all sinks by default. /// /// See [End-to-end Acknowledgements][e2e_acks] for more information on how Vector handles event @@ -241,6 +247,13 @@ impl GlobalOptions { acknowledgements: self.acknowledgements.merge_default(&with.acknowledgements), timezone: self.timezone.or(with.timezone), proxy: self.proxy.merge(&with.proxy), + // Whichever file set a non-default wins; if both did, this file's value is kept. + // Same precedence as the `Option` fields above, which use `or`. + limits: if self.limits == OperationalLimits::default() { + with.limits + } else { + self.limits + }, expire_metrics: self.expire_metrics.or(with.expire_metrics), expire_metrics_secs: self.expire_metrics_secs.or(with.expire_metrics_secs), checkpoint, diff --git a/lib/vector-core/src/sink/compressor.rs b/lib/vector-core/src/sink/compressor.rs index 71e45a0cc0..f2dda9cb7b 100644 --- a/lib/vector-core/src/sink/compressor.rs +++ b/lib/vector-core/src/sink/compressor.rs @@ -4,7 +4,7 @@ use std::{io, io::Read, io::BufWriter}; use bytes::{BufMut, BytesMut, Bytes, Buf}; use flate2::write::{GzEncoder, ZlibEncoder}; -use flate2::read::{GzDecoder, ZlibDecoder}; +use vector_common::decompression::{CappedDecoder, CompressionLimits}; use crate::sink::compression::Compression; use super::{ snappy::SnappyEncoder, snappy::SnappyDecoder, @@ -196,36 +196,30 @@ impl From for Compressor { pub enum Decompressor { Plain, - Gzip, - Zlib, - Zstd, - Snappy, + Gzip(CompressionLimits), + Zlib(CompressionLimits), + Zstd(CompressionLimits), + Snappy(CompressionLimits), } impl Decompressor { pub fn decompress(&self, bytes: Bytes) -> io::Result { match self { Decompressor::Plain => Ok(bytes), - Decompressor::Gzip => { - let mut decoder = GzDecoder::new(io::Cursor::new(bytes)); + Decompressor::Gzip(limits) => Ok(CappedDecoder::gzip(io::Cursor::new(bytes), limits) + .decompress()? + .into()), + Decompressor::Zlib(limits) => Ok(CappedDecoder::zlib(bytes.reader(), limits) + .decompress()? + .into()), + Decompressor::Zstd(limits) => { + let mut decoder = ZstdDecoder::new(bytes.reader(), limits)?; let mut buff: Vec = Vec::with_capacity(OUTPUT_BUFFER_CAPACITY); Read::read_to_end(&mut decoder, &mut buff)?; Ok(buff.into()) }, - Decompressor::Zlib => { - let mut decoder = ZlibDecoder::new(bytes.reader()); - let mut buff: Vec = Vec::with_capacity(OUTPUT_BUFFER_CAPACITY); - Read::read_to_end(&mut decoder, &mut buff)?; - Ok(buff.into()) - }, - Decompressor::Zstd => { - let mut decoder = ZstdDecoder::new(bytes.reader())?; - let mut buff: Vec = Vec::with_capacity(OUTPUT_BUFFER_CAPACITY); - Read::read_to_end(&mut decoder, &mut buff)?; - Ok(buff.into()) - }, - Decompressor::Snappy => { - let mut decoder = SnappyDecoder::new(bytes.reader()); + Decompressor::Snappy(limits) => { + let mut decoder = SnappyDecoder::new(bytes.reader(), *limits); let mut buff: Vec = Vec::with_capacity(OUTPUT_BUFFER_CAPACITY); Read::read_to_end(&mut decoder, &mut buff)?; Ok(buff.into()) @@ -234,20 +228,25 @@ impl Decompressor { } } -impl From for Decompressor { - fn from(compression: Compression) -> Self { +/// Built from the compression scheme plus the limits to decode under. +/// +/// `CompressionLimits` is `Copy`, so this needs neither a lifetime nor an `Arc`: callers hand over +/// a copy of the one in `GlobalOptions`. +impl From<(Compression, CompressionLimits)> for Decompressor { + fn from((compression, limits): (Compression, CompressionLimits)) -> Self { match compression { Compression::None => Decompressor::Plain, - Compression::Gzip(_) => Decompressor::Gzip, - Compression::Zlib(_) => Decompressor::Zlib, - Compression::Zstd(_) => Decompressor::Zstd, - Compression::Snappy => Decompressor::Snappy, + Compression::Gzip(_) => Decompressor::Gzip(limits), + Compression::Zlib(_) => Decompressor::Zlib(limits), + Compression::Zstd(_) => Decompressor::Zstd(limits), + Compression::Snappy => Decompressor::Snappy(limits), } } } #[cfg(test)] mod tests { + use vector_common::decompression::CompressionLimits; use std::io::Write; use crate::sink::compression::CompressionLevel; @@ -274,7 +273,7 @@ mod tests { assert!(compressed_data.len() < data.len()); assert_ne!(data, &compressed_data[..]); } - let decompressor = Decompressor::from(c); + let decompressor = Decompressor::from((c, CompressionLimits::default())); let decompressed_data = decompressor.decompress(compressed_data).unwrap(); assert_eq!(data, &decompressed_data[..]); } diff --git a/lib/vector-core/src/sink/snappy.rs b/lib/vector-core/src/sink/snappy.rs index 4a7f4a6787..c119dfd182 100644 --- a/lib/vector-core/src/sink/snappy.rs +++ b/lib/vector-core/src/sink/snappy.rs @@ -11,7 +11,8 @@ use std::io; -use snap::raw::{Encoder, Decoder}; +use snap::raw::{decompress_len, Decoder, Encoder}; +use vector_common::decompression::{CompressionLimits, DecompressedSizeLimitExceeded}; pub struct SnappyEncoder { writer: W, @@ -65,14 +66,21 @@ impl std::fmt::Debug for SnappyEncoder { pub struct SnappyDecoder { reader: R, + limits: CompressionLimits, buffer: Vec, decoded: bool, } impl SnappyDecoder { - pub const fn new(reader: R) -> Self { + /// Decodes snappy under `limits`. + /// + /// The limits are a constructor parameter rather than something a caller may forget, because + /// this is a `Read` impl in name only: the first `read` materialises the entire input and the + /// entire output. There is no streaming point at which a cap could be applied afterwards. + pub const fn new(reader: R, limits: CompressionLimits) -> Self { Self { - reader: reader, + reader, + limits, buffer: Vec::new(), decoded: false, } @@ -82,9 +90,25 @@ impl SnappyDecoder { impl io::Read for SnappyDecoder { fn read(&mut self, buf: &mut [u8]) -> io::Result { if !self.decoded { - let mut decoder = Decoder::new(); + // Bound the input first: `read_to_end` on an untrusted reader is itself unbounded, and + // it runs before anything can be learned about the declared output size. Reading one + // byte past the ceiling is what makes an over-long input detectable. + let max_compressed = self.limits.max_snappy_compressed_frame_size_bytes(); let mut compressed = Vec::new(); - self.reader.read_to_end(&mut compressed)?; + io::Read::take(&mut self.reader, max_compressed as u64 + 1) + .read_to_end(&mut compressed)?; + if compressed.len() > max_compressed { + return Err(io::Error::other(DecompressedSizeLimitExceeded)); + } + + // Snappy's header declares the decompressed length, so the allocation can be refused + // before it happens rather than capped while it grows. + let declared = decompress_len(&compressed)?; + if declared > self.limits.max_decompressed_size_bytes { + return Err(io::Error::other(DecompressedSizeLimitExceeded)); + } + + let mut decoder = Decoder::new(); self.buffer = decoder.decompress_vec(&compressed)?; self.decoded = true; } @@ -109,12 +133,111 @@ impl std::fmt::Debug for SnappyDecoder { #[cfg(test)] mod tests { - use std::io::Write; + use std::io::{Read, Write}; use bytes::{BufMut, BytesMut}; use super::*; + fn limits(max: usize) -> CompressionLimits { + CompressionLimits::with_max_decompressed_size_bytes(max) + } + + fn snappy(payload: &[u8]) -> Vec { + Encoder::new().compress_vec(payload).expect("compress") + } + + fn decode(compressed: &[u8], max: usize) -> io::Result> { + let mut out = Vec::new(); + SnappyDecoder::new(io::Cursor::new(compressed.to_vec()), limits(max)) + .read_to_end(&mut out)?; + Ok(out) + } + + /// Positive: a payload within the cap round-trips byte for byte. The cap must not change what + /// a legitimate decode produces. + #[test] + fn a_payload_within_the_limit_round_trips() { + let payload = vec![b'x'; 4096]; + let decoded = decode(&snappy(&payload), 8192).expect("should decode"); + + assert_eq!(decoded, payload); + } + + /// Negative: a frame small enough to pass the input bound, whose header still declares more + /// than the cap. Snappy manages roughly 21:1 on repetitive data, so 128 KiB of one byte + /// compresses to about 6 KiB — under the ~9.6 KiB compressed ceiling for an 8 KiB cap, but + /// declaring 128 KiB of output. This is the case the header check exists for. + #[test] + fn a_payload_over_the_limit_is_rejected() { + let max = 8192; + let payload = vec![b'x'; 128 * 1024]; + let compressed = snappy(&payload); + assert!( + compressed.len() <= limits(max).max_snappy_compressed_frame_size_bytes(), + "the frame must pass the input bound so the header check is what rejects it, got {} bytes", + compressed.len() + ); + + let error = decode(&compressed, max).expect_err("an over-large payload must be refused"); + + assert!( + DecompressedSizeLimitExceeded::is(&error), + "should be the shared limit error, got: {error}" + ); + } + + /// The refusal must happen before the output is allocated, not after — that is the whole point + /// of reading the declared length from the header. + #[test] + fn an_over_large_payload_is_refused_before_it_is_allocated() { + // 512 MiB declared, far more than a test should ever allocate. Compressing this directly + // would defeat the purpose, so build the frame from its varint header instead: snappy + // stores the decompressed length first, which is all the check reads. + let mut frame = Vec::new(); + let mut declared = 512u64 * 1024 * 1024; + while declared >= 0x80 { + frame.push((declared as u8) | 0x80); + declared >>= 7; + } + frame.push(declared as u8); + frame.extend_from_slice(&snappy(b"trailing garbage")[1..]); + + let error = decode(&frame, 4096).expect_err("a huge declared length must be refused"); + + assert!( + DecompressedSizeLimitExceeded::is(&error), + "should be refused on the declared length, got: {error}" + ); + } + + /// The input read is bounded too. A well-formed frame can never breach the compressed + /// ceiling while declaring an output within the cap, so the bound exists for a hostile + /// *reader*: without it, `read_to_end` on an endless stream runs until memory is gone, long + /// before the header can be inspected. + #[test] + fn an_endless_stream_is_cut_off_instead_of_being_buffered() { + let mut out = Vec::new(); + let error = SnappyDecoder::new(io::repeat(0u8), limits(1024)) + .read_to_end(&mut out) + .expect_err("an endless stream must be cut off"); + + assert!( + DecompressedSizeLimitExceeded::is(&error), + "should stop on the compressed-size ceiling, got: {error}" + ); + } + + /// Boundary: a payload landing exactly on the cap is legitimate and must still decode, so the + /// check cannot drift into rejecting valid traffic. + #[test] + fn a_payload_exactly_at_the_limit_is_accepted() { + let payload = vec![b'y'; 4096]; + let decoded = decode(&snappy(&payload), 4096).expect("exactly at the limit must decode"); + + assert_eq!(decoded.len(), 4096); + } + #[test] fn is_empty() { let writer = BytesMut::with_capacity(64).writer(); diff --git a/lib/vector-core/src/sink/zstd.rs b/lib/vector-core/src/sink/zstd.rs index c5459f3c52..ebbcfa8f18 100644 --- a/lib/vector-core/src/sink/zstd.rs +++ b/lib/vector-core/src/sink/zstd.rs @@ -1,6 +1,7 @@ use std::fmt::Display; use std::io; use std::io::{Read, Write}; +use vector_common::decompression::{CappedDecoder, CappedZstdReader, CompressionLimits}; use crate::sink::compression::CompressionLevel; #[derive(Debug)] @@ -68,20 +69,24 @@ impl std::fmt::Debug for ZstdEncoder { /// 2. Sharing only internal writer, which implements `Sync` unsafe impl Sync for ZstdEncoder {} +/// Streaming zstd decoder bounded by the global decompressed-size cap. +/// +/// Delegates to [`CappedDecoder`], so a frame that expands past the cap fails with an error +/// instead of being silently truncated or driving an unbounded allocation. pub struct ZstdDecoder { - inner: zstd::Decoder<'static, io::BufReader>, + inner: CappedZstdReader, } impl ZstdDecoder { - pub fn new(reader: R) -> io::Result { - let decoder = zstd::Decoder::new(reader)?; - Ok(Self { inner: decoder }) + pub fn new(reader: R, limits: &CompressionLimits) -> io::Result { + Ok(Self { + inner: CappedDecoder::zstd(reader, limits)?.into_reader(), + }) } } impl Read for ZstdDecoder { fn read(&mut self, buf: &mut [u8]) -> io::Result { - #[allow(clippy::disallowed_methods)] // Caller handles the result of `read`. self.inner.read(buf) } } diff --git a/lib/vector-core/src/test_util.rs b/lib/vector-core/src/test_util.rs index edfa61ab82..f38fec3d69 100644 --- a/lib/vector-core/src/test_util.rs +++ b/lib/vector-core/src/test_util.rs @@ -1,4 +1,16 @@ #![allow(missing_docs)] +// Tests decode payloads this process just encoded, so there is no untrusted input and nothing to +// cap. They deliberately keep using the raw decoders: leaving them untouched means they stay an +// independent regression check on the capped wrappers, rather than testing those wrappers against +// themselves. +// +// Caveat: unlike the other test modules this one is declared as a plain `pub mod test_util` with +// no `cfg`, so it compiles into the library and this allow is broader than it looks. The helpers +// below only read files this process wrote, so nothing here touches untrusted input — but if a +// caller outside the tests ever uses them, the disallowed-types lint will not object. Gate the +// module (as `inet_test_util` above it is) if that becomes a concern. +#![allow(clippy::disallowed_types)] + use std::{ collections::HashMap, convert::Infallible, diff --git a/lib/vector-core/src/tls/settings.rs b/lib/vector-core/src/tls/settings.rs index 803dbee225..fc30638c22 100644 --- a/lib/vector-core/src/tls/settings.rs +++ b/lib/vector-core/src/tls/settings.rs @@ -1,3 +1,4 @@ +use cfg_if::cfg_if; use std::{ fmt, fs::File, @@ -310,11 +311,21 @@ impl TlsSettings { if self.authorities.is_empty() { debug!("Fetching system root certs."); - #[cfg(windows)] - load_windows_certs(context).unwrap(); - - #[cfg(target_os = "macos")] - load_mac_certs(context).unwrap(); + cfg_if! { + if #[cfg(windows)] { + load_windows_certs(context).unwrap(); + } else if #[cfg(target_os = "macos")] { + cfg_if! { // Panic in release builds, warn in debug builds. + if #[cfg(debug_assertions)] { + if let Err(error) = load_mac_certs(context) { + warn!(message = "Failed to load macOS root certs.", %error); + } + } else { + load_mac_certs(context).unwrap(); + } + } + } + } } else { let mut store = X509StoreBuilder::new().context(NewStoreBuilderSnafu)?; for authority in &self.authorities { diff --git a/src/app.rs b/src/app.rs index 32586b4c8c..ac106f1677 100644 --- a/src/app.rs +++ b/src/app.rs @@ -83,6 +83,7 @@ impl ApplicationConfig { opts.require_healthy, opts.allow_empty_config, graceful_shutdown_duration, + opts.allow_component_limit_overrides, signal_handler, ) .await?; @@ -264,6 +265,7 @@ impl Application { signals, topology_controller, allow_empty_config: root_opts.allow_empty_config, + allow_component_limit_overrides: root_opts.allow_component_limit_overrides, }) } } @@ -275,6 +277,7 @@ pub struct StartedApplication { pub signals: SignalPair, pub topology_controller: SharedTopologyController, pub allow_empty_config: bool, + pub allow_component_limit_overrides: bool, } impl StartedApplication { @@ -290,6 +293,7 @@ impl StartedApplication { topology_controller, internal_topologies, allow_empty_config, + allow_component_limit_overrides, } = self; let mut graceful_crash = UnboundedReceiverStream::new(graceful_crash_receiver); @@ -306,6 +310,7 @@ impl StartedApplication { &config_paths, &mut signal_handler, allow_empty_config, + allow_component_limit_overrides, ).await { break signal; }, @@ -334,6 +339,7 @@ async fn handle_signal( config_paths: &[ConfigPath], signal_handler: &mut SignalHandler, allow_empty_config: bool, + allow_component_limit_overrides: bool, ) -> Option { match signal { Ok(SignalTo::ReloadFromConfigBuilder(config_builder)) => { @@ -353,6 +359,7 @@ async fn handle_signal( &topology_controller.config_paths, signal_handler, allow_empty_config, + allow_component_limit_overrides, ) .await; @@ -487,6 +494,7 @@ pub async fn load_configs( require_healthy: Option, allow_empty_config: bool, graceful_shutdown_duration: Option, + allow_component_limit_overrides: bool, signal_handler: &mut SignalHandler, ) -> Result { let config_paths = config::process_paths(config_paths).ok_or(exitcode::CONFIG)?; @@ -514,6 +522,7 @@ pub async fn load_configs( &config_paths, signal_handler, allow_empty_config, + allow_component_limit_overrides, ) .await .map_err(handle_config_errors)?; @@ -527,6 +536,15 @@ pub async fn load_configs( config.healthchecks.set_require_healthy(require_healthy); config.graceful_shutdown_duration = graceful_shutdown_duration; + if allow_component_limit_overrides { + // Worth a line of its own: the global limits stop being a ceiling for the rest of this + // run, so the log should say so even when no component actually exceeds them. + info!( + "Component limit overrides are permitted; a component may raise a limit above the \ + global value." + ); + } + Ok(config) } diff --git a/src/cli.rs b/src/cli.rs index 6ab179ddfa..70e3412997 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -236,6 +236,14 @@ pub struct RootOpts { /// `--watch-config`. #[arg(long, env = "VECTOR_ALLOW_EMPTY_CONFIG", default_value = "false")] pub allow_empty_config: bool, + + /// Allow a component to raise an operational limit above the global one. + /// + /// Without this, a component asking for a limit looser than `limits.*` is clamped back to the + /// global value and a warning is logged. It is a start option rather than a config key so that + /// a ceiling set by whoever runs Vector cannot be lifted by editing pipeline config. + #[arg(long, env = "VECTOR_ALLOW_COMPONENT_LIMIT_OVERRIDES")] + pub allow_component_limit_overrides: bool, } impl RootOpts { @@ -395,3 +403,56 @@ pub fn handle_config_errors(errors: Vec) -> exitcode::ExitCode { exitcode::CONFIG } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Opts, SubCommand}; + + fn parse(args: &[&str]) -> Opts { + Opts::try_parse_from(args).expect("args should parse") + } + + /// Off unless asked for: the default run must clamp component limits to the global ones. + #[test] + fn component_limit_overrides_are_off_by_default() { + assert!(!parse(&["vector"]).root.allow_component_limit_overrides); + } + + /// The flag is the only way to lift the ceiling, so it has to actually be wired to clap. + #[test] + fn the_component_limit_override_flag_is_accepted() { + assert!( + parse(&["vector", "--allow-component-limit-overrides"]) + .root + .allow_component_limit_overrides + ); + } + + /// `validate` takes it as its own option, so it can be written after the subcommand the way + /// every other validate option is. + #[test] + fn validate_accepts_the_component_limit_override_flag() { + let opts = parse(&[ + "vector", + "validate", + "--allow-component-limit-overrides", + "vector.yaml", + ]); + + match opts.sub_command { + Some(SubCommand::Validate(v)) => assert!(v.allow_component_limit_overrides), + other => panic!("expected the validate subcommand, got {other:?}"), + } + } + + /// And defaults to off there too, so `vector validate` describes a default run. + #[test] + fn validate_defaults_the_component_limit_override_flag_to_off() { + match parse(&["vector", "validate", "vector.yaml"]).sub_command { + Some(SubCommand::Validate(v)) => assert!(!v.allow_component_limit_overrides), + other => panic!("expected the validate subcommand, got {other:?}"), + } + } +} diff --git a/src/components/validation/resources/mod.rs b/src/components/validation/resources/mod.rs index 0ba8dccdad..cd64435877 100644 --- a/src/components/validation/resources/mod.rs +++ b/src/components/validation/resources/mod.rs @@ -4,6 +4,7 @@ mod http; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; +use vector_common::decompression::CompressionLimits; use vector_lib::{ codecs::{ decoding::{self, DeserializerConfig}, @@ -266,7 +267,7 @@ fn encoder_framing_to_decoding_framer(framing: encoding::FramingConfig) -> decod }, }; - framing_config.build() + framing_config.build(CompressionLimits::default()) } /// Direction that the resource is operating in. diff --git a/src/config/builder.rs b/src/config/builder.rs index 3a0413e38a..9fcbf947c6 100644 --- a/src/config/builder.rs +++ b/src/config/builder.rs @@ -77,6 +77,15 @@ pub struct ConfigBuilder { #[serde(default, skip)] #[doc(hidden)] pub allow_empty: bool, + + /// Allow a component to raise an operational limit above the global one. + /// + /// Not a config key: it comes from a start option, so that a ceiling set by whoever runs + /// Vector cannot be lifted by editing a pipeline file. Set by the loader (and by `validate`, + /// which must predict what the corresponding run would do). + #[serde(skip)] + #[doc(hidden)] + pub allow_component_limit_overrides: bool, } impl From for ConfigBuilder { @@ -94,6 +103,7 @@ impl From for ConfigBuilder { tests, secret, graceful_shutdown_duration, + allow_component_limit_overrides, } = config; let transforms = transforms @@ -123,6 +133,7 @@ impl From for ConfigBuilder { secret, graceful_shutdown_duration, allow_empty: false, + allow_component_limit_overrides, } } } diff --git a/src/config/compiler.rs b/src/config/compiler.rs index 28015e01ab..14b49d457a 100644 --- a/src/config/compiler.rs +++ b/src/config/compiler.rs @@ -51,6 +51,7 @@ pub fn compile(mut builder: ConfigBuilder) -> Result<(Config, Vec), Vec< secret, graceful_shutdown_duration, allow_empty: _, + allow_component_limit_overrides, } = builder; let graph = match Graph::new(&sources, &transforms, &sinks, schema) { @@ -104,6 +105,7 @@ pub fn compile(mut builder: ConfigBuilder) -> Result<(Config, Vec), Vec< tests, secret, graceful_shutdown_duration, + allow_component_limit_overrides, }; config.propagate_acknowledgements()?; diff --git a/src/config/loading/mod.rs b/src/config/loading/mod.rs index a4def7e197..1820a4345e 100644 --- a/src/config/loading/mod.rs +++ b/src/config/loading/mod.rs @@ -137,6 +137,7 @@ pub async fn load_from_paths_with_provider_and_secrets( config_paths: &[ConfigPath], signal_handler: &mut signal::SignalHandler, allow_empty: bool, + allow_component_limit_overrides: bool, ) -> Result> { // Load secret backends first let mut secrets_backends_loader = load_secret_backends_from_paths(config_paths)?; @@ -164,6 +165,10 @@ pub async fn load_from_paths_with_provider_and_secrets( debug!(message = "Provider configured.", provider = ?provider.get_component_name()); } + // Set after the provider swap above, which replaces the whole builder: a start option must + // survive a config that came from a provider. + builder.allow_component_limit_overrides = allow_component_limit_overrides; + let (new_config, build_warnings) = builder.build_with_warnings()?; validation::check_buffer_preconditions(&new_config).await?; diff --git a/src/config/mod.rs b/src/config/mod.rs index 4a6d99fd87..38b2b0422b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -105,6 +105,11 @@ pub struct Config { tests: Vec, secret: IndexMap, pub graceful_shutdown_duration: Option, + /// Whether a component may raise a limit above the global one. + /// + /// Settable only by whoever starts the process (`--allow-component-limit-overrides`), never + /// from config, so a ceiling cannot be lifted by editing a pipeline file. + pub allow_component_limit_overrides: bool, } impl Config { @@ -624,6 +629,212 @@ mod tests { assert_eq!(result, expected); } + // ---- per-component limit overrides -------------------------------------------------------- + + /// A component asking for more than the global limit is reported, at startup, on reload and + /// under `vector validate`. + #[tokio::test] + async fn a_component_raising_a_limit_warns() { + let warnings = load( + indoc! {r#" + [limits.compression] + max_decompressed_size_bytes = 1024 + + [sources.in] + type = "test_basic" + + [sources.in.limits.compression] + max_decompressed_size_bytes = 1048576 + + [sinks.out] + type = "test_basic" + inputs = ["in"] + "#}, + Format::Toml, + ) + .await + .unwrap(); + + assert_eq!(warnings.len(), 1, "got: {warnings:?}"); + let warning = &warnings[0]; + assert!(warning.contains(r#"Source "in""#), "got: {warning}"); + assert!( + warning.contains("limits.compression.max_decompressed_size_bytes = 1048576"), + "the warning must name what was asked for, got: {warning}" + ); + assert!( + warning.contains("above the global limit of 1024"), + "the warning must name the ceiling, got: {warning}" + ); + assert!( + warning.contains("--allow-component-limit-overrides"), + "the warning must say how to grant it, got: {warning}" + ); + } + + /// Tightening is legitimate and must stay silent, or the warning becomes noise nobody reads. + #[tokio::test] + async fn a_component_lowering_a_limit_is_silent() { + let warnings = load( + indoc! {r#" + [limits.compression] + max_decompressed_size_bytes = 1048576 + + [sources.in] + type = "test_basic" + + [sources.in.limits.compression] + max_decompressed_size_bytes = 1024 + + [sinks.out] + type = "test_basic" + inputs = ["in"] + "#}, + Format::Toml, + ) + .await + .unwrap(); + + assert_eq!(warnings, Vec::::new()); + } + + /// The global limit is below the built-in default here, so a component that says nothing would + /// look like it were asking for the default if "unset" were not tracked properly. + #[tokio::test] + async fn a_component_with_no_override_is_silent() { + let warnings = load( + indoc! {r#" + [limits.compression] + max_decompressed_size_bytes = 1024 + + [sources.in] + type = "test_basic" + + [sinks.out] + type = "test_basic" + inputs = ["in"] + "#}, + Format::Toml, + ) + .await + .unwrap(); + + assert_eq!( + warnings, + Vec::::new(), + "a component that said nothing must not be reported as asking for the default" + ); + } + + /// Transforms and sinks carry the same field, and sinks in particular are rebuilt through + /// `map_inputs` on the way into the topology — a drop there would silently lose the override. + #[tokio::test] + async fn transforms_and_sinks_carry_the_override_too() { + let warnings = load( + indoc! {r#" + [limits.compression] + max_decompressed_size_bytes = 1024 + + [sources.in] + type = "test_basic" + + [transforms.mid] + type = "test_basic" + inputs = ["in"] + suffix = "foo" + increase = 1.25 + + [transforms.mid.limits.compression] + max_decompressed_size_bytes = 2048 + + [sinks.out] + type = "test_basic" + inputs = ["mid"] + + [sinks.out.limits.compression] + max_decompressed_size_bytes = 4096 + "#}, + Format::Toml, + ) + .await + .unwrap(); + + assert_eq!(warnings.len(), 2, "got: {warnings:?}"); + assert!(warnings.iter().any(|w| w.contains(r#"Transform "mid""#))); + assert!(warnings.iter().any(|w| w.contains(r#"Sink "out""#))); + } + + /// With the raise permitted, the warning must describe the run that will actually happen — + /// `validate --allow-component-limit-overrides` exists to predict exactly this. + #[tokio::test] + async fn a_permitted_raise_reports_that_it_was_granted() { + let toml = indoc! {r#" + [limits.compression] + max_decompressed_size_bytes = 1024 + + [sources.in] + type = "test_basic" + + [sources.in.limits.compression] + max_decompressed_size_bytes = 1048576 + + [sinks.out] + type = "test_basic" + inputs = ["in"] + "#}; + + let mut builder: ConfigBuilder = format::deserialize(toml, Format::Toml).unwrap(); + builder.allow_component_limit_overrides = true; + let (config, warnings) = builder.build_with_warnings().unwrap(); + + assert_eq!(warnings.len(), 1, "a granted raise is still reported"); + assert!( + warnings[0].contains("permitted by --allow-component-limit-overrides"), + "got: {}", + warnings[0] + ); + assert!( + !warnings[0].contains("clamped"), + "must not claim it was clamped, got: {}", + warnings[0] + ); + assert!(config.allow_component_limit_overrides); + } + + /// The same config without the flag says the opposite, so the two messages cannot be confused. + #[tokio::test] + async fn an_unpermitted_raise_reports_that_it_was_clamped() { + let toml = indoc! {r#" + [limits.compression] + max_decompressed_size_bytes = 1024 + + [sources.in] + type = "test_basic" + + [sources.in.limits.compression] + max_decompressed_size_bytes = 1048576 + + [sinks.out] + type = "test_basic" + inputs = ["in"] + "#}; + + let builder: ConfigBuilder = format::deserialize(toml, Format::Toml).unwrap(); + assert!( + !builder.allow_component_limit_overrides, + "the flag must default to off" + ); + let (config, warnings) = builder.build_with_warnings().unwrap(); + + assert_eq!(warnings.len(), 1); + assert!( + warnings[0].contains("clamped to the global value"), + "got: {}", + warnings[0] + ); + assert!(!config.allow_component_limit_overrides); + } + #[tokio::test] async fn warnings() { let warnings = load( diff --git a/src/config/sink.rs b/src/config/sink.rs index 3e779e04f7..3c3921b4ff 100644 --- a/src/config/sink.rs +++ b/src/config/sink.rs @@ -1,4 +1,5 @@ use std::cell::RefCell; +use vector_common::decompression::OperationalLimitsOverride; use async_trait::async_trait; use dyn_clone::DynClone; @@ -80,6 +81,14 @@ where #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")] proxy: ProxyConfig, + /// Overrides the global operational limits for this component. + /// + /// A limit looser than the global one is clamped back to the global value unless Vector runs + /// with `--allow-component-limit-overrides`; a stricter one always applies. + #[configurable(derived)] + #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")] + pub limits: OperationalLimitsOverride, + #[serde(flatten)] #[configurable(metadata(docs::hidden))] pub inner: BoxedSink, @@ -101,6 +110,7 @@ where healthcheck_uri: None, inner: inner.into(), proxy: Default::default(), + limits: Default::default(), graph: Default::default(), } } @@ -158,6 +168,7 @@ where healthcheck: self.healthcheck, healthcheck_uri: self.healthcheck_uri, proxy: self.proxy, + limits: self.limits, graph: self.graph, } } diff --git a/src/config/source.rs b/src/config/source.rs index cd2231001e..0f37c9b2a2 100644 --- a/src/config/source.rs +++ b/src/config/source.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; +use vector_common::decompression::OperationalLimitsOverride; use async_trait::async_trait; use dyn_clone::DynClone; @@ -55,6 +56,14 @@ pub struct SourceOuter { #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")] pub proxy: ProxyConfig, + /// Overrides the global operational limits for this component. + /// + /// A limit looser than the global one is clamped back to the global value unless Vector runs + /// with `--allow-component-limit-overrides`; a stricter one always applies. + #[configurable(derived)] + #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")] + pub limits: OperationalLimitsOverride, + #[configurable(derived)] #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")] pub graph: GraphConfig, @@ -71,6 +80,7 @@ impl SourceOuter { pub(crate) fn new>(inner: I) -> Self { Self { proxy: Default::default(), + limits: Default::default(), graph: Default::default(), sink_acknowledgements: false, inner: inner.into(), diff --git a/src/config/transform.rs b/src/config/transform.rs index 4ab7e21f99..3e11b47ddf 100644 --- a/src/config/transform.rs +++ b/src/config/transform.rs @@ -1,5 +1,6 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; +use vector_common::decompression::OperationalLimitsOverride; use async_trait::async_trait; use dyn_clone::DynClone; @@ -61,6 +62,14 @@ where #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")] pub graph: GraphConfig, + /// Overrides the global operational limits for this component. + /// + /// A limit looser than the global one is clamped back to the global value unless Vector runs + /// with `--allow-component-limit-overrides`; a stricter one always applies. + #[configurable(derived)] + #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")] + pub limits: OperationalLimitsOverride, + #[configurable(derived)] pub inputs: Inputs, @@ -83,6 +92,7 @@ where TransformOuter { inputs, inner, + limits: Default::default(), graph: Default::default(), } } @@ -103,6 +113,7 @@ where TransformOuter { inputs: Inputs::from_iter(inputs), inner: self.inner, + limits: self.limits, graph: self.graph, } } diff --git a/src/config/validation.rs b/src/config/validation.rs index 56423e6aa7..93873b7eab 100644 --- a/src/config/validation.rs +++ b/src/config/validation.rs @@ -367,6 +367,52 @@ pub fn warnings(config: &Config) -> Vec { } } + warnings.extend(component_limit_warnings(config)); + + warnings +} + +/// Reports every component asking for a limit looser than the global one. +/// +/// Emitted from [`warnings`] so the same message reaches startup, reload and `vector validate` +/// (where `--deny-warnings` turns it into a failure). A granted raise is still reported — the log +/// should record that a component exceeded the fleet ceiling either way — but the message says +/// which of the two happened, so `validate` describes the run it was asked about rather than the +/// default one. +fn component_limit_warnings(config: &Config) -> Vec { + let components = config + .sources + .iter() + .map(|(key, outer)| ("Source", key, &outer.limits)) + .chain( + config + .transforms + .iter() + .map(|(key, outer)| ("Transform", key, &outer.limits)), + ) + .chain( + config + .sinks + .iter() + .map(|(key, outer)| ("Sink", key, &outer.limits)), + ); + + let mut warnings = vec![]; + for (kind, key, over) in components { + if over.is_empty() { + continue; + } + let allowed = config.allow_component_limit_overrides; + let (_, raises) = config.global.limits.resolve(over, allowed); + let outcome = if allowed { + "It is permitted by --allow-component-limit-overrides." + } else { + "It is clamped to the global value; pass --allow-component-limit-overrides to grant it." + }; + for raise in raises { + warnings.push(format!("{kind} \"{key}\" requests {raise}. {outcome}")); + } + } warnings } diff --git a/src/sinks/aws_s3/integration_tests.rs b/src/sinks/aws_s3/integration_tests.rs index 9e1645778b..472fa71432 100644 --- a/src/sinks/aws_s3/integration_tests.rs +++ b/src/sinks/aws_s3/integration_tests.rs @@ -1,5 +1,11 @@ #![cfg(all(test, feature = "aws-s3-integration-tests"))] +// Tests decode payloads this process just encoded, so there is no untrusted input and nothing to +// cap. They deliberately keep using the raw decoders: leaving them untouched means they stay an +// independent regression check on the capped wrappers, rather than testing those wrappers against +// themselves. +#![allow(clippy::disallowed_types)] + use std::{ io::{BufRead, BufReader}, time::Duration, diff --git a/src/sinks/azure_blob/integration_tests.rs b/src/sinks/azure_blob/integration_tests.rs index 9e36ed1a6a..1adaaacc44 100644 --- a/src/sinks/azure_blob/integration_tests.rs +++ b/src/sinks/azure_blob/integration_tests.rs @@ -1,3 +1,9 @@ +// Tests decode payloads this process just encoded, so there is no untrusted input and nothing to +// cap. They deliberately keep using the raw decoders: leaving them untouched means they stay an +// independent regression check on the capped wrappers, rather than testing those wrappers against +// themselves. +#![allow(clippy::disallowed_types)] + use std::{ io::{BufRead, BufReader}, num::NonZeroU32, diff --git a/src/sinks/datadog/metrics/encoder.rs b/src/sinks/datadog/metrics/encoder.rs index 9f0608df80..5cb263ac5f 100644 --- a/src/sinks/datadog/metrics/encoder.rs +++ b/src/sinks/datadog/metrics/encoder.rs @@ -981,6 +981,11 @@ fn write_payload_footer( #[cfg(test)] mod tests { + // Tests decode payloads this process just encoded, so there is no untrusted input and nothing + // to cap. They deliberately keep using the raw decoders: leaving them untouched means they + // stay an independent regression check on the capped wrappers. + #![allow(clippy::disallowed_types)] + use std::{ io::{self, copy}, num::NonZeroU32, diff --git a/src/sinks/datadog/metrics/integration_tests.rs b/src/sinks/datadog/metrics/integration_tests.rs index 5a3bda4a20..44cfdb428c 100644 --- a/src/sinks/datadog/metrics/integration_tests.rs +++ b/src/sinks/datadog/metrics/integration_tests.rs @@ -1,3 +1,9 @@ +// Tests decode payloads this process just encoded, so there is no untrusted input and nothing to +// cap. They deliberately keep using the raw decoders: leaving them untouched means they stay an +// independent regression check on the capped wrappers, rather than testing those wrappers against +// themselves. +#![allow(clippy::disallowed_types)] + use std::num::NonZeroU32; use bytes::Bytes; diff --git a/src/sinks/datadog/traces/apm_stats/integration_tests.rs b/src/sinks/datadog/traces/apm_stats/integration_tests.rs index fbd2eb6d19..b13b951e2f 100644 --- a/src/sinks/datadog/traces/apm_stats/integration_tests.rs +++ b/src/sinks/datadog/traces/apm_stats/integration_tests.rs @@ -1,3 +1,9 @@ +// Tests decode payloads this process just encoded, so there is no untrusted input and nothing to +// cap. They deliberately keep using the raw decoders: leaving them untouched means they stay an +// independent regression check on the capped wrappers, rather than testing those wrappers against +// themselves. +#![allow(clippy::disallowed_types)] + use axum::{ body::Body, extract::Extension, diff --git a/src/sinks/elasticsearch/config.rs b/src/sinks/elasticsearch/config.rs index a7cb1f8bc3..ff3f61c2d4 100644 --- a/src/sinks/elasticsearch/config.rs +++ b/src/sinks/elasticsearch/config.rs @@ -546,6 +546,8 @@ impl DataStreamConfig { #[typetag::serde(name = "elasticsearch")] impl SinkConfig for ElasticsearchConfig { async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> { + // From this component's context, so the deployment controls the cap. + let compression_limits = cx.globals.limits.compression; let commons = ElasticsearchCommon::parse_many(self, cx.proxy()).await?; let common = commons[0].clone(); let app_info = crate::app_info(); @@ -581,7 +583,8 @@ impl SinkConfig for ElasticsearchConfig { http_request_builder, self.rejection_report.clone(), self.compression.clone(), - rej_ctx); + rej_ctx, + compression_limits); (endpoint, service) }) diff --git a/src/sinks/elasticsearch/service.rs b/src/sinks/elasticsearch/service.rs index d94b68d4ea..d190ad1da4 100644 --- a/src/sinks/elasticsearch/service.rs +++ b/src/sinks/elasticsearch/service.rs @@ -17,6 +17,7 @@ use vector_lib::{ request_metadata::{GroupedCountByteSize, MetaDescriptive, RequestMetadata}, }; +use vector_common::decompression::CompressionLimits; use super::{ElasticsearchCommon, ElasticsearchConfig, RejectionReport}; use crate::{ event::{EventFinalizers, EventStatus, Finalizable}, @@ -99,6 +100,8 @@ impl RejectionContext for ElasticsearchRejectionContext { #[derive(Clone)] pub struct ElasticsearchService { + /// Limits used when logging a rejected request payload. + compression_limits: CompressionLimits, // TODO: `HttpBatchService` has been deprecated for direct use in sinks. // This sink should undergo a refactor to utilize the `HttpService` // instead, which extracts much of the boilerplate code for `Service`. @@ -118,6 +121,7 @@ impl ElasticsearchService { rej_rpt: RejectionReport, compression: Compression, rej_ctx: Arc, + compression_limits: CompressionLimits, ) -> ElasticsearchService { let http_request_builder = Arc::new(http_request_builder); let batch_service = HttpBatchService::new(http_client, move |req| { @@ -126,7 +130,13 @@ impl ElasticsearchService { Box::pin(async move { request_builder.build_request(req).await }); future }); - ElasticsearchService { batch_service, rej_rpt, compression, rej_ctx } + ElasticsearchService { + batch_service, + rej_rpt, + compression, + rej_ctx, + compression_limits, + } } } @@ -234,13 +244,20 @@ impl Service for ElasticsearchService { None }; let rej_ctx = Arc::clone(&self.rej_ctx); + let compression_limits = self.compression_limits; Box::pin(async move { http_service.ready().await?; let events_byte_size = std::mem::take(req.metadata_mut()).into_events_estimated_json_encoded_byte_size(); let http_response = http_service.call(req).await?; - let event_status = get_event_status(&http_response, req_for_rpt, rej_rpt, &rej_ctx); + let event_status = get_event_status( + &http_response, + req_for_rpt, + rej_rpt, + &rej_ctx, + compression_limits, + ); Ok(ElasticsearchResponse { event_status, http_response, @@ -289,13 +306,14 @@ fn get_event_status( request: Option<(ElasticsearchRequest, Compression)>, rej_rpt: RejectionReport, rej_ctx: &ElasticsearchRejectionContext, + compression_limits: CompressionLimits, ) -> EventStatus { let status = response.status(); if status.is_success() { let body = response.body(); if String::from_utf8_lossy(body).contains(response_frag("errors", "true").as_str()) { let req = request.map(|(req, comp)| (req.payload, comp)); - emit_rejection_error(rej_ctx, status.as_u16(), body, req, rej_rpt); + emit_rejection_error(rej_ctx, status.as_u16(), body, req, rej_rpt, compression_limits); EventStatus::Rejected } else { EventStatus::Delivered @@ -306,11 +324,25 @@ fn get_event_status( } else { rej_rpt }; - emit_rejection_error(rej_ctx, status.as_u16(), response.body(), None, mode); + emit_rejection_error( + rej_ctx, + status.as_u16(), + response.body(), + None, + mode, + compression_limits, + ); EventStatus::Errored } else { let req = request.map(|(req, comp)| (req.payload, comp)); - emit_rejection_error(rej_ctx, status.as_u16(), response.body(), req, rej_rpt); + emit_rejection_error( + rej_ctx, + status.as_u16(), + response.body(), + req, + rej_rpt, + compression_limits, + ); EventStatus::Rejected } } diff --git a/src/sinks/http/tests.rs b/src/sinks/http/tests.rs index 363877380c..9897cbe7cd 100644 --- a/src/sinks/http/tests.rs +++ b/src/sinks/http/tests.rs @@ -1,5 +1,11 @@ //! Unit tests for the `http` sink. +// Tests decode payloads this process just encoded, so there is no untrusted input and nothing to +// cap. They deliberately keep using the raw decoders: leaving them untouched means they stay an +// independent regression check on the capped wrappers, rather than testing those wrappers against +// themselves. +#![allow(clippy::disallowed_types)] + use std::sync::{atomic, Arc}; use bytes::{Buf, Bytes}; diff --git a/src/sinks/prometheus/exporter.rs b/src/sinks/prometheus/exporter.rs index e272a6d9dd..42b6b5400c 100644 --- a/src/sinks/prometheus/exporter.rs +++ b/src/sinks/prometheus/exporter.rs @@ -595,6 +595,11 @@ impl StreamSink for PrometheusExporter { #[cfg(test)] mod tests { + // Tests decode payloads this process just encoded, so there is no untrusted input and nothing + // to cap. They deliberately keep using the raw decoders: leaving them untouched means they + // stay an independent regression check on the capped wrappers. + #![allow(clippy::disallowed_types)] + use chrono::{Duration, Utc}; use flate2::read::GzDecoder; use futures::stream; diff --git a/src/sinks/splunk_hec/common/service.rs b/src/sinks/splunk_hec/common/service.rs index f05641dc16..5e81ce22ba 100644 --- a/src/sinks/splunk_hec/common/service.rs +++ b/src/sinks/splunk_hec/common/service.rs @@ -30,6 +30,7 @@ use crate::{ UriParseSnafu, }, }; +use vector_common::decompression::CompressionLimits; pub struct HecRejectionContext { pub rejected: Counter, @@ -57,6 +58,8 @@ impl RejectionContext for HecRejectionContext { } pub struct HecService { + /// Limits used when logging a rejected request payload. + compression_limits: CompressionLimits, pub inner: S, ack_finalizer_tx: Option)>>, ack_slots: PollSemaphore, @@ -87,6 +90,7 @@ where rej_rpt: RejectionReport, compression: Compression, rej_ctx: Arc, + compression_limits: CompressionLimits, ) -> Self { let max_pending_acks = indexer_acknowledgements.max_pending_acks.get(); let tx = if let Some(ack_client) = ack_client { @@ -111,6 +115,7 @@ where rej_rpt, compression, rej_ctx, + compression_limits, } } } @@ -150,6 +155,7 @@ where let rej_rpt = self.rej_rpt.clone(); let compression = self.compression; let rej_ctx = Arc::clone(&self.rej_ctx); + let compression_limits = self.compression_limits; let req_for_rpt = if rej_rpt.needs_request() { Some((req.body.clone(), compression)) } else { @@ -202,10 +208,24 @@ where } else { rej_rpt }; - emit_rejection_error(rej_ctx.as_ref(), response.status_code(), response.body(), None, mode); + emit_rejection_error( + rej_ctx.as_ref(), + response.status_code(), + response.body(), + None, + mode, + compression_limits, + ); EventStatus::Errored } else { - emit_rejection_error(rej_ctx.as_ref(), response.status_code(), response.body(), req_for_rpt, rej_rpt); + emit_rejection_error( + rej_ctx.as_ref(), + response.status_code(), + response.body(), + req_for_rpt, + rej_rpt, + compression_limits, + ); EventStatus::Rejected }; @@ -352,6 +372,8 @@ impl HttpRequestBuilder { #[cfg(test)] mod tests { + use vector_common::decompression::CompressionLimits; + use std::{ collections::HashMap, future::poll_fn, @@ -423,6 +445,7 @@ mod tests { rej_rpt, Compression::default(), test_context(), + CompressionLimits::default(), ) } @@ -481,6 +504,7 @@ mod tests { RejectionReport::default(), Compression::default(), test_context(), + CompressionLimits::default(), ) } @@ -806,6 +830,7 @@ mod tests { RejectionReport::default(), Compression::default(), test_context(), + CompressionLimits::default(), ); let request = get_hec_request(); @@ -856,6 +881,7 @@ mod tests { RejectionReport::default(), Compression::default(), test_context(), + CompressionLimits::default(), ); let request = get_hec_request(); @@ -918,6 +944,7 @@ mod tests { RejectionReport::default(), Compression::default(), test_context(), + CompressionLimits::default(), ); let request = get_hec_request(); diff --git a/src/sinks/splunk_hec/logs/config.rs b/src/sinks/splunk_hec/logs/config.rs index 93fd9cd9d7..3c2cbe8b9a 100644 --- a/src/sinks/splunk_hec/logs/config.rs +++ b/src/sinks/splunk_hec/logs/config.rs @@ -370,6 +370,7 @@ impl HecLogsSinkConfig { self.rejection_report.clone(), self.compression, rej_ctx, + cx.globals.limits.compression, ); let batch_settings = self.batch.into_batcher_settings()?; diff --git a/src/sinks/splunk_hec/metrics/config.rs b/src/sinks/splunk_hec/metrics/config.rs index 8ebe76b0a9..89bbc159d2 100644 --- a/src/sinks/splunk_hec/metrics/config.rs +++ b/src/sinks/splunk_hec/metrics/config.rs @@ -177,7 +177,11 @@ impl SinkConfig for HecMetricsSinkConfig { } impl HecMetricsSinkConfig { - pub fn build_processor(&self, client: HttpClient, _: SinkContext) -> crate::Result { + pub fn build_processor( + &self, + client: HttpClient, + cx: SinkContext, + ) -> crate::Result { let ack_client = if self.acknowledgements.indexer_acknowledgements_enabled { Some(client.clone()) } else { @@ -223,6 +227,7 @@ impl HecMetricsSinkConfig { self.rejection_report.clone(), self.compression, rej_ctx, + cx.globals.limits.compression, ); let batch_settings = self.batch.into_batcher_settings()?; diff --git a/src/sinks/util/buffer/mod.rs b/src/sinks/util/buffer/mod.rs index 427ada410a..a8f2ddd8de 100644 --- a/src/sinks/util/buffer/mod.rs +++ b/src/sinks/util/buffer/mod.rs @@ -162,6 +162,11 @@ impl Batch for Buffer { #[cfg(test)] mod test { + // Tests decode payloads this process just encoded, so there is no untrusted input and nothing + // to cap. They deliberately keep using the raw decoders: leaving them untouched means they + // stay an independent regression check on the capped wrappers. + #![allow(clippy::disallowed_types)] + use std::{ io::Read, sync::{Arc, Mutex}, diff --git a/src/sinks/util/rejection_report.rs b/src/sinks/util/rejection_report.rs index 42fef17f94..8c5836ea6f 100644 --- a/src/sinks/util/rejection_report.rs +++ b/src/sinks/util/rejection_report.rs @@ -2,6 +2,7 @@ use bytes::Bytes; use vector_lib::configurable::configurable_component; use super::{Compression, Decompressor}; +use vector_common::decompression::CompressionLimits; /// Controls how much detail is logged when a sink's HTTP request is rejected. #[configurable_component] @@ -61,6 +62,7 @@ pub fn emit_rejection_error( response_body: &Bytes, request: Option<(Bytes, Compression)>, mode: RejectionReport, + limits: CompressionLimits, ) { context.record_rejection(status, response_body); let error_code = context.error_code(status); @@ -69,7 +71,7 @@ pub fn emit_rejection_error( match (mode, request) { (RejectionReport::RequestResponse, Some((body, comp))) => { - let decomp = Decompressor::from(comp); + let decomp = Decompressor::from((comp, limits)); let req_data = match decomp.decompress(body) { Ok(data) => data, Err(err) => format!("- decompression failed({comp}): '{err}' -").into(), @@ -167,21 +169,21 @@ mod tests { let body = Bytes::from("error body"); let (ctx, count) = make_ctx(); - emit_rejection_error(&ctx, 400, &body, None, RejectionReport::Stats); + emit_rejection_error(&ctx, 400, &body, None, RejectionReport::Stats, CompressionLimits::default()); assert_eq!(count.load(Ordering::Relaxed), 1); let (ctx, count) = make_ctx(); - emit_rejection_error(&ctx, 400, &body, None, RejectionReport::Response); + emit_rejection_error(&ctx, 400, &body, None, RejectionReport::Response, CompressionLimits::default()); assert_eq!(count.load(Ordering::Relaxed), 1); let (ctx, count) = make_ctx(); // RequestResponse without a request body falls back to response-only logging. - emit_rejection_error(&ctx, 400, &body, None, RejectionReport::RequestResponse); + emit_rejection_error(&ctx, 400, &body, None, RejectionReport::RequestResponse, CompressionLimits::default()); assert_eq!(count.load(Ordering::Relaxed), 1); let (ctx, count) = make_ctx(); let req = Bytes::from("request body"); - emit_rejection_error(&ctx, 400, &body, Some((req, Compression::None)), RejectionReport::RequestResponse); + emit_rejection_error(&ctx, 400, &body, Some((req, Compression::None)), RejectionReport::RequestResponse, CompressionLimits::default()); assert_eq!(count.load(Ordering::Relaxed), 1); } @@ -196,6 +198,7 @@ mod tests { &response_body, Some((request_body, Compression::None)), RejectionReport::RequestResponse, + CompressionLimits::default(), ); } diff --git a/src/sinks/util/test.rs b/src/sinks/util/test.rs index c029ce8d74..6a8880e252 100644 --- a/src/sinks/util/test.rs +++ b/src/sinks/util/test.rs @@ -1,3 +1,9 @@ +// Tests decode payloads this process just encoded, so there is no untrusted input and nothing to +// cap. They deliberately keep using the raw decoders: leaving them untouched means they stay an +// independent regression check on the capped wrappers, rather than testing those wrappers against +// themselves. +#![allow(clippy::disallowed_types)] + use bytes::{Buf, Bytes}; use flate2::read::{MultiGzDecoder, ZlibDecoder}; use futures::{channel::mpsc, stream, FutureExt, SinkExt, TryFutureExt}; diff --git a/src/sources/aws_kinesis_firehose/filters.rs b/src/sources/aws_kinesis_firehose/filters.rs index afd3ec507f..6377e17da6 100644 --- a/src/sources/aws_kinesis_firehose/filters.rs +++ b/src/sources/aws_kinesis_firehose/filters.rs @@ -1,15 +1,14 @@ -use std::{convert::Infallible, io}; +use std::convert::Infallible; use bytes::{Buf, Bytes}; use chrono::Utc; -use flate2::read::MultiGzDecoder; use snafu::ResultExt; use vector_lib::config::LogNamespace; use vector_lib::internal_event::{BytesReceived, Protocol}; use warp::{http::StatusCode, Filter}; use super::{ - errors::{ParseSnafu, RequestError}, + errors::{DecodeSnafu, ParseSnafu, RequestError}, handlers, models::{FirehoseRequest, FirehoseResponse}, Compression, @@ -17,6 +16,11 @@ use super::{ use crate::{ codecs, internal_events::{AwsKinesisFirehoseRequestError, AwsKinesisFirehoseRequestReceived}, + sources::util::{ + decompression::{CappedDecoder, CompressionLimits}, + http::capped_body, + http::ErrorMessage, + }, SourceSender, }; @@ -29,10 +33,12 @@ pub fn firehose( acknowledgements: bool, out: SourceSender, log_namespace: LogNamespace, + compression_limits: CompressionLimits, ) -> impl Filter + Clone { let bytes_received = register!(BytesReceived::from(Protocol::HTTP)); let context = handlers::Context { compression: record_compression, + compression_limits, store_access_key, decoder, acknowledgements, @@ -57,7 +63,7 @@ pub fn firehose( }) .untuple_one(), ) - .and(parse_body()) + .and(parse_body(compression_limits)) .and(warp::any().map(move || context.clone())) .and_then(handlers::firehose) .recover(handle_firehose_rejection) @@ -66,33 +72,41 @@ pub fn firehose( /// Decode (if needed) and parse request body /// /// Firehose can be configured to gzip compress messages so we handle this here -fn parse_body() -> impl Filter + Clone -{ +fn parse_body( + compression_limits: CompressionLimits, +) -> impl Filter + Clone { warp::any() .and(warp::header::optional::("Content-Encoding")) .and(warp::header("X-Amz-Firehose-Request-Id")) - .and(warp::body::bytes()) + .and(capped_body(&compression_limits)) .and_then( - |encoding: Option, request_id: String, body: Bytes| async move { - match encoding { - Some(s) if s == "gzip" => { - Ok(Box::new(MultiGzDecoder::new(body.reader())) as Box) - } - Some(s) => Err(warp::reject::Rejection::from( - RequestError::UnsupportedEncoding { - encoding: s, - request_id: request_id.clone(), - }, - )), - None => Ok(Box::new(body.reader()) as Box), - } - .and_then(|r| { - serde_json::from_reader(r) - .context(ParseSnafu { + move |encoding: Option, request_id: String, body: Bytes| async move { + // Decompress (if needed) into a buffer capped by the global decompressed-size + // limit so a gzip bomb cannot drive unbounded allocation. + let decoded: Bytes = match encoding { + Some(s) if s == "gzip" => CappedDecoder::gzip(body.reader(), &compression_limits) + .decompress() + .map(Bytes::from) + .with_context(|_| DecodeSnafu { request_id: request_id.clone(), }) - .map_err(warp::reject::custom) - }) + .map_err(warp::reject::custom)?, + Some(s) => { + return Err(warp::reject::Rejection::from( + RequestError::UnsupportedEncoding { + encoding: s, + request_id, + }, + )); + } + None => body, + }; + + serde_json::from_slice(&decoded) + .context(ParseSnafu { + request_id: request_id.clone(), + }) + .map_err(warp::reject::custom) }, ) } @@ -156,6 +170,13 @@ async fn handle_firehose_rejection(err: warp::Rejection) -> Result() { + // `capped_body()` rejects an oversized request body with an `ErrorMessage` carrying a 413. + // Without this arm warp falls through to a generic 500, which would misreport a client + // error as a server fault. + code = e.status_code(); + message = e.to_string(); + request_id = None; } else { code = StatusCode::INTERNAL_SERVER_ERROR; message = format!("{:?}", err); diff --git a/src/sources/aws_kinesis_firehose/handlers.rs b/src/sources/aws_kinesis_firehose/handlers.rs index cd424d97a3..14cd4d853c 100644 --- a/src/sources/aws_kinesis_firehose/handlers.rs +++ b/src/sources/aws_kinesis_firehose/handlers.rs @@ -1,9 +1,6 @@ -use std::io::Read; - use base64::prelude::{Engine as _, BASE64_STANDARD}; use bytes::Bytes; use chrono::Utc; -use flate2::read::MultiGzDecoder; use futures::StreamExt; use snafu::{ResultExt, Snafu}; use tokio_util::codec::FramedRead; @@ -36,13 +33,18 @@ use crate::{ internal_events::{ AwsKinesisFirehoseAutomaticRecordDecodeError, EventsReceived, StreamClosedError, }, - sources::aws_kinesis_firehose::AwsKinesisFirehoseConfig, + sources::{ + aws_kinesis_firehose::AwsKinesisFirehoseConfig, + util::decompression::{CappedDecoder, CompressionLimits, DecompressedSizeLimitExceeded}, + }, SourceSender, }; #[derive(Clone)] pub(super) struct Context { pub(super) compression: Compression, + /// Limits to decompress records under, from this component's context. + pub(super) compression_limits: CompressionLimits, pub(super) store_access_key: bool, pub(super) decoder: Decoder, pub(super) acknowledgements: bool, @@ -62,7 +64,7 @@ pub(super) async fn firehose( let events_received = register!(EventsReceived); for record in request.records { - let bytes = decode_record(&record, context.compression) + let bytes = decode_record(&record, context.compression, &context.compression_limits) .with_context(|_| ParseRecordsSnafu { request_id: request_id.clone(), }) @@ -205,6 +207,7 @@ pub enum RecordDecodeError { fn decode_record( record: &EncodedFirehoseRecord, compression: Compression, + limits: &CompressionLimits, ) -> Result { let buf = BASE64_STANDARD .decode(record.data.as_bytes()) @@ -216,12 +219,20 @@ fn decode_record( match compression { Compression::None => Ok(Bytes::from(buf)), - Compression::Gzip => decode_gzip(&buf[..]).with_context(|_| DecompressionSnafu { + Compression::Gzip => decode_gzip(&buf[..], limits).with_context(|_| DecompressionSnafu { compression: compression.to_owned(), }), Compression::Auto => { if is_gzip(&buf) { - decode_gzip(&buf[..]).or_else(|error| { + decode_gzip(&buf[..], limits).or_else(|error| { + // An exceeded size cap means the magic bytes really were gzip and the payload + // is oversized, so reject it. Only fall back to forwarding the raw bytes when + // auto-detection guessed wrong (valid-looking magic, but not actually gzip). + if DecompressedSizeLimitExceeded::is(&error) { + return Err(error).with_context(|_| DecompressionSnafu { + compression: Compression::Gzip, + }); + } emit!(AwsKinesisFirehoseAutomaticRecordDecodeError { compression: Compression::Gzip, error @@ -246,13 +257,9 @@ fn is_gzip(data: &[u8]) -> bool { data.starts_with(GZIP_MAGIC) } -fn decode_gzip(data: &[u8]) -> std::io::Result { - let mut decoded = Vec::new(); - - let mut gz = MultiGzDecoder::new(data); - gz.read_to_end(&mut decoded)?; - - Ok(Bytes::from(decoded)) +fn decode_gzip(data: &[u8], limits: &CompressionLimits) -> std::io::Result { + // Cap the decompressed output so a gzip-bomb record cannot drive unbounded allocation. + CappedDecoder::gzip(data, limits).decompress().map(Bytes::from) } #[cfg(test)] @@ -272,4 +279,87 @@ mod tests { let compressed = encoder.finish().unwrap(); assert!(is_gzip(&compressed)); } + + /// One cheap gzip member repeated past the cap. `MultiGzDecoder` walks every concatenated + /// member, so no single oversized member is required. + fn gzip_bomb() -> Vec { + use crate::sources::util::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(&vec![0u8; 1024 * 1024]).unwrap(); + let member = encoder.finish().unwrap(); + + let mut bomb = Vec::new(); + for _ in 0..(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1) { + bomb.extend_from_slice(&member); + } + assert!( + bomb.len() < 1024 * 1024, + "the bomb must stay small on the wire to be a meaningful test, got {} bytes", + bomb.len() + ); + bomb + } + + fn record(data: &[u8]) -> EncodedFirehoseRecord { + EncodedFirehoseRecord { + data: BASE64_STANDARD.encode(data), + } + } + + /// A gzip-bomb record must be refused rather than inflated. + #[test] + fn explicit_gzip_record_over_the_cap_is_rejected() { + let error = decode_record(&record(&gzip_bomb()), super::Compression::Gzip, &CompressionLimits::default()) + .expect_err("a record inflating past the cap must be rejected"); + + assert!(matches!( + error, + RecordDecodeError::Decompression { .. } + )); + } + + /// Under `Auto`, an oversized payload whose magic bytes really are gzip must be rejected, not + /// silently forwarded as raw bytes. The raw-bytes fallback exists only for a mis-detection. + #[test] + fn auto_detected_gzip_record_over_the_cap_is_rejected_not_forwarded() { + let error = decode_record(&record(&gzip_bomb()), super::Compression::Auto, &CompressionLimits::default()) + .expect_err("an oversized auto-detected gzip record must not fall back to raw bytes"); + + assert!(matches!( + error, + RecordDecodeError::Decompression { .. } + )); + } + + /// The cap must not disturb ordinary records, on either the explicit or the auto path. + #[test] + fn records_under_the_cap_are_unaffected() { + let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(CONTENT).unwrap(); + let compressed = encoder.finish().unwrap(); + + for compression in [super::Compression::Gzip, super::Compression::Auto] { + let decoded = decode_record(&record(&compressed), compression, &CompressionLimits::default()) + .expect("a record within the cap must decode"); + assert_eq!(decoded, Bytes::from_static(CONTENT)); + } + + let plain = decode_record(&record(CONTENT), super::Compression::None, &CompressionLimits::default()) + .expect("an uncompressed record must decode"); + assert_eq!(plain, Bytes::from_static(CONTENT)); + } + + /// Auto-detection guessing wrong (gzip magic, not actually gzip) must still fall back to + /// forwarding the raw bytes -- the size cap must not turn that into a hard failure. + #[test] + fn auto_detection_mistake_still_falls_back_to_raw_bytes() { + let mut not_gzip = vector_common::constants::GZIP_MAGIC.to_vec(); + not_gzip.extend_from_slice(b"definitely not a gzip stream"); + + let decoded = decode_record(&record(¬_gzip), super::Compression::Auto, &CompressionLimits::default()) + .expect("a mis-detected record must fall back to raw bytes"); + + assert_eq!(decoded, Bytes::from(not_gzip)); + } } diff --git a/src/sources/aws_kinesis_firehose/mod.rs b/src/sources/aws_kinesis_firehose/mod.rs index 4788692219..abdd32437c 100644 --- a/src/sources/aws_kinesis_firehose/mod.rs +++ b/src/sources/aws_kinesis_firehose/mod.rs @@ -174,6 +174,8 @@ impl SourceConfig for AwsKinesisFirehoseConfig { .flatten() .chain(self.access_key.iter()); + // From this component's context, so the deployment controls the cap. + let compression_limits = cx.globals.limits.compression; let svc = filters::firehose( access_keys.map(|key| key.inner().to_string()).collect(), self.store_access_key, @@ -182,6 +184,7 @@ impl SourceConfig for AwsKinesisFirehoseConfig { acknowledgements, cx.out, log_namespace, + compression_limits, ); let tls = MaybeTlsSettings::from_config(self.tls.as_ref(), true)?; @@ -301,7 +304,7 @@ mod tests { event::{Event, EventStatus}, log_event, test_util::{ - collect_ready, + collect_n, components::{assert_source_compliance, SOURCE_TAGS}, next_addr, wait_for_tcp, }, @@ -431,6 +434,137 @@ mod tests { builder.send().await } + /// Request-level caps, distinct from the per-record caps covered in `handlers::tests`. + mod request_body_caps { + use futures::StreamExt; + use similar_asserts::assert_eq; + + use super::*; + + /// The source is built with `acknowledgements: true`, and `new_test_finalize` only marks + /// an event acknowledged once it is dropped. So a test that awaits the HTTP response must + /// drain the pipeline concurrently, or the handler waits forever for an ack that cannot + /// arrive. + fn drain(rx: impl Stream + Unpin + Send + 'static) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut rx = rx; + while rx.next().await.is_some() {} + }) + } + + fn post(address: SocketAddr) -> reqwest::RequestBuilder { + reqwest::Client::new() + .post(format!("http://{}", address)) + .header("host", address.to_string()) + .header("x-amz-firehose-protocol-version", "1.0") + .header("x-amz-firehose-request-id", REQUEST_ID.to_string()) + .header("x-amz-firehose-source-arn", SOURCE_ARN.to_string()) + .header("content-type", "application/json") + } + + /// A `Content-Encoding: gzip` bomb on the request body must be refused rather than + /// inflated. `MultiGzDecoder` walks every concatenated member, so one cheap member + /// repeated past the cap suffices. + #[tokio::test] + async fn gzip_encoded_request_body_over_the_cap_is_rejected() { + use crate::sources::util::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + let (rx, address) = source(None, None, false, Compression::None, true, false).await; + let draining = drain(rx); + + let mut encoder = + GzEncoder::new(Cursor::new(vec![0u8; 1024 * 1024]), flate2::Compression::best()); + let mut member = Vec::new(); + encoder.read_to_end(&mut member).unwrap(); + + let mut bomb = Vec::new(); + for _ in 0..(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1) { + bomb.extend_from_slice(&member); + } + assert!(bomb.len() < 1024 * 1024, "bomb must stay small on the wire"); + + let response = post(address) + .header("content-encoding", "gzip") + .body(bomb) + .send() + .await + .unwrap(); + + // Asserting only `!= 200` would pass for the wrong reason: with the cap removed the + // bomb inflates to ~101 MiB, `serde_json` then fails to parse it and the handler + // answers 401 (`RequestError::Parse`), which is also non-200. Pin the decode failure + // specifically, which only the cap can produce. + assert_eq!(400, response.status().as_u16()); + let body = response.text().await.unwrap(); + assert!( + body.contains("Could not decode record"), + "expected the capped-decompression error, got: {body}" + ); + draining.abort(); + } + + /// `capped_body()` refuses an oversized declared `Content-Length` before reading any body + /// bytes. Sent over a raw socket so the test does not have to upload gigabytes. + #[tokio::test] + async fn oversized_declared_content_length_is_rejected() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (_rx, address) = source(None, None, false, Compression::None, true, false).await; + + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + let request = format!( + "POST / HTTP/1.1\r\n\ + Host: {address}\r\n\ + x-amz-firehose-protocol-version: 1.0\r\n\ + x-amz-firehose-request-id: {REQUEST_ID}\r\n\ + x-amz-firehose-source-arn: {SOURCE_ARN}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: 999999999999\r\n\ + \r\n" + ); + stream.write_all(request.as_bytes()).await.unwrap(); + stream.flush().await.unwrap(); + + // Without the declared-length guard the server waits for a body that never arrives, + // so bound the read: a regression must fail here rather than hang the suite. + let mut response = vec![0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(10), + stream.read(&mut response), + ) + .await + .expect("server must answer without waiting for the declared body") + .unwrap(); + let status_line = String::from_utf8_lossy(&response[..n]); + + assert!( + status_line.starts_with("HTTP/1.1 413"), + "expected 413 Payload Too Large, got: {status_line}" + ); + } + + /// An ordinary gzip-encoded request must still be accepted. + #[tokio::test] + async fn ordinary_gzip_encoded_request_is_accepted() { + let (rx, address) = source(None, None, false, Compression::None, true, false).await; + let draining = drain(rx); + + let response = send( + address, + Utc::now(), + vec![RECORD.as_bytes()], + None, + true, + Compression::None, + ) + .await + .unwrap(); + + assert_eq!(200, response.status().as_u16()); + draining.abort(); + } + } + async fn spawn_send( address: SocketAddr, timestamp: DateTime, @@ -541,7 +675,10 @@ mod tests { .await; if success { - let events = collect_ready(rx).await; + // collect_n drives the ack handshake: new_test_finalize only marks events + // acknowledged when they are polled, so the handler (which awaits the ack + // before returning 200) must see events drained here first. + let events = collect_n(rx, 1).await; let res = res.await.unwrap().unwrap(); assert_eq!(200, res.status().as_u16()); @@ -641,7 +778,7 @@ mod tests { .await; if success { - let events = collect_ready(rx).await; + let events = collect_n(rx, 1).await; let res = res.await.unwrap().unwrap(); assert_eq!(200, res.status().as_u16()); @@ -711,7 +848,7 @@ mod tests { ) .await; - let events = collect_ready(rx).await; + let events = collect_n(rx, 1).await; let res = res.await.unwrap().unwrap(); assert_eq!(200, res.status().as_u16()); @@ -871,7 +1008,7 @@ mod tests { ) .await; - let events = collect_ready(rx).await; + let events = collect_n(rx, 1).await; let res = res.await.unwrap().unwrap(); assert_eq!(406, res.status().as_u16()); @@ -915,7 +1052,7 @@ mod tests { ) .await; - let events = collect_ready(rx).await; + let events = collect_n(rx, 1).await; let access_key = events[0] .metadata() .secrets() @@ -940,7 +1077,7 @@ mod tests { ) .await; - let events = collect_ready(rx).await; + let events = collect_n(rx, 1).await; assert!(events[0] .metadata() @@ -998,7 +1135,6 @@ mod tests { #[tokio::test] async fn permit_origin_allows_matching_ip() { - use crate::test_util::collect_n; let (recv, address) = spawn_with_permit_origin(&["127.0.0.1"]).await; // Run concurrently: the server waits for event ack before sending the HTTP // response, so collecting must happen in parallel with the request. diff --git a/src/sources/datadog_agent/logs.rs b/src/sources/datadog_agent/logs.rs index 50ee4aebb2..d530c83ca5 100644 --- a/src/sources/datadog_agent/logs.rs +++ b/src/sources/datadog_agent/logs.rs @@ -12,6 +12,7 @@ use vector_lib::{config::LegacyKey, EstimatedJsonEncodedSizeOf}; use vrl::core::Value; use warp::{filters::BoxedFilter, path as warp_path, path::FullPath, reply::Response, Filter}; +use crate::sources::util::http::capped_body; use crate::common::datadog::DDTAGS; use crate::{ event::Event, @@ -37,7 +38,7 @@ pub(crate) fn build_warp_filter( .and(warp::header::optional::("content-encoding")) .and(warp::header::optional::("dd-api-key")) .and(warp::query::()) - .and(warp::body::bytes()) + .and(capped_body(&source.compression_limits)) .and_then( move |_, path: FullPath, diff --git a/src/sources/datadog_agent/metrics.rs b/src/sources/datadog_agent/metrics.rs index fd3bcdfe66..e7e7fa8823 100644 --- a/src/sources/datadog_agent/metrics.rs +++ b/src/sources/datadog_agent/metrics.rs @@ -14,6 +14,7 @@ use vector_lib::{ EstimatedJsonEncodedSizeOf, }; +use crate::sources::util::http::capped_body; use crate::{ common::datadog::{DatadogMetricType, DatadogSeriesMetric}, config::log_schema, @@ -69,7 +70,7 @@ fn sketches_service( .and(warp::header::optional::("content-encoding")) .and(warp::header::optional::("dd-api-key")) .and(warp::query::()) - .and(warp::body::bytes()) + .and(capped_body(&source.compression_limits)) .and_then( move |path: FullPath, encoding_header: Option, @@ -107,7 +108,7 @@ fn series_v1_service( .and(warp::header::optional::("content-encoding")) .and(warp::header::optional::("dd-api-key")) .and(warp::query::()) - .and(warp::body::bytes()) + .and(capped_body(&source.compression_limits)) .and_then( move |path: FullPath, encoding_header: Option, @@ -148,7 +149,7 @@ fn series_v2_service( .and(warp::header::optional::("content-encoding")) .and(warp::header::optional::("dd-api-key")) .and(warp::query::()) - .and(warp::body::bytes()) + .and(capped_body(&source.compression_limits)) .and_then( move |path: FullPath, encoding_header: Option, diff --git a/src/sources/datadog_agent/mod.rs b/src/sources/datadog_agent/mod.rs index 7b3b3f85c7..63d2fb0968 100644 --- a/src/sources/datadog_agent/mod.rs +++ b/src/sources/datadog_agent/mod.rs @@ -19,11 +19,10 @@ pub(crate) mod ddtrace_proto { use std::convert::Infallible; use std::time::Duration; -use std::{fmt::Debug, io::Read, net::SocketAddr, sync::Arc}; +use std::{fmt::Debug, net::SocketAddr, sync::Arc}; use bytes::{Buf, Bytes}; use chrono::{serde::ts_milliseconds, DateTime, Utc}; -use flate2::read::{MultiGzDecoder, ZlibDecoder}; use futures::{FutureExt, StreamExt}; use http::StatusCode; use hyper::service::make_service_fn; @@ -34,6 +33,9 @@ use snafu::Snafu; use tokio::net::TcpStream; use tower::ServiceBuilder; use tracing::Span; +use crate::sources::util::decompression::{ + CappedDecoder, CompressionLimits, DecompressedSizeLimitExceeded, +}; use vector_lib::codecs::decoding::{DeserializerConfig, FramingConfig}; use vector_lib::config::{LegacyKey, LogNamespace}; use vector_lib::configurable::configurable_component; @@ -194,6 +196,7 @@ impl SourceConfig for DatadogAgentConfig { logs_schema_definition, log_namespace, self.parse_ddtags, + cx.globals.limits.compression, ); let listener = tls.bind(&self.address).await?; let listener = listener @@ -345,6 +348,8 @@ pub struct ApiKeyQueryParams { #[derive(Clone)] pub(crate) struct DatadogAgentSource { + /// Limits to decompress under, from this component's context. + pub(crate) compression_limits: CompressionLimits, pub(crate) api_key_extractor: ApiKeyExtractor, pub(crate) log_schema_host_key: OwnedTargetPath, pub(crate) log_schema_source_type_key: OwnedTargetPath, @@ -391,8 +396,10 @@ impl DatadogAgentSource { logs_schema_definition: Option, log_namespace: LogNamespace, parse_ddtags: bool, + compression_limits: CompressionLimits, ) -> Self { Self { + compression_limits, api_key_extractor: ApiKeyExtractor { store_api_key, matcher: Regex::new(r"^/v1/input/(?P[[:alnum:]]{32})/??") @@ -467,26 +474,22 @@ impl DatadogAgentSource { for encoding in encodings.rsplit(',').map(str::trim) { body = match encoding { "identity" => body, - "gzip" | "x-gzip" => { - let mut decoded = Vec::new(); - MultiGzDecoder::new(body.reader()) - .read_to_end(&mut decoded) - .map_err(|error| handle_decode_error(encoding, error))?; - decoded.into() - } - "zstd" => { - let mut decoded = Vec::new(); - zstd::stream::copy_decode(body.reader(), &mut decoded) - .map_err(|error| handle_decode_error(encoding, error))?; - decoded.into() - } - "deflate" | "x-deflate" => { - let mut decoded = Vec::new(); - ZlibDecoder::new(body.reader()) - .read_to_end(&mut decoded) - .map_err(|error| handle_decode_error(encoding, error))?; - decoded.into() - } + // Cap each decompressed payload so a compression bomb cannot drive unbounded + // allocation on this unauthenticated HTTP listener. Capping every round also + // bounds a stacked `Content-Encoding: gzip,gzip,...` chain, since each round's + // output is the next round's input. + "gzip" | "x-gzip" => CappedDecoder::gzip(body.reader(), &self.compression_limits) + .decompress() + .map_err(|error| handle_decode_error(encoding, error, &self.compression_limits))? + .into(), + "zstd" => CappedDecoder::zstd_http(body.reader(), &self.compression_limits) + .and_then(CappedDecoder::decompress) + .map_err(|error| handle_decode_error(encoding, error, &self.compression_limits))? + .into(), + "deflate" | "x-deflate" => CappedDecoder::zlib(body.reader(), &self.compression_limits) + .decompress() + .map_err(|error| handle_decode_error(encoding, error, &self.compression_limits))? + .into(), encoding => { return Err(ErrorMessage::new( StatusCode::UNSUPPORTED_MEDIA_TYPE, @@ -544,7 +547,23 @@ pub(crate) async fn handle_request( } } -fn handle_decode_error(encoding: &str, error: impl std::error::Error) -> ErrorMessage { +fn handle_decode_error( + encoding: &str, + error: std::io::Error, + limits: &CompressionLimits, +) -> ErrorMessage { + // A size-cap trip is an oversized-request client fault, so report it as 413 with the limit + // that was enforced, matching the shared HTTP decoder. Anything else is malformed input (422). + if DecompressedSizeLimitExceeded::is(&error) { + return ErrorMessage::new( + StatusCode::PAYLOAD_TOO_LARGE, + format!( + "Decompressed {} body exceeds limit of {} bytes.", + encoding, + limits.max_decompressed_size_bytes + ), + ); + } emit!(HttpDecompressError { encoding, error: &error diff --git a/src/sources/datadog_agent/tests.rs b/src/sources/datadog_agent/tests.rs index c8c0803f27..95f84cdfac 100644 --- a/src/sources/datadog_agent/tests.rs +++ b/src/sources/datadog_agent/tests.rs @@ -14,6 +14,7 @@ use ordered_float::NotNan; use prost::Message; use quickcheck::{Arbitrary, Gen, QuickCheck, TestResult}; use similar_asserts::assert_eq; +use vector_common::decompression::CompressionLimits; use vector_lib::{ codecs::{decoding::CharacterDelimitedDecoderOptions, CharacterDelimitedDecoderConfig}, lookup::{owned_value_path, OwnedTargetPath}, @@ -103,6 +104,7 @@ fn test_decode_log_body() { Some(test_logs_schema_definition()), LogNamespace::Legacy, false, + CompressionLimits::default(), ); let events = decode_log_body(body, api_key, &source).unwrap(); @@ -158,6 +160,7 @@ fn test_decode_log_body_parse_ddtags() { Some(test_logs_schema_definition()), LogNamespace::Legacy, true, + CompressionLimits::default(), ); let events = decode_log_body(body, api_key, &source).unwrap(); @@ -194,6 +197,7 @@ fn test_decode_log_body_empty_object() { Some(test_logs_schema_definition()), LogNamespace::Legacy, false, + CompressionLimits::default(), ); let events = decode_log_body(body, api_key, &source).unwrap(); @@ -2635,3 +2639,163 @@ async fn permit_origin_blocks_non_fatal_emits_bad_peer_metric() { } register_validatable_component!(DatadogAgentConfig); + +/// OBE-11237: every `Content-Encoding` branch inflated the request body with an unbounded +/// `read_to_end`, so a small body on this unauthenticated listener could exhaust memory. +mod decompression_caps { + use std::io::Write as _; + + use similar_asserts::assert_eq; + use vector_common::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + use super::*; + use vector_common::decompression::CompressionLimits; + use crate::sources::datadog_agent::DatadogAgentSource; + + fn test_source() -> DatadogAgentSource { + let decoder = crate::codecs::Decoder::new( + Framer::Bytes(BytesDecoder::new()), + Deserializer::Bytes(BytesDeserializer), + ); + DatadogAgentSource::new( + true, + decoder, + "http", + None, + LogNamespace::Legacy, + false, + CompressionLimits::default(), + ) + } + + /// One cheap gzip member repeated past the cap. `MultiGzDecoder` walks every concatenated + /// member, so no single oversized member is required. + fn gzip_bomb() -> Bytes { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&vec![0u8; 1024 * 1024]).unwrap(); + let member = encoder.finish().unwrap(); + + let mut bomb = Vec::new(); + for _ in 0..(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1) { + bomb.extend_from_slice(&member); + } + assert!( + bomb.len() < 1024 * 1024, + "the bomb must stay small on the wire to be a meaningful test, got {} bytes", + bomb.len() + ); + Bytes::from(bomb) + } + + fn zlib_bomb() -> Bytes { + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + encoder + .write_all(&vec![0u8; DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES + 1]) + .unwrap(); + Bytes::from(encoder.finish().unwrap()) + } + + #[test] + fn gzip_body_over_the_cap_is_rejected() { + let error = test_source() + .decode(&Some("gzip".to_owned()), gzip_bomb(), "/api/v2/logs") + .expect_err("a body inflating past the cap must be rejected"); + + assert_eq!(error.status_code(), http::StatusCode::PAYLOAD_TOO_LARGE); + } + + #[test] + fn deflate_body_over_the_cap_is_rejected() { + let error = test_source() + .decode(&Some("deflate".to_owned()), zlib_bomb(), "/api/v2/logs") + .expect_err("a body inflating past the cap must be rejected"); + + assert_eq!(error.status_code(), http::StatusCode::PAYLOAD_TOO_LARGE); + } + + /// Stacking encodings used to multiply the amplification. Capping each round bounds the chain, + /// because every round's output is the next round's input. + /// + /// The payload must be genuinely double-gzipped: handing a singly-gzipped body a `gzip,gzip` + /// header would fail the second round as malformed regardless of the cap, and pass for the + /// wrong reason. Here the outer round yields the (small) bomb and the inner round is what + /// exceeds the cap. + #[test] + fn stacked_encodings_are_capped_at_every_round() { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&gzip_bomb()).unwrap(); + let doubly_compressed = Bytes::from(encoder.finish().unwrap()); + + let error = test_source() + .decode( + &Some("gzip,gzip".to_owned()), + doubly_compressed, + "/api/v2/logs", + ) + .expect_err("a stacked chain inflating past the cap must be rejected"); + + assert_eq!(error.status_code(), http::StatusCode::PAYLOAD_TOO_LARGE); + } + + /// Level 1 keeps each frame's declared window well under the RFC 9659 8 MiB ceiling that + /// `zstd_http` applies, so the window clamp stays out of the way and the size cap is what + /// rejects the payload. Concatenated frames are what push the aggregate past the cap. + #[test] + fn zstd_body_over_the_cap_is_rejected() { + let frame = zstd::encode_all(vec![0u8; 1024 * 1024].as_slice(), 1).unwrap(); + let mut bomb = Vec::new(); + for _ in 0..(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1) { + bomb.extend_from_slice(&frame); + } + + let error = test_source() + .decode(&Some("zstd".to_owned()), Bytes::from(bomb), "/api/v2/logs") + .expect_err("a body inflating past the cap must be rejected"); + + assert_eq!(error.status_code(), http::StatusCode::PAYLOAD_TOO_LARGE); + } + + /// A realistic agent frame must still decode: the 8 MiB window clamp must not reject ordinary + /// zstd traffic. + #[test] + fn zstd_body_under_the_cap_is_unaffected() { + let payload = b"{\"message\":\"hello\"}"; + let compressed = Bytes::from(zstd::encode_all(&payload[..], 3).unwrap()); + + let decoded = test_source() + .decode(&Some("zstd".to_owned()), compressed, "/api/v2/logs") + .expect("a body within the cap must decode"); + + assert_eq!(decoded, Bytes::from_static(payload)); + } + + /// Malformed input must stay a 422, distinct from the 413 the cap raises — otherwise the + /// size tests above could be passing for the wrong reason. + #[test] + fn malformed_payload_is_422_not_413() { + let error = test_source() + .decode( + &Some("gzip".to_owned()), + Bytes::from_static(b"not gzip at all"), + "/api/v2/logs", + ) + .expect_err("malformed input must be rejected"); + + assert_eq!(error.status_code(), http::StatusCode::UNPROCESSABLE_ENTITY); + } + + /// The cap must not disturb ordinary traffic. + #[test] + fn body_under_the_cap_is_unaffected() { + let payload = b"{\"message\":\"hello\"}"; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(payload).unwrap(); + let compressed = Bytes::from(encoder.finish().unwrap()); + + let decoded = test_source() + .decode(&Some("gzip".to_owned()), compressed, "/api/v2/logs") + .expect("a body within the cap must decode"); + + assert_eq!(decoded, Bytes::from_static(payload)); + } +} diff --git a/src/sources/datadog_agent/traces.rs b/src/sources/datadog_agent/traces.rs index a9bef10865..9b1921499d 100644 --- a/src/sources/datadog_agent/traces.rs +++ b/src/sources/datadog_agent/traces.rs @@ -12,6 +12,7 @@ use warp::{filters::BoxedFilter, path, path::FullPath, reply::Response, Filter, use vector_lib::internal_event::{CountByteSize, InternalEventHandle as _}; use vector_lib::EstimatedJsonEncodedSizeOf; +use crate::sources::util::http::capped_body; use crate::{ event::{Event, ObjectMap, TraceEvent, Value}, sources::{ @@ -48,7 +49,7 @@ fn build_trace_filter( "X-Datadog-Reported-Languages", )) .and(warp::query::()) - .and(warp::body::bytes()) + .and(capped_body(&source.compression_limits)) .and_then( move |path: FullPath, encoding_header: Option, diff --git a/src/sources/fluent/message.rs b/src/sources/fluent/message.rs index 25c3c05890..597a4ef944 100644 --- a/src/sources/fluent/message.rs +++ b/src/sources/fluent/message.rs @@ -32,8 +32,12 @@ pub(super) enum FluentMessage { PackedForward(FluentTag, serde_bytes::ByteBuf), PackedForwardWithOptions(FluentTag, serde_bytes::ByteBuf, FluentMessageOptions), - // should be last as it'll match any other message - Heartbeat(rmpv::Value), // should be Nil if heartbeat + // Should be last, as an untagged variant matches whatever the earlier ones reject. + // + // Deliberately `()` rather than `rmpv::Value`: the Forward spec sends nil for a heartbeat, and + // typing it as nil means an unrecognised message is refused by serde instead of being + // materialised into an arbitrary, arbitrarily-nested value. + Heartbeat(()), } /// Server options sent by client. diff --git a/src/sources/fluent/mod.rs b/src/sources/fluent/mod.rs index 47afb99ded..54ecdf92a0 100644 --- a/src/sources/fluent/mod.rs +++ b/src/sources/fluent/mod.rs @@ -1,12 +1,11 @@ use std::collections::HashMap; -use std::io::{self, Read}; +use std::io; use std::net::SocketAddr; use std::time::Duration; use base64::prelude::{Engine as _, BASE64_STANDARD}; use bytes::{Buf, Bytes, BytesMut}; use chrono::Utc; -use flate2::read::MultiGzDecoder; use rmp_serde::{decode, Deserializer, Serializer}; use serde::{Deserialize, Serialize}; use smallvec::{smallvec, SmallVec}; @@ -21,6 +20,7 @@ use vector_lib::schema::Definition; use vrl::value::kind::Collection; use vrl::value::{Kind, Value}; +use super::util::decompression::{CappedDecoder, CompressionLimits}; use super::util::net::{SocketListenAddr, TcpSource, TcpSourceAck, TcpSourceAcker}; use crate::{ config::{ @@ -35,7 +35,24 @@ use crate::{ }; mod message; +mod scan; use self::message::{FluentEntry, FluentMessage, FluentRecord, FluentTag, FluentTimestamp}; +use self::scan::scan_msgpack_frame; + +/// Default ceiling on concurrent connections to the (unauthenticated) fluent listener. +const fn default_connection_limit() -> Option { + Some(1024) +} + +/// Default for [`FluentConfig::max_entries_per_frame`]. +const fn default_max_entries_per_frame() -> usize { + 100_000 +} + +/// Default for [`FluentConfig::max_msgpack_depth`]. +const fn default_max_msgpack_depth() -> usize { + crate::sources::fluent::scan::DEFAULT_MAX_MSGPACK_DEPTH +} /// Configuration for the `fluent` source. #[configurable_component(source("fluent", "Collect logs from a Fluentd or Fluent Bit agent."))] @@ -45,9 +62,38 @@ pub struct FluentConfig { address: SocketListenAddr, /// The maximum number of TCP connections that are allowed at any given time. + /// + /// Defaults to a finite value: the source is unauthenticated, so an unlimited connection + /// count lets a peer multiply any per-connection memory cost without bound. #[configurable(metadata(docs::type_unit = "connections"))] + #[serde(default = "default_connection_limit")] connection_limit: Option, + /// The maximum number of entries a single frame may decode into. + /// + /// A frame within the byte cap can still carry a very large number of tiny entries, each of + /// which becomes an event; this bounds the burst one frame can turn into. + #[configurable(metadata(docs::type_unit = "entries"))] + #[serde(default = "default_max_entries_per_frame")] + max_entries_per_frame: usize, + + /// The maximum MessagePack nesting depth accepted from a peer. + /// + /// Fluent records are shallow in practice — a tag, a timestamp and a flat map of fields — so + /// the default leaves generous headroom while keeping recursion far below any stack limit. + /// Nesting costs one byte per level on the wire, so the frame size limit cannot bound it. + #[configurable(metadata(docs::type_unit = "levels"))] + #[serde(default = "default_max_msgpack_depth")] + max_msgpack_depth: usize, + + /// The maximum size, in bytes, of a single MessagePack frame buffered while waiting for a + /// complete message. + /// + /// Defaults to the global `--max-decompressed-size-bytes` limit. + #[configurable(metadata(docs::type_unit = "bytes"))] + #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")] + max_frame_bytes: Option, + #[configurable(derived)] keepalive: Option, @@ -78,12 +124,15 @@ impl GenerateConfig for FluentConfig { fn generate_config() -> toml::Value { toml::Value::try_from(Self { address: SocketListenAddr::SocketAddr("0.0.0.0:24224".parse().unwrap()), + max_entries_per_frame: default_max_entries_per_frame(), + max_msgpack_depth: default_max_msgpack_depth(), keepalive: None, permit_origin: None, tls: None, receive_buffer_bytes: None, acknowledgements: Default::default(), - connection_limit: Some(2), + connection_limit: default_connection_limit(), + max_frame_bytes: None, log_namespace: None, }) .unwrap() @@ -95,7 +144,13 @@ impl GenerateConfig for FluentConfig { impl SourceConfig for FluentConfig { async fn build(&self, cx: SourceContext) -> crate::Result { let log_namespace = cx.log_namespace(self.log_namespace); - let source = FluentSource::new(log_namespace); + let source = FluentSource::new( + log_namespace, + self.max_frame_bytes, + cx.globals.limits.compression, + self.max_entries_per_frame, + self.max_msgpack_depth, + ); let shutdown_secs = Duration::from_secs(30); let tls_config = self.tls.as_ref().map(|tls| tls.tls_config.clone()); let tls_client_metadata_key = self @@ -211,15 +266,29 @@ impl FluentConfig { #[derive(Debug, Clone)] struct FluentSource { + compression_limits: CompressionLimits, + max_entries_per_frame: usize, + max_msgpack_depth: usize, log_namespace: LogNamespace, legacy_host_key_path: Option, + max_frame_bytes: Option, } impl FluentSource { - fn new(log_namespace: LogNamespace) -> Self { + fn new( + log_namespace: LogNamespace, + max_frame_bytes: Option, + compression_limits: CompressionLimits, + max_entries_per_frame: usize, + max_msgpack_depth: usize, + ) -> Self { Self { + compression_limits, + max_entries_per_frame, + max_msgpack_depth, log_namespace, legacy_host_key_path: log_schema().host_key().cloned(), + max_frame_bytes, } } } @@ -231,7 +300,13 @@ impl TcpSource for FluentSource { type Acker = FluentAcker; fn decoder(&self) -> Self::Decoder { - FluentDecoder::new(self.log_namespace) + FluentDecoder::new( + self.log_namespace, + self.max_frame_bytes, + self.compression_limits, + self.max_entries_per_frame, + self.max_msgpack_depth, + ) } fn handle_events(&self, events: &mut [Event], host: SocketAddr) { @@ -263,7 +338,40 @@ pub enum DecodeError { IO(io::Error), Decode(decode::Error), UnknownCompression(String), - UnexpectedValue(rmpv::Value), + /// The buffered frame grew past the maximum allowed size before a complete message could be + /// decoded. Bounds memory when a peer declares an oversized msgpack array/map/string and + /// streams the bytes to force unbounded buffering. + FrameTooLarge { + size: usize, + max: usize, + /// What the decoder was actually reporting when the frame was rejected. + /// + /// Normally `UnexpectedEof` — "give me more bytes" — which is why this is worth keeping: + /// without it the log says the frame is too large while the decoder said it was merely + /// incomplete, and the two are confusing to reconcile when debugging. + kind: io::ErrorKind, + }, + /// The frame nests deeper than `rmp_serde` can safely recurse over. Nesting costs one byte per + /// level, so a byte-size cap cannot bound it. + FrameTooDeep { + depth: usize, + max: usize, + }, + /// The frame declares a string/binary length or element count that no frame within the size + /// cap could satisfy. + DeclaredLengthTooLarge { + len: usize, + max: usize, + }, + /// A marker byte that is never valid MessagePack. + InvalidMarker { + marker: u8, + }, + /// One frame decoded into more entries than a single frame is allowed to produce. + TooManyEntries { + count: usize, + max: usize, + }, } impl std::fmt::Display for DecodeError { @@ -274,8 +382,37 @@ impl std::fmt::Display for DecodeError { DecodeError::UnknownCompression(compression) => { write!(f, "unknown compression: {}", compression) } - DecodeError::UnexpectedValue(value) => { - write!(f, "unexpected msgpack value, ignoring: {}", value) + DecodeError::FrameTooLarge { size, max, kind } => { + write!( + f, + "fluent frame exceeds maximum size before decoding: {} bytes buffered, limit \ + is {} bytes (decoder reported {:?})", + size, max, kind + ) + } + DecodeError::FrameTooDeep { depth, max } => { + write!( + f, + "fluent frame nests too deeply: depth {} exceeds limit of {}", + depth, max + ) + } + DecodeError::DeclaredLengthTooLarge { len, max } => { + write!( + f, + "fluent frame declares a length of {} bytes, beyond the {} byte limit", + len, max + ) + } + DecodeError::InvalidMarker { marker } => { + write!(f, "invalid msgpack marker byte {:#04x}", marker) + } + DecodeError::TooManyEntries { count, max } => { + write!( + f, + "fluent frame decodes to {} entries, beyond the limit of {}", + count, max + ) } } } @@ -287,7 +424,13 @@ impl StreamDecodingError for DecodeError { DecodeError::IO(_) => false, DecodeError::Decode(_) => true, DecodeError::UnknownCompression(_) => true, - DecodeError::UnexpectedValue(_) => true, + // A structurally hostile or oversized partial frame has no framing boundary to + // resync on, so the connection must be dropped rather than re-decoded in a loop. + DecodeError::FrameTooLarge { .. } + | DecodeError::FrameTooDeep { .. } + | DecodeError::DeclaredLengthTooLarge { .. } + | DecodeError::InvalidMarker { .. } + | DecodeError::TooManyEntries { .. } => false, } } } @@ -306,12 +449,45 @@ impl From for DecodeError { #[derive(Debug)] struct FluentDecoder { + /// Limits to decompress under, from this component's context. + compression_limits: CompressionLimits, + max_entries_per_frame: usize, + max_msgpack_depth: usize, log_namespace: LogNamespace, + /// Maximum number of bytes that may be buffered while waiting for a complete frame. Bounds + /// memory against a peer that declares an oversized msgpack structure and streams the bytes to + /// force unbounded buffering. + max_frame_size: usize, } impl FluentDecoder { - const fn new(log_namespace: LogNamespace) -> Self { - Self { log_namespace } + fn new( + log_namespace: LogNamespace, + max_frame_bytes: Option, + compression_limits: CompressionLimits, + max_entries_per_frame: usize, + max_msgpack_depth: usize, + ) -> Self { + Self { + log_namespace, + max_frame_size: max_frame_bytes + .unwrap_or(compression_limits.max_decompressed_size_bytes), + compression_limits, + max_entries_per_frame, + max_msgpack_depth, + } + } + + /// Bounds how many events one frame may expand into. A frame within the byte cap can still + /// carry a very large number of tiny entries. + fn ensure_entry_count(&self, count: usize) -> Result<(), DecodeError> { + if count > self.max_entries_per_frame { + return Err(DecodeError::TooManyEntries { + count, + max: self.max_entries_per_frame, + }); + } + Ok(()) } fn handle_message( @@ -349,6 +525,7 @@ impl FluentDecoder { Ok(Some((frame, byte_size))) } FluentMessage::Forward(tag, entries) => { + self.ensure_entry_count(entries.len())?; let events = entries .into_iter() .map(|FluentEntry(timestamp, record)| { @@ -367,6 +544,7 @@ impl FluentDecoder { Ok(Some((frame, byte_size))) } FluentMessage::ForwardWithOptions(tag, entries, options) => { + self.ensure_entry_count(entries.len())?; let events = entries .into_iter() .map(|FluentEntry(timestamp, record)| { @@ -388,9 +566,13 @@ impl FluentDecoder { let mut buf = BytesMut::from(&bin[..]); let mut events = smallvec![]; - while let Some(FluentEntry(timestamp, record)) = - FluentEntryStreamDecoder.decode(&mut buf)? + while let Some(FluentEntry(timestamp, record)) = (FluentEntryStreamDecoder { + max_frame_size: self.max_frame_size, + max_msgpack_depth: self.max_msgpack_depth, + }) + .decode(&mut buf)? { + self.ensure_entry_count(events.len() + 1)?; events.push(Event::from(FluentEvent { tag: tag.clone(), timestamp, @@ -406,13 +588,12 @@ impl FluentDecoder { } FluentMessage::PackedForwardWithOptions(tag, bin, options) => { let buf = match options.compressed.as_deref() { - Some("gzip") => { - let mut buf = Vec::new(); - MultiGzDecoder::new(io::Cursor::new(bin.into_vec())) - .read_to_end(&mut buf) - .map(|_| buf) - .map_err(Into::into) - } + Some("gzip") => CappedDecoder::gzip( + io::Cursor::new(bin.into_vec()), + &self.compression_limits, + ) + .decompress() + .map_err(Into::into), Some("text") | None => Ok(bin.into_vec()), Some(s) => Err(DecodeError::UnknownCompression(s.to_owned())), }?; @@ -420,9 +601,13 @@ impl FluentDecoder { let mut buf = BytesMut::from(&buf[..]); let mut events = smallvec![]; - while let Some(FluentEntry(timestamp, record)) = - FluentEntryStreamDecoder.decode(&mut buf)? + while let Some(FluentEntry(timestamp, record)) = (FluentEntryStreamDecoder { + max_frame_size: self.max_frame_size, + max_msgpack_depth: self.max_msgpack_depth, + }) + .decode(&mut buf)? { + self.ensure_entry_count(events.len() + 1)?; events.push(Event::from(FluentEvent { tag: tag.clone(), timestamp, @@ -436,8 +621,7 @@ impl FluentDecoder { }; Ok(Some((frame, byte_size))) } - FluentMessage::Heartbeat(rmpv::Value::Nil) => Ok(None), - FluentMessage::Heartbeat(value) => Err(DecodeError::UnexpectedValue(value)), + FluentMessage::Heartbeat(()) => Ok(None), } } } @@ -452,6 +636,12 @@ impl Decoder for FluentDecoder { return Ok(None); } + // Reject structurally hostile frames before `rmp_serde` recurses over them. Nesting + // costs one byte per level on the wire, so `max_frame_size` cannot bound recursion + // depth on its own. Truncation is not an error here: the `UnexpectedEof` path below + // still asks for more bytes. + scan_msgpack_frame(&src[..], self.max_msgpack_depth, self.max_frame_size)?; + let (byte_size, res) = { let mut des = Deserializer::new(io::Cursor::new(&src[..])); @@ -464,6 +654,18 @@ impl Decoder for FluentDecoder { )) = res { if custom.kind() == io::ErrorKind::UnexpectedEof { + // We need more bytes before a full message can be decoded. Bound the + // buffer so a peer cannot force unbounded memory growth by declaring a + // huge msgpack array/map/string and streaming the bytes: if the frame has + // already grown past the limit without yielding a complete message, drop + // the connection. + if src.len() > self.max_frame_size { + return Err(DecodeError::FrameTooLarge { + size: src.len(), + max: self.max_frame_size, + kind: custom.kind(), + }); + } return Ok(None); } } @@ -489,7 +691,12 @@ impl Decoder for FluentDecoder { /// Decoder for decoding MessagePackEventStream which are just a stream of Entries #[derive(Clone, Debug)] -struct FluentEntryStreamDecoder; +struct FluentEntryStreamDecoder { + /// Frame-size bound for the entries inside a decompressed payload. + max_frame_size: usize, + /// Nesting bound for those entries. + max_msgpack_depth: usize, +} impl Decoder for FluentEntryStreamDecoder { type Item = FluentEntry; @@ -499,6 +706,10 @@ impl Decoder for FluentEntryStreamDecoder { if src.is_empty() { return Ok(None); } + + // The entries inside a `PackedForward` payload are attacker-controlled too — the gzip cap + // bounds their size but not their nesting depth. + scan_msgpack_frame(&src[..], self.max_msgpack_depth, self.max_frame_size)?; let (byte_size, res) = { let mut des = Deserializer::new(io::Cursor::new(&src[..])); @@ -851,10 +1062,343 @@ mod tests { assert_event_data_eq!(got.0[2], expected[2]); } + /// A valid but incomplete frame must ask for more data rather than erroring — otherwise the + /// frame cap would break ordinary streaming reads. + #[test] + fn decode_incomplete_frame_requests_more_data() { + // An array of 2 elements (`0x92`) with a tag string declaring 16 bytes (`0xb0`) but only + // 4 bytes provided: a valid, incomplete frame. + let partial: Vec = vec![0x92, 0xb0, b't', b'a', b'g']; + let mut buf = BytesMut::from(&partial[..]); + let mut decoder = FluentDecoder::new( + LogNamespace::default(), + None, + CompressionLimits::default(), + default_max_entries_per_frame(), + default_max_msgpack_depth(), + ); + + assert!(matches!(decoder.decode(&mut buf), Ok(None))); + // The buffer is retained so more bytes can complete the frame. + assert_eq!(buf.len(), partial.len()); + } + + /// OBE-11557: a peer declaring an oversized msgpack structure could stream bytes forever and + /// grow the connection's frame buffer without bound, since an incomplete frame simply asked + /// for more data. + #[test] + fn decode_oversized_frame_is_rejected() { + // Same shape as above (a 2-element array whose string is declared far larger than what has + // arrived), but with a decoder whose frame cap is tiny. + let max_frame_size = 8; + let partial: Vec = vec![0x92, 0xb0, b't', b'a', b'g', b'.', b'n', b'a', b'm', b'e']; + assert!(partial.len() > max_frame_size); + + let mut buf = BytesMut::from(&partial[..]); + let mut decoder = FluentDecoder { + compression_limits: CompressionLimits::default(), + max_entries_per_frame: default_max_entries_per_frame(), + max_msgpack_depth: default_max_msgpack_depth(), + log_namespace: LogNamespace::default(), + max_frame_size, + }; + + let error = match decoder.decode(&mut buf) { + Err(error) => error, + Ok(_) => panic!("expected FrameTooLarge, got Ok"), + }; + + assert!( + matches!( + error, + DecodeError::FrameTooLarge { size, max, kind } + if size == partial.len() + && max == max_frame_size + // the decoder was mid-frame, which is why the kind is preserved + && kind == io::ErrorKind::UnexpectedEof + ), + "unexpected error: {error:?}" + ); + // A frame-too-large error must terminate the connection. + assert!(!error.can_continue()); + } + + /// OBE-11233: the report asks for a per-source frame cap rather than only a global one. + #[test] + fn max_frame_bytes_config_overrides_the_global_cap() { + let decoder = FluentDecoder::new( + LogNamespace::default(), + Some(4096), + CompressionLimits::default(), + default_max_entries_per_frame(), + default_max_msgpack_depth(), + ); + assert_eq!(decoder.max_frame_size, 4096); + + let default = FluentDecoder::new( + LogNamespace::default(), + None, + CompressionLimits::default(), + default_max_entries_per_frame(), + default_max_msgpack_depth(), + ); + assert_eq!( + default.max_frame_size, + CompressionLimits::default().max_decompressed_size_bytes + ); + } + + /// OBE-11233: the listener is unauthenticated, so an unlimited connection count multiplies + /// every per-connection cost. + #[test] + fn connection_limit_defaults_to_a_finite_value() { + let config: FluentConfig = toml::from_str(r#"address = "0.0.0.0:24224""#).unwrap(); + assert_eq!(config.connection_limit, default_connection_limit()); + assert!(config.connection_limit.is_some()); + } + + fn test_decoder() -> FluentDecoder { + FluentDecoder::new( + LogNamespace::default(), + None, + CompressionLimits::default(), + default_max_entries_per_frame(), + default_max_msgpack_depth(), + ) + } + + /// The limits are configuration, not constants: a source configured tighter than the default + /// must actually enforce the configured value. Without this the fields could be wired to + /// nothing and every test above would still pass on the defaults. + #[test] + fn configured_limits_override_the_defaults() { + let decoder = FluentDecoder::new( + LogNamespace::default(), + None, + CompressionLimits::default(), + 5, // max_entries_per_frame + 3, // max_msgpack_depth + ); + + // Entry count: at the configured limit is fine, one past it is not. + decoder + .ensure_entry_count(5) + .expect("a frame at the configured entry limit must be accepted"); + let error = decoder + .ensure_entry_count(6) + .expect_err("one entry past the configured limit must be rejected"); + assert!( + matches!(error, DecodeError::TooManyEntries { max, .. } if max == 5), + "the error should report the configured limit, got: {error:?}" + ); + + // Depth: nesting past the configured depth is refused even though it is far below the + // 128-level default. + let mut nested = BytesMut::new(); + for _ in 0..10 { + nested.extend_from_slice(&[0x91]); // fixarray of 1 + } + nested.extend_from_slice(&[0xc0]); // nil + let mut decoder = decoder; + let error = match decoder.decode(&mut nested) { + Err(error) => error, + Ok(_) => panic!("nesting past the configured depth must be rejected"), + }; + assert!( + matches!(error, DecodeError::FrameTooDeep { max, .. } if max == 3), + "the error should report the configured depth, got: {error:?}" + ); + } + + /// OBE-11233: a frame within the byte cap can still carry a huge number of tiny entries. + #[test] + fn entry_count_beyond_the_limit_is_rejected() { + let decoder = test_decoder(); + let error = decoder + .ensure_entry_count(default_max_entries_per_frame() + 1) + .expect_err("a frame decoding to too many entries must be rejected"); + + assert!(matches!(error, DecodeError::TooManyEntries { .. })); + assert!(!error.can_continue()); + } + + #[test] + fn entry_count_within_the_limit_is_accepted() { + test_decoder() + .ensure_entry_count(default_max_entries_per_frame()) + .expect("a frame at the limit must be accepted"); + } + + /// A nil heartbeat is the documented Forward-protocol keepalive and must still be accepted. + #[test] + fn nil_heartbeat_is_accepted() { + let mut buf = BytesMut::from(&[0xc0u8][..]); // msgpack nil + let mut decoder = FluentDecoder::new( + LogNamespace::default(), + None, + CompressionLimits::default(), + default_max_entries_per_frame(), + default_max_msgpack_depth(), + ); + + assert!( + matches!(decoder.decode(&mut buf), Ok(None)), + "a nil heartbeat must be consumed without producing an event" + ); + assert!(buf.is_empty(), "the heartbeat byte must be consumed"); + } + + /// OBE-11233: the catch-all used to be `rmpv::Value`, so any unrecognised message was + /// materialised into an arbitrary value. It is now typed as nil, so serde refuses the message + /// instead — and the failure must stay recoverable, since an unknown message shape from an + /// otherwise well-behaved client is not a reason to drop the connection. + #[test] + fn unrecognised_message_is_refused_without_materialising_it() { + // A bare integer matches no variant: not a heartbeat, not a tagged message. + let mut buf = BytesMut::from(&[0x2au8][..]); + let mut decoder = FluentDecoder::new( + LogNamespace::default(), + None, + CompressionLimits::default(), + default_max_entries_per_frame(), + default_max_msgpack_depth(), + ); + + let error = match decoder.decode(&mut buf) { + Err(error) => error, + Ok(_) => panic!("expected a decode error, got Ok"), + }; + + assert!( + error.can_continue(), + "an unknown message shape must not drop the connection" + ); + + // Recoverable is only safe if the frame was consumed. serde buffers an untagged enum + // whole before choosing a variant, so the deserializer advances past the message even + // when no variant matches; without that this would re-decode the same byte forever, the + // livelock OBE-11559 describes for logstash. + assert!( + buf.is_empty(), + "the refused message must still be consumed, or the decoder livelocks" + ); + + // And the stream keeps working: the next well-formed message decodes normally. + buf.extend_from_slice(&[0xc0u8]); // nil heartbeat + assert!( + matches!(decoder.decode(&mut buf), Ok(None)), + "the decoder should carry on after refusing an unknown message" + ); + assert!(buf.is_empty()); + } + + /// OBE-10708: a deeply nested frame must be refused by our pre-scan *before* `rmp_serde` + /// sees it. + /// + /// This cannot be written as a test of the library's own behaviour: OBE-11233 claims + /// rmp-serde/rmpv "provide a recursion-depth guard (MAX_DEPTH=128)", but that is wrong on two + /// counts. rmp-serde 1.3.0 defaults to 1024, not 128, and measurement shows the guard does not + /// fire on the `rmpv::Value` path at all — deserialising 2,000 nesting levels directly + /// overflows the stack and aborts the process rather than returning `DepthLimitExceeded`. + /// Our scan is therefore the only thing standing between this input and a crash, and the + /// assertion below is safe precisely because the scan runs first. + #[test] + fn deeply_nested_frame_is_rejected_before_rmp_serde_recurses() { + // 0x91 is a one-element array, so each byte adds a nesting level. 2,000 levels is enough + // to abort the process if it ever reaches `rmp_serde`. + let mut buf = BytesMut::from(&vec![0x91u8; 2_000][..]); + let mut decoder = FluentDecoder::new( + LogNamespace::default(), + None, + CompressionLimits::default(), + default_max_entries_per_frame(), + default_max_msgpack_depth(), + ); + + let error = match decoder.decode(&mut buf) { + Err(error) => error, + Ok(_) => panic!("expected FrameTooDeep, got Ok"), + }; + + assert!( + matches!(error, DecodeError::FrameTooDeep { .. }), + "unexpected error: {error:?}" + ); + assert!( + !error.can_continue(), + "an over-deep frame must drop the connection" + ); + } + + /// The same guard must protect the inner entry stream, whose contents come from a decompressed + /// `PackedForward` payload and are equally untrusted. + #[test] + fn deeply_nested_inner_entry_is_rejected() { + let mut buf = BytesMut::from(&vec![0x91u8; 2_000][..]); + + let error = match (FluentEntryStreamDecoder { + max_frame_size: usize::MAX, + max_msgpack_depth: default_max_msgpack_depth(), + }) + .decode(&mut buf) + { + Err(error) => error, + Ok(_) => panic!("expected FrameTooDeep, got Ok"), + }; + + assert!(matches!(error, DecodeError::FrameTooDeep { .. })); + } + + /// OBE-11233 / OBE-10708: `CompressedPackedForward` inflated the client's gzip payload with an + /// unbounded `read_to_end`, so a small frame could drive an arbitrarily large allocation. + /// + /// `MultiGzDecoder` walks every concatenated member, so repeating one cheap member past the cap + /// is enough to exceed it — no single oversized member required. + #[test] + fn compressed_packed_forward_decompression_is_capped() { + use std::collections::BTreeMap; + use std::io::Write as _; + + use vector_common::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&vec![0u8; 1024 * 1024]).unwrap(); + let member = encoder.finish().unwrap(); + + let mut bomb = Vec::new(); + for _ in 0..(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1) { + bomb.extend_from_slice(&member); + } + assert!( + bomb.len() < 1024 * 1024, + "the bomb must stay small on the wire to be a meaningful test, got {} bytes", + bomb.len() + ); + + let options = BTreeMap::from([("compressed", "gzip")]); + let message = rmp_serde::to_vec(&("tag.name", serde_bytes::ByteBuf::from(bomb), options)) + .expect("failed to build the fluent frame"); + + let error = + decode_all(message).expect_err("a payload inflating past the cap must be rejected"); + + assert!(matches!(error, DecodeError::IO(_)), "got {error:?}"); + assert!( + !error.can_continue(), + "an oversized frame must drop the connection rather than be retried" + ); + } + fn decode_all(message: Vec) -> Result<(SmallVec<[Event; 1]>, usize), DecodeError> { let mut buf = BytesMut::from(&message[..]); - let mut decoder = FluentDecoder::new(LogNamespace::default()); + let mut decoder = FluentDecoder::new( + LogNamespace::default(), + None, + CompressionLimits::default(), + default_max_entries_per_frame(), + default_max_msgpack_depth(), + ); let (frame, byte_size) = decoder.decode(&mut buf)?.unwrap(); Ok((frame.into(), byte_size)) @@ -900,12 +1444,15 @@ mod tests { let address = next_addr(); let source = FluentConfig { address: address.into(), + max_entries_per_frame: default_max_entries_per_frame(), + max_msgpack_depth: default_max_msgpack_depth(), tls: None, keepalive: None, permit_origin: None, receive_buffer_bytes: None, acknowledgements: true.into(), connection_limit: None, + max_frame_bytes: None, log_namespace: None, } .build(SourceContext::new_test(sender, None)) @@ -965,12 +1512,15 @@ mod tests { fn output_schema_definition_vector_namespace() { let config = FluentConfig { address: SocketListenAddr::SocketAddr("0.0.0.0:24224".parse().unwrap()), + max_entries_per_frame: default_max_entries_per_frame(), + max_msgpack_depth: default_max_msgpack_depth(), tls: None, keepalive: None, permit_origin: None, receive_buffer_bytes: None, acknowledgements: false.into(), connection_limit: None, + max_frame_bytes: None, log_namespace: Some(true), }; @@ -1021,12 +1571,15 @@ mod tests { fn output_schema_definition_legacy_namespace() { let config = FluentConfig { address: SocketListenAddr::SocketAddr("0.0.0.0:24224".parse().unwrap()), + max_entries_per_frame: default_max_entries_per_frame(), + max_msgpack_depth: default_max_msgpack_depth(), tls: None, keepalive: None, permit_origin: None, receive_buffer_bytes: None, acknowledgements: false.into(), connection_limit: None, + max_frame_bytes: None, log_namespace: None, }; @@ -1243,12 +1796,15 @@ mod integration_tests { tokio::spawn(async move { FluentConfig { address: address.into(), + max_entries_per_frame: default_max_entries_per_frame(), + max_msgpack_depth: default_max_msgpack_depth(), tls: None, keepalive: None, permit_origin: None, receive_buffer_bytes: None, acknowledgements: false.into(), connection_limit: None, + max_frame_bytes: None, log_namespace: None, } .build(SourceContext::new_test(sender, None)) diff --git a/src/sources/fluent/scan.rs b/src/sources/fluent/scan.rs new file mode 100644 index 0000000000..f3a6e9ede8 --- /dev/null +++ b/src/sources/fluent/scan.rs @@ -0,0 +1,325 @@ +//! Structural pre-scan of an untrusted MessagePack frame. +//! +//! `rmp_serde` deserialises nested MessagePack by recursing, and the `fluent` source hands it +//! attacker-controlled bytes. Nesting costs one byte per level on the wire (`0x91` for a +//! one-element array), so a small frame can drive hundreds of thousands of stack frames and +//! overflow the stack — a byte-size cap such as +//! [`FluentDecoder::max_frame_size`](super::FluentDecoder) cannot defend against it. +//! +//! Recursion is reachable through more than one path: `FluentRecord` values are `rmpv::Value`, +//! the `Heartbeat` variant is a bare `rmpv::Value`, `#[serde(untagged)]` buffers input into +//! serde's own recursive `Content` type before any variant is chosen, and converting the result +//! into a VRL `Value` (and later dropping it) recurses again. Bounding depth here, at admission, +//! bounds all of them at once. +//! +//! The scan is deliberately **iterative**: a recursive scanner would reintroduce the very bug it +//! exists to prevent. + +use super::DecodeError; + +/// Default maximum MessagePack nesting depth accepted from a peer. +/// +/// Fluent records are shallow in practice — a tag, a timestamp and a flat map of fields. This +/// leaves generous headroom for nested objects while keeping recursion far below any stack limit. +/// Configurable per source via `max_msgpack_depth`. +pub(super) const DEFAULT_MAX_MSGPACK_DEPTH: usize = 128; + +/// Walks the MessagePack structure in `buf` without recursing, rejecting frames that are nested +/// too deeply or that declare a length no legitimate frame could satisfy. +/// +/// A truncated buffer is **not** an error: the caller is a streaming decoder, so an incomplete +/// prefix simply means more bytes are needed and the scan stops early. Only structural violations +/// are reported. +/// +/// `max_len` bounds any single declared length (string, binary, ext) and any declared element +/// count. A container needs at least one byte per element, so a count beyond `max_len` can never +/// be satisfied within a frame that size — rejecting it up front also keeps this scan cheap. +pub(super) fn scan_msgpack_frame( + buf: &[u8], + max_depth: usize, + max_len: usize, +) -> Result<(), DecodeError> { + // Remaining element count at each open container level; the initial entry is the single + // top-level value. + let mut stack: Vec = vec![1]; + let mut pos: usize = 0; + + // Reads `n` bytes as a big-endian length, or signals truncation. + fn read_len(buf: &[u8], pos: usize, n: usize) -> Option { + let bytes = buf.get(pos..pos + n)?; + let mut value: u64 = 0; + for byte in bytes { + value = (value << 8) | u64::from(*byte); + } + usize::try_from(value).ok() + } + + let too_large = |len: usize| DecodeError::DeclaredLengthTooLarge { len, max: max_len }; + + while let Some(remaining) = stack.last_mut() { + if *remaining == 0 { + stack.pop(); + continue; + } + *remaining -= 1; + + let Some(&marker) = buf.get(pos) else { + // Truncated: the caller needs more bytes. + return Ok(()); + }; + pos += 1; + + // `payload` is a byte count to skip; `children` is a count of nested values to expect. + let (payload, children) = match marker { + // fixint (positive and negative), nil, false, true + 0x00..=0x7f | 0xc0 | 0xc2 | 0xc3 | 0xe0..=0xff => (0, 0), + // never used + 0xc1 => return Err(DecodeError::InvalidMarker { marker }), + 0x80..=0x8f => (0, 2 * usize::from(marker & 0x0f)), // fixmap + 0x90..=0x9f => (0, usize::from(marker & 0x0f)), // fixarray + 0xa0..=0xbf => (usize::from(marker & 0x1f), 0), // fixstr + 0xcc | 0xd0 => (1, 0), + 0xcd | 0xd1 => (2, 0), + 0xca | 0xce | 0xd2 => (4, 0), + 0xcb | 0xcf | 0xd3 => (8, 0), + 0xd4 => (2, 0), // fixext1 (type + 1) + 0xd5 => (3, 0), // fixext2 + 0xd6 => (5, 0), // fixext4 + 0xd7 => (9, 0), // fixext8 + 0xd8 => (17, 0), // fixext16 + // bin / str with an explicit length + 0xc4 | 0xd9 | 0xc5 | 0xda | 0xc6 | 0xdb => { + let width = match marker { + 0xc4 | 0xd9 => 1, + 0xc5 | 0xda => 2, + _ => 4, + }; + let Some(len) = read_len(buf, pos, width) else { + return Ok(()); + }; + if len > max_len { + return Err(too_large(len)); + } + pos += width; + (len, 0) + } + // ext with an explicit length (payload carries a one-byte type tag) + 0xc7 | 0xc8 | 0xc9 => { + let width = match marker { + 0xc7 => 1, + 0xc8 => 2, + _ => 4, + }; + let Some(len) = read_len(buf, pos, width) else { + return Ok(()); + }; + if len > max_len { + return Err(too_large(len)); + } + pos += width; + (len.saturating_add(1), 0) + } + // array / map with an explicit element count + 0xdc | 0xdd | 0xde | 0xdf => { + let width = if matches!(marker, 0xdc | 0xde) { 2 } else { 4 }; + let Some(count) = read_len(buf, pos, width) else { + return Ok(()); + }; + if count > max_len { + return Err(too_large(count)); + } + pos += width; + let children = if matches!(marker, 0xde | 0xdf) { + count.saturating_mul(2) + } else { + count + }; + (0, children) + } + }; + + if payload > 0 { + match pos.checked_add(payload) { + Some(next) if next <= buf.len() => pos = next, + // Truncated, or a length that overflows the buffer: need more bytes. + _ => return Ok(()), + } + } + + if children > 0 { + if stack.len() >= max_depth { + return Err(DecodeError::FrameTooDeep { + depth: stack.len() + 1, + max: max_depth, + }); + } + stack.push(children); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use vector_lib::codecs::StreamDecodingError; + + use super::*; + + const MAX_LEN: usize = 1024 * 1024; + + fn scan(buf: &[u8]) -> Result<(), DecodeError> { + scan_msgpack_frame(buf, DEFAULT_MAX_MSGPACK_DEPTH, MAX_LEN) + } + + /// `0x91` is a one-element array, so each byte adds a nesting level. + fn nested(levels: usize) -> Vec { + let mut buf = vec![0x91; levels]; + buf.push(0xc0); // nil at the centre + buf + } + + // ---- accept: ordinary frames must be unaffected ---- + + #[test] + fn accepts_a_typical_fluent_frame() { + // ["tag", 1441588984, {"message": "foo"}] + let frame = rmp_serde::to_vec(&( + "tag.name", + 1_441_588_984u32, + std::collections::BTreeMap::from([("message", "foo")]), + )) + .unwrap(); + + scan(&frame).expect("an ordinary fluent frame must be accepted"); + } + + #[test] + fn accepts_nesting_just_below_the_limit() { + scan(&nested(DEFAULT_MAX_MSGPACK_DEPTH - 1)) + .expect("nesting within the limit must be accepted"); + } + + #[test] + fn accepts_every_scalar_marker() { + for frame in [ + vec![0xc0], // nil + vec![0xc2], // false + vec![0xc3], // true + vec![0x7f], // positive fixint + vec![0xff], // negative fixint + vec![0xcc, 0x01], // uint8 + vec![0xcd, 0x00, 0x01], // uint16 + vec![0xce, 0, 0, 0, 1], // uint32 + vec![0xcf, 0, 0, 0, 0, 0, 0, 0, 1], // uint64 + vec![0xcb, 0, 0, 0, 0, 0, 0, 0, 0], // float64 + vec![0xa3, b'f', b'o', b'o'], // fixstr + vec![0xc4, 0x02, 0xaa, 0xbb], // bin8 + vec![0xd4, 0x00, 0x01], // fixext1 + vec![0xc7, 0x01, 0x00, 0xaa], // ext8 + ] { + scan(&frame).unwrap_or_else(|e| panic!("marker {:#04x} rejected: {e}", frame[0])); + } + } + + /// A streaming decoder feeds partial frames constantly; truncation must never be an error. + #[test] + fn truncation_is_not_an_error() { + let frame = rmp_serde::to_vec(&("tag.name", 1u32, "payload")).unwrap(); + for cut in 0..frame.len() { + scan(&frame[..cut]) + .unwrap_or_else(|e| panic!("truncation at {cut} must not error, got {e}")); + } + } + + /// A declared length larger than the buffer is truncation, not a violation, so long as it + /// stays within the cap — the rest of the frame may still be in flight. + #[test] + fn declared_length_within_the_cap_but_not_yet_arrived_is_truncation() { + // bin32 declaring 4096 bytes, none of which have arrived. + let frame = vec![0xc6, 0x00, 0x00, 0x10, 0x00]; + scan(&frame).expect("a legitimate declared length awaiting bytes must not error"); + } + + // ---- reject: the two vectors this scan exists for ---- + + /// OBE-10708: one byte per nesting level, so a byte-size cap cannot bound recursion depth. + #[test] + fn rejects_nesting_past_the_limit() { + let error = scan(&nested(DEFAULT_MAX_MSGPACK_DEPTH + 1)) + .expect_err("nesting past the limit must be rejected"); + + assert!( + matches!(error, DecodeError::FrameTooDeep { max, .. } if max == DEFAULT_MAX_MSGPACK_DEPTH), + "unexpected error: {error:?}" + ); + assert!( + !error.can_continue(), + "an over-deep frame must drop the connection" + ); + } + + /// The depth guard must hold for maps as well as arrays. + #[test] + fn rejects_deep_map_nesting() { + // 0x81 is a one-pair fixmap: key, then a nested map as the value. + let mut frame = Vec::new(); + for _ in 0..=DEFAULT_MAX_MSGPACK_DEPTH { + frame.push(0x81); + frame.push(0xc0); // nil key + } + frame.push(0xc0); + + let error = scan(&frame).expect_err("deep map nesting must be rejected"); + assert!(matches!(error, DecodeError::FrameTooDeep { .. })); + } + + /// OBE-11233: a declared length no frame could satisfy is refused up front, so the claim + /// cannot resurface if a dependency bump changes how `rmp_serde` pre-allocates. + #[test] + fn rejects_declared_length_beyond_the_cap() { + // bin32 declaring ~4 GiB. + let frame = vec![0xc6, 0xff, 0xff, 0xff, 0xff]; + + let error = scan(&frame).expect_err("an impossible declared length must be rejected"); + assert!( + matches!(error, DecodeError::DeclaredLengthTooLarge { max, .. } if max == MAX_LEN), + "unexpected error: {error:?}" + ); + assert!(!error.can_continue()); + } + + #[test] + fn rejects_declared_element_count_beyond_the_cap() { + // array32 declaring ~4 billion elements. + let frame = vec![0xdd, 0xff, 0xff, 0xff, 0xff]; + + let error = scan(&frame).expect_err("an impossible element count must be rejected"); + assert!(matches!(error, DecodeError::DeclaredLengthTooLarge { .. })); + } + + #[test] + fn rejects_str32_and_map32_beyond_the_cap() { + for frame in [ + vec![0xdb, 0xff, 0xff, 0xff, 0xff], // str32 + vec![0xdf, 0xff, 0xff, 0xff, 0xff], // map32 + ] { + let error = scan(&frame).expect_err("an impossible declared length must be rejected"); + assert!(matches!(error, DecodeError::DeclaredLengthTooLarge { .. })); + } + } + + #[test] + fn rejects_the_never_used_marker() { + let error = scan(&[0xc1]).expect_err("0xc1 is never valid msgpack"); + assert!(matches!(error, DecodeError::InvalidMarker { marker: 0xc1 })); + } + + /// The scan must not itself recurse, or it reintroduces the bug it prevents. A frame far + /// deeper than any stack could handle must return an error rather than crash the process. + #[test] + fn scanning_is_iterative_and_survives_pathological_depth() { + let error = scan(&nested(5_000_000)).expect_err("must be rejected, not overflow the stack"); + assert!(matches!(error, DecodeError::FrameTooDeep { .. })); + } +} diff --git a/src/sources/http_server.rs b/src/sources/http_server.rs index 59599a17c5..7c475f06ff 100644 --- a/src/sources/http_server.rs +++ b/src/sources/http_server.rs @@ -1838,6 +1838,61 @@ mod tests { spawn_simple_http_source(address, permit_origin, context).await; } + /// The shared `HttpSource` filter now collects the body through `capped_body()`, which + /// refuses an oversized declared `Content-Length` before reading any body bytes. Sent over a + /// raw socket so the test does not have to upload gigabytes. + /// + /// This covers `http_server` and, through the same prelude filter, `heroku_logs`. + #[tokio::test] + async fn oversized_declared_body_is_rejected_with_413() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (sender, _recv) = SourceSender::new_test_finalize(EventStatus::Delivered); + let address = next_addr(); + spawn_simple_http_source(address, None, SourceContext::new_test(sender, None)).await; + wait_for_tcp(address).await; + + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + let request = format!( + "POST / HTTP/1.1\r\n\ + Host: {address}\r\n\ + Content-Length: 999999999999\r\n\ + \r\n" + ); + stream.write_all(request.as_bytes()).await.unwrap(); + stream.flush().await.unwrap(); + + // Without the declared-length guard the server waits for a body that never arrives, so + // bound the read: a regression must fail here rather than hang the suite. + let mut response = vec![0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(10), + stream.read(&mut response), + ) + .await + .expect("server must answer without waiting for the declared body") + .unwrap(); + let status_line = String::from_utf8_lossy(&response[..n]); + + assert!( + status_line.starts_with("HTTP/1.1 413"), + "expected 413 Payload Too Large, got: {status_line}" + ); + } + + /// An ordinary body must still be accepted through the same filter. + #[tokio::test] + async fn ordinary_body_is_accepted() { + let (sender, _recv) = SourceSender::new_test_finalize(EventStatus::Delivered); + let address = next_addr(); + spawn_simple_http_source(address, None, SourceContext::new_test(sender, None)).await; + wait_for_tcp(address).await; + + let response = send_http_event(address, "hello").await.unwrap(); + + assert_eq!(200, response.status().as_u16()); + } + async fn send_http_event( address: std::net::SocketAddr, body: &'static str, diff --git a/src/sources/logstash.rs b/src/sources/logstash.rs index e81b5498e0..34f408a142 100644 --- a/src/sources/logstash.rs +++ b/src/sources/logstash.rs @@ -3,12 +3,11 @@ use std::time::Duration; use std::{ collections::{BTreeMap, VecDeque}, convert::TryFrom, - io::{self, Read}, + io, }; use vector_lib::ipallowlist::IpAllowlistConfig; use bytes::{Buf, Bytes, BytesMut}; -use flate2::read::ZlibDecoder; use smallvec::{smallvec, SmallVec}; use snafu::{ResultExt, Snafu}; use tokio_util::codec::Decoder; @@ -22,6 +21,7 @@ use vector_lib::{ use vrl::value::kind::Collection; use vrl::value::{KeyString, Kind}; +use super::util::decompression::{CappedDecoder, CompressionLimits}; use super::util::net::{SocketListenAddr, TcpSource, TcpSourceAck, TcpSourceAcker}; use crate::{ config::{ @@ -35,21 +35,6 @@ use crate::{ types, }; -/// Cap on the inflated size of a single compressed frame. -/// -/// One Beats `C` frame carries an entire window, so its inflated size scales with the sender's -/// batch size (`bulk_max_size` defaults to 2048 events; go-lumber's `maxWindowSize` allows 10000) -/// times the per-event size. 256 MiB sits above any such batch, so the bound only ever trips on a -/// decompression bomb. -/// -/// The bound is per frame, so peak memory is this value times the concurrent connection count. -/// Set a finite `connection_limit` if that product matters for your deployment. -const DEFAULT_MAX_DECOMPRESSED_BYTES: u64 = 256 * 1024 * 1024; - -fn default_max_decompressed_bytes() -> u64 { - DEFAULT_MAX_DECOMPRESSED_BYTES -} - /// Configuration for the `logstash` source. #[configurable_component(source("logstash", "Collect logs from a Logstash agent."))] #[derive(Clone, Debug)] @@ -86,16 +71,6 @@ pub struct LogstashConfig { #[configurable(metadata(docs::hidden))] #[serde(default)] log_namespace: Option, - - /// Maximum size in bytes that a compressed frame payload is allowed to expand to. - /// Guards against decompression bomb (zip bomb) attacks. Defaults to 256 MiB. - /// - /// This bound applies per frame, so peak memory scales with the number of concurrent - /// connections. Raise it only alongside a finite `connection_limit`. - #[configurable(metadata(docs::type_unit = "bytes"))] - #[configurable(metadata(docs::advanced))] - #[serde(default = "default_max_decompressed_bytes")] - max_decompressed_bytes: u64, } impl LogstashConfig { @@ -152,7 +127,6 @@ impl Default for LogstashConfig { acknowledgements: Default::default(), connection_limit: None, log_namespace: None, - max_decompressed_bytes: default_max_decompressed_bytes(), } } } @@ -169,10 +143,11 @@ impl SourceConfig for LogstashConfig { async fn build(&self, cx: SourceContext) -> crate::Result { let log_namespace = cx.log_namespace(self.log_namespace); let source = LogstashSource { + // From this component's context, so the deployment controls the cap. + compression_limits: cx.globals.limits.compression, timestamp_converter: types::Conversion::Timestamp(cx.globals.timezone()), legacy_host_key_path: log_schema().host_key().cloned(), log_namespace, - max_decompressed_bytes: self.max_decompressed_bytes, }; let shutdown_secs = Duration::from_secs(30); let tls_config = self.tls.as_ref().map(|tls| tls.tls_config.clone()); @@ -220,10 +195,10 @@ impl SourceConfig for LogstashConfig { #[derive(Debug, Clone)] struct LogstashSource { + compression_limits: CompressionLimits, timestamp_converter: types::Conversion, log_namespace: LogNamespace, legacy_host_key_path: Option, - max_decompressed_bytes: u64, } impl TcpSource for LogstashSource { @@ -233,7 +208,7 @@ impl TcpSource for LogstashSource { type Acker = LogstashAcker; fn decoder(&self) -> Self::Decoder { - LogstashDecoder::new(self.max_decompressed_bytes) + LogstashDecoder::new(self.compression_limits) } fn handle_events(&self, events: &mut [Event], host: SocketAddr) { @@ -343,25 +318,32 @@ enum LogstashDecoderReadState { #[derive(Debug)] struct LogstashDecoder { + /// Limits to decompress under, from this component's context. + compression_limits: CompressionLimits, state: LogstashDecoderReadState, - inside_compressed: bool, - max_decompressed_bytes: u64, + // Set for the decoder used to parse a decompressed payload. No known + // Lumberjack/Beats client emits a compressed frame nested inside another, + // so a nested `C` frame here is rejected rather than recursed into. + // Without this, an attacker could nest compressed frames arbitrarily deep + // and drive unbounded recursion in `decode_compressed_frame`, exhausting + // the stack (CWE-674). + nested: bool, } impl LogstashDecoder { - fn new(max_decompressed_bytes: u64) -> Self { + const fn new(compression_limits: CompressionLimits) -> Self { Self { state: LogstashDecoderReadState::ReadProtocol, - inside_compressed: false, - max_decompressed_bytes, + nested: false, + compression_limits, } } - fn new_inside_compressed(max_decompressed_bytes: u64) -> Self { + const fn new_nested(compression_limits: CompressionLimits) -> Self { Self { state: LogstashDecoderReadState::ReadProtocol, - inside_compressed: true, - max_decompressed_bytes, + nested: true, + compression_limits, } } } @@ -378,22 +360,18 @@ pub enum DecodeError { JsonFrameFailedDecode { source: serde_json::Error }, #[snafu(display("Failed to decompress compressed frame: {}", source))] DecompressionFailed { source: io::Error }, - #[snafu(display("Nested compressed frames are not allowed"))] - NestedCompressionRejected, + #[snafu(display("Compressed frame contains a nested compressed frame"))] + NestedCompressedFrame, } impl StreamDecodingError for DecodeError { fn can_continue(&self) -> bool { - use DecodeError::*; - - match self { - IO { .. } => false, - UnknownProtocolVersion { .. } => false, - UnknownFrameType { .. } => false, - JsonFrameFailedDecode { .. } => true, - DecompressionFailed { .. } => true, - NestedCompressionRejected => false, - } + // No decode error is recoverable on this stream. Lumberjack is a + // length-prefixed binary protocol with no resync marker, so once a + // frame fails to decode the stream position is no longer trustworthy: + // continuing would misframe subsequent bytes and emit ACKs for bogus + // sequence numbers. + false } } @@ -579,10 +557,12 @@ impl Decoder for LogstashDecoder { } // https://github.com/logstash-plugins/logstash-input-beats/blob/master/PROTOCOL.md#compressed-frame-type LogstashDecoderReadState::ReadFrame(_protocol, LogstashFrameType::Compressed) => { - if self.inside_compressed { - return Err(DecodeError::NestedCompressionRejected); + if self.nested { + return Err(DecodeError::NestedCompressedFrame); } - let Some(frames) = decode_compressed_frame(src, self.max_decompressed_bytes)? else { + + let Some(frames) = decode_compressed_frame(src, &self.compression_limits)? + else { return Ok(None); }; @@ -693,7 +673,7 @@ fn decode_json_frame( fn decode_compressed_frame( src: &mut BytesMut, - max_decompressed_bytes: u64, + limits: &CompressionLimits, ) -> Result>, DecodeError> { let mut rest = src.as_ref(); @@ -702,49 +682,38 @@ fn decode_compressed_frame( } let payload_size = rest.get_u32() as usize; + // Reject an oversized declared payload before buffering it, so a peer cannot force multi-GB + // buffering by advertising a huge length and slow-streaming its bytes. The bound includes + // zlib's worst-case expansion so a valid frame whose decompressed content is within `limit` + // is never rejected here; the decompressed cap itself is still enforced below. + let compressed_limit = limits.max_zlib_compressed_frame_size_bytes(); + if payload_size > compressed_limit { + return Err(DecodeError::DecompressionFailed { + source: io::Error::other(format!( + "compressed frame payload size {} exceeds limit of {} bytes", + payload_size, compressed_limit + )), + }); + } + if rest.remaining() < payload_size { - src.reserve(payload_size); return Ok(None); } let (slice, right) = rest.split_at(payload_size); rest = right; - let mut buf = Vec::new(); - - // Cap output with `.take()` so a decompression bomb can never allocate without bound. Reading - // up to `max + 1` bytes lets us distinguish "exactly at the limit" (legal) from "truncated at - // the limit" (rejected) — capping at `max` alone makes those two cases indistinguishable and - // would reject a payload that is exactly `max_decompressed_bytes` long. - let res: Result<(), DecodeError> = ZlibDecoder::new(io::Cursor::new(slice)) - .take(max_decompressed_bytes.saturating_add(1)) - .read_to_end(&mut buf) - .context(DecompressionFailedSnafu) - .and_then(|_| { - if buf.len() as u64 > max_decompressed_bytes { - Err(DecodeError::DecompressionFailed { - source: io::Error::new( - io::ErrorKind::Other, - format!( - "decompressed size limit of {max_decompressed_bytes} bytes exceeded" - ), - ), - }) - } else { - Ok(()) - } - }); + let res = CappedDecoder::zlib(io::Cursor::new(slice), limits) + .decompress() + .map(|decompressed| BytesMut::from(decompressed.as_slice())) + .context(DecompressionFailedSnafu); let byte_size = bytes_remaining(src, rest); src.advance(byte_size); - res?; - - let mut buf = BytesMut::from(buf.as_slice()); + let mut buf = res?; - // Use `new_inside_compressed` so that any nested C frame encountered while - // decoding the inflated bytes is rejected immediately. - let mut decoder = LogstashDecoder::new_inside_compressed(max_decompressed_bytes); + let mut decoder = LogstashDecoder::new_nested(*limits); let mut frames = VecDeque::new(); @@ -801,145 +770,6 @@ mod test { crate::test_util::test_generate_config::(); } - /// Wraps `payload` in the length-prefixed envelope `decode_compressed_frame` expects. - fn zlib_frame(payload: &[u8]) -> BytesMut { - use flate2::write::ZlibEncoder; - use flate2::Compression; - use std::io::Write; - - let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); - enc.write_all(payload).unwrap(); - let compressed = enc.finish().unwrap(); - - let mut src = BytesMut::new(); - src.extend_from_slice(&(compressed.len() as u32).to_be_bytes()); - src.extend_from_slice(&compressed); - src - } - - #[test] - fn decompression_bomb_exceeds_limit() { - let mut src = zlib_frame(&vec![b'A'; 200]); - - // A limit of 10 bytes is well below the 200-byte inflated output. - let result = decode_compressed_frame(&mut src, 10); - assert!( - matches!(result, Err(DecodeError::DecompressionFailed { .. })), - "expected DecompressionFailed, got {result:?}", - ); - } - - /// Boundary: a payload that inflates to exactly the limit is legal. Capping the reader at - /// `max` (rather than `max + 1`) made this case indistinguishable from a truncated bomb and - /// rejected it. - #[test] - fn decompression_at_exactly_the_limit_is_accepted() { - let plain = vec![b'A'; 200]; - let mut src = zlib_frame(&plain); - - let result = decode_compressed_frame(&mut src, plain.len() as u64); - assert!( - !matches!(result, Err(DecodeError::DecompressionFailed { .. })), - "a payload exactly at the limit must not be rejected as a bomb, got {result:?}", - ); - } - - #[test] - fn decompression_one_byte_over_the_limit_is_rejected() { - let plain = vec![b'A'; 200]; - let mut src = zlib_frame(&plain); - - let result = decode_compressed_frame(&mut src, plain.len() as u64 - 1); - assert!( - matches!(result, Err(DecodeError::DecompressionFailed { .. })), - "one byte over the limit must be rejected, got {result:?}", - ); - } - - /// The source bytes must be consumed even when the frame is rejected, otherwise the same bomb - /// is re-decoded forever. - #[test] - fn rejected_bomb_still_advances_the_source_buffer() { - let mut src = zlib_frame(&vec![b'A'; 200]); - let original_len = src.len(); - - let _ = decode_compressed_frame(&mut src, 10); - assert!( - src.len() < original_len, - "the rejected frame's bytes must be drained from the buffer" - ); - } - - #[test] - fn nested_compressed_frame_rejected() { - // Inner payload: version=0x32, type=0x43 ('C'), payload_len=0x00000000. - // When the inside_compressed decoder encounters 'C' in ReadFrame state it returns - // NestedCompressionRejected before ever calling decode_compressed_frame again. - let mut src = zlib_frame(&[0x32, 0x43, 0, 0, 0, 0]); - - let result = decode_compressed_frame(&mut src, 1024 * 1024); - assert!( - matches!(result, Err(DecodeError::NestedCompressionRejected)), - "expected NestedCompressionRejected, got {result:?}", - ); - } - - /// A nested compressed frame is unrecoverable: continuing would let the sender keep feeding - /// nested bombs down the same connection. - #[test] - fn nested_compression_error_terminates_the_stream() { - assert!(!DecodeError::NestedCompressionRejected.can_continue()); - } - - /// A single oversized frame is a per-frame condition, so the connection survives it. - #[test] - fn decompression_failure_does_not_terminate_the_stream() { - assert!(DecodeError::DecompressionFailed { - source: io::Error::new(io::ErrorKind::Other, "boom"), - } - .can_continue()); - } - - #[test] - fn top_level_decoder_is_not_marked_inside_compressed() { - // Only frames reached *through* a compressed frame may reject nesting; a plain 'C' frame - // at the top level is legal and must still decode. - assert!(!LogstashDecoder::new(DEFAULT_MAX_DECOMPRESSED_BYTES).inside_compressed); - assert!( - LogstashDecoder::new_inside_compressed(DEFAULT_MAX_DECOMPRESSED_BYTES) - .inside_compressed - ); - } - - #[test] - fn default_max_decompressed_bytes_is_256_mib() { - // Pinned deliberately: this bound is per-frame, so raising it multiplies peak memory by - // the concurrent connection count. - assert_eq!(DEFAULT_MAX_DECOMPRESSED_BYTES, 256 * 1024 * 1024); - assert_eq!( - LogstashConfig::default().max_decompressed_bytes, - DEFAULT_MAX_DECOMPRESSED_BYTES - ); - } - - #[test] - fn max_decompressed_bytes_round_trips_through_config() { - let config: LogstashConfig = - serde_json::from_str(r#"{"address":"0.0.0.0:5044","max_decompressed_bytes":1234}"#) - .unwrap(); - assert_eq!(config.max_decompressed_bytes, 1234); - } - - #[test] - fn max_decompressed_bytes_defaults_when_absent_from_config() { - let config: LogstashConfig = - serde_json::from_str(r#"{"address":"0.0.0.0:5044"}"#).unwrap(); - assert_eq!( - config.max_decompressed_bytes, - DEFAULT_MAX_DECOMPRESSED_BYTES - ); - } - #[tokio::test] async fn test_delivered() { test_protocol(EventStatus::Delivered, true).await; @@ -964,7 +794,6 @@ mod test { acknowledgements: true.into(), connection_limit: None, log_namespace: None, - max_decompressed_bytes: default_max_decompressed_bytes(), } .build(SourceContext::new_test(sender, None)) .await @@ -1113,6 +942,218 @@ mod test { assert_eq!(definitions, Some(expected_definition)) } + + /// OBE-10711: a compressed frame's 4-byte length header was fed straight to `src.reserve()`, + /// so six bytes on the wire could commit a multi-gigabyte allocation. The declared length is + /// now checked against zlib's worst-case expansion of the decompressed cap first. + #[test] + fn oversized_declared_frame_is_rejected_before_reserving() { + let mut src = BytesMut::new(); + src.put_u32(u32::MAX); + src.put_slice(b"partial"); + + let error = decode_compressed_frame(&mut src, &CompressionLimits::default()) + .expect_err("a frame declaring more than the cap must be rejected"); + + assert!( + error.to_string().contains("exceeds limit"), + "expected the declared-size guard, got: {error}" + ); + } + + /// A frame whose *compressed* length is legitimate but which inflates past the decompressed + /// cap must still be refused — the declared-length guard alone is not enough. + #[test] + fn decompressed_bomb_is_rejected() { + use std::io::Write as _; + + use vector_common::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1024 * 1024]; + for _ in 0..(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1) { + encoder.write_all(&chunk).unwrap(); + } + let compressed = encoder.finish().unwrap(); + + assert!( + compressed.len() < CompressionLimits::default().max_zlib_compressed_frame_size_bytes(), + "the bomb must pass the declared-length guard so the decompressed cap is what fires" + ); + + let mut src = BytesMut::new(); + src.put_u32(compressed.len() as u32); + src.put_slice(&compressed); + + let error = decode_compressed_frame(&mut src, &CompressionLimits::default()) + .expect_err("a frame inflating past the cap must be rejected"); + + assert!(matches!(error, DecodeError::DecompressionFailed { .. })); + } + + /// The caps must not disturb an ordinary compressed frame. + #[test] + fn ordinary_compressed_frame_is_accepted() { + use std::io::Write as _; + + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(b"").unwrap(); + let compressed = encoder.finish().unwrap(); + + let mut src = BytesMut::new(); + src.put_u32(compressed.len() as u32); + src.put_slice(&compressed); + + let frames = decode_compressed_frame(&mut src, &CompressionLimits::default()) + .expect("a well-formed frame within the caps must decode"); + + assert!(frames.is_some_and(|frames| frames.is_empty())); + } + + /// An incomplete frame whose declared length is legitimate must still be treated as "need more + /// bytes", not rejected — otherwise the caps would break normal streaming reads. + #[test] + fn incomplete_frame_within_the_cap_waits_for_more_bytes() { + let mut src = BytesMut::new(); + src.put_u32(64); + src.put_slice(b"only a few bytes so far"); + + let result = decode_compressed_frame(&mut src, &CompressionLimits::default()) + .expect("an incomplete but legitimate frame must not error"); + + assert!(result.is_none(), "expected the decoder to await more bytes"); + } + + fn push_req(req: &mut BytesMut, seq: u32, pairs: &[(&str, &str)]) { + req.put_slice(&encode_req(seq, pairs)); + } + + /// Wraps `inner` in a `'2' 'C'` compressed frame. + fn push_compressed(req: &mut BytesMut, inner: &[u8]) { + use std::io::Write as _; + + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(inner).unwrap(); + let compressed = encoder.finish().unwrap(); + + req.put_u8(b'2'); + req.put_u8(b'C'); + req.put_u32(compressed.len() as u32); + req.put_slice(&compressed); + } + + // A malformed frame must be a fatal (non-continuable) decode error: the + // Lumberjack stream can't be resynced, so the connection is closed rather + // than continuing with a desynced decoder (which would emit bogus ACKs). + // This matches upstream logstash-input-beats, which closes the channel on + // any decode exception. + + #[test] + fn malformed_json_frame_is_a_fatal_decode_error() { + let mut decoder = LogstashDecoder::new(CompressionLimits::default()); + let mut src = BytesMut::new(); + src.put_u8(b'2'); + src.put_u8(b'J'); + src.put_u32(1); // sequence number + let bad = b"{ not valid json "; + src.put_u32(bad.len() as u32); // payload size + src.put_slice(&bad[..]); + + let err = decoder.decode(&mut src).unwrap_err(); + assert!(matches!(err, DecodeError::JsonFrameFailedDecode { .. })); + assert!( + !err.can_continue(), + "a malformed JSON frame must be fatal so the connection closes", + ); + } + + #[test] + fn malformed_compressed_frame_is_a_fatal_decode_error() { + let mut decoder = LogstashDecoder::new(CompressionLimits::default()); + let mut src = BytesMut::new(); + src.put_u8(b'2'); + src.put_u8(b'C'); + let garbage = b"this is not a zlib stream"; + src.put_u32(garbage.len() as u32); // payload size + src.put_slice(&garbage[..]); + + let err = decoder.decode(&mut src).unwrap_err(); + assert!(matches!(err, DecodeError::DecompressionFailed { .. })); + assert!(!err.can_continue()); + } + + /// A compressed frame nested inside another must be refused rather than recursed into, so a + /// frame nested arbitrarily deep cannot exhaust the stack. + #[test] + fn nested_compressed_frame_is_a_fatal_decode_error() { + let mut inner = BytesMut::new(); + push_req(&mut inner, 1, &[("message", "should never be reached")]); + + let mut middle = BytesMut::new(); + push_compressed(&mut middle, &inner); + + let mut req = BytesMut::new(); + push_compressed(&mut req, &middle); + + let mut decoder = LogstashDecoder::new(CompressionLimits::default()); + let err = decoder.decode(&mut req).unwrap_err(); + assert!(matches!(err, DecodeError::NestedCompressedFrame)); + assert!(!err.can_continue()); + } + + /// The nesting guard must not disturb a single (non-nested) compressed frame carrying ordinary + /// data frames — the accept half of the pair above. + #[test] + fn singly_compressed_frame_is_still_accepted() { + let mut inner = BytesMut::new(); + push_req(&mut inner, 1, &[("message", "hello")]); + + let mut req = BytesMut::new(); + push_compressed(&mut req, &inner); + + let mut decoder = LogstashDecoder::new(CompressionLimits::default()); + let frame = decoder + .decode(&mut req) + .expect("a singly-compressed frame must decode") + .expect("expected one decoded frame"); + + assert_eq!( + frame.0.fields.get("message"), + Some(&serde_json::Value::from("hello")), + ); + } + + #[tokio::test] + async fn malformed_frame_closes_connection_without_ack() { + let (address, _recv) = start_logstash(EventStatus::Delivered).await; + + let mut socket = tokio::net::TcpStream::connect(address).await.unwrap(); + + // A '2' 'J' frame whose payload is not valid JSON. + let mut req = BytesMut::new(); + req.put_u8(b'2'); + req.put_u8(b'J'); + req.put_u32(1); // sequence number + let bad = b"{ not valid json "; + req.put_u32(bad.len() as u32); // payload size + req.put_slice(&bad[..]); + socket.write_all(&req).await.unwrap(); + + // The source must close the connection on the decode error and send no + // ACK; the client will reconnect and retransmit. + let mut output = BytesMut::new(); + let result = socket.read_buf(&mut output).await; + assert!( + matches!(result, Ok(0)) || result.is_err(), + "expected the connection to close; read returned {result:?} with {output:?}", + ); + assert!( + output.is_empty(), + "no ACK should be sent for a malformed frame, got {output:?}", + ); + } } #[cfg(all(test, feature = "logstash-integration-tests"))] @@ -1221,7 +1262,6 @@ mod integration_tests { acknowledgements: false.into(), connection_limit: None, log_namespace: None, - max_decompressed_bytes: default_max_decompressed_bytes(), } .build(SourceContext::new_test(sender, None)) .await @@ -1232,5 +1272,4 @@ mod integration_tests { wait_for_tcp(address).await; recv } - } diff --git a/src/sources/opentelemetry/http.rs b/src/sources/opentelemetry/http.rs index d658bdc50b..40862735cc 100644 --- a/src/sources/opentelemetry/http.rs +++ b/src/sources/opentelemetry/http.rs @@ -30,9 +30,11 @@ use warp::{ use vector_lib::ipallowlist::IpAllowlistConfig; +use crate::sources::util::decompression::CompressionLimits; use crate::http::{KeepaliveConfig, MaxConnectionAgeLayer}; use crate::sources::http_server::HttpConfigParamKind; use crate::sources::util::add_headers; +use crate::sources::util::http::capped_body; use crate::sources::util::handle_accept_error; use crate::{ event::Event, @@ -101,6 +103,7 @@ pub(crate) fn build_warp_filter( bytes_received: Registered, events_received: Registered, headers: Vec, + compression_limits: CompressionLimits, ) -> BoxedFilter<(Response,)> { let log_filters = build_warp_log_filter( acknowledgements, @@ -109,18 +112,21 @@ pub(crate) fn build_warp_filter( bytes_received.clone(), events_received.clone(), headers.clone(), + compression_limits, ); let metrics_filters = build_warp_metrics_filter( acknowledgements, out.clone(), bytes_received.clone(), events_received.clone(), + compression_limits, ); let trace_filters = build_warp_trace_filter( acknowledgements, out.clone(), bytes_received, events_received, + compression_limits, ); log_filters .or(trace_filters) @@ -152,6 +158,7 @@ fn build_warp_log_filter( bytes_received: Registered, events_received: Registered, headers: Vec, + compression_limits: CompressionLimits, ) -> BoxedFilter<(Response,)> { warp::post() .and(warp::path!("v1" / "logs")) @@ -161,10 +168,10 @@ fn build_warp_log_filter( )) .and(warp::header::optional::("content-encoding")) .and(warp::header::headers_cloned()) - .and(warp::body::bytes()) + .and(capped_body(&compression_limits)) .and_then( move |encoding_header: Option, headers_config: HeaderMap, body: Bytes| { - let events = decode(encoding_header.as_deref(), body) + let events = decode(encoding_header.as_deref(), body, &compression_limits) .and_then(|body| { bytes_received.emit(ByteSize(body.len())); decode_log_body(body, log_namespace, &events_received) @@ -191,6 +198,7 @@ fn build_warp_metrics_filter( out: SourceSender, bytes_received: Registered, events_received: Registered, + compression_limits: CompressionLimits, ) -> BoxedFilter<(Response,)> { warp::post() .and(warp::path!("v1" / "metrics")) @@ -199,9 +207,9 @@ fn build_warp_metrics_filter( "application/x-protobuf", )) .and(warp::header::optional::("content-encoding")) - .and(warp::body::bytes()) + .and(capped_body(&compression_limits)) .and_then(move |encoding_header: Option, body: Bytes| { - let events = decode(encoding_header.as_deref(), body).and_then(|body| { + let events = decode(encoding_header.as_deref(), body, &compression_limits).and_then(|body| { bytes_received.emit(ByteSize(body.len())); decode_metrics_body(body, &events_received) }); @@ -222,6 +230,7 @@ fn build_warp_trace_filter( out: SourceSender, bytes_received: Registered, events_received: Registered, + compression_limits: CompressionLimits, ) -> BoxedFilter<(Response,)> { warp::post() .and(warp::path!("v1" / "traces")) @@ -230,9 +239,9 @@ fn build_warp_trace_filter( "application/x-protobuf", )) .and(warp::header::optional::("content-encoding")) - .and(warp::body::bytes()) + .and(capped_body(&compression_limits)) .and_then(move |encoding_header: Option, body: Bytes| { - let events = decode(encoding_header.as_deref(), body).and_then(|body| { + let events = decode(encoding_header.as_deref(), body, &compression_limits).and_then(|body| { bytes_received.emit(ByteSize(body.len())); decode_trace_body(body, &events_received) }); diff --git a/src/sources/opentelemetry/mod.rs b/src/sources/opentelemetry/mod.rs index ff750040a8..87836e54bd 100644 --- a/src/sources/opentelemetry/mod.rs +++ b/src/sources/opentelemetry/mod.rs @@ -44,7 +44,10 @@ use crate::{ }, http::KeepaliveConfig, serde::bool_or_struct, - sources::{util::grpc::run_grpc_server_with_routes, Source}, + sources::{ + util::{decompression::CompressionLimits, grpc::run_grpc_server_with_routes}, + Source, + }, tls::{MaybeTlsSettings, TlsEnableableConfig}, }; @@ -163,6 +166,9 @@ impl GenerateConfig for OpentelemetryConfig { #[typetag::serde(name = "opentelemetry")] impl SourceConfig for OpentelemetryConfig { async fn build(&self, cx: SourceContext) -> crate::Result { + // Taken from this component's context rather than process state, so the deployment + // controls the cap. `CompressionLimits` is `Copy`, so it can be captured freely below. + let compression_limits: CompressionLimits = cx.globals.limits.compression; let grpc_config_exists = self.grpc.is_some(); let http_config_exists = self.http.is_some(); @@ -182,7 +188,7 @@ impl SourceConfig for OpentelemetryConfig { events_received: events_received.clone(), }) .accept_compressed(CompressionEncoding::Gzip) - .max_decoding_message_size(usize::MAX); + .max_decoding_message_size(compression_limits.max_decompressed_size_bytes); let trace_service = TraceServiceServer::new(Service { pipeline: cx.out.clone(), @@ -191,7 +197,7 @@ impl SourceConfig for OpentelemetryConfig { events_received: events_received.clone(), }) .accept_compressed(CompressionEncoding::Gzip) - .max_decoding_message_size(usize::MAX); + .max_decoding_message_size(compression_limits.max_decompressed_size_bytes); let metrics_service = MetricsServiceServer::new(Service { pipeline: cx.out.clone(), @@ -200,7 +206,7 @@ impl SourceConfig for OpentelemetryConfig { events_received: events_received.clone(), }) .accept_compressed(CompressionEncoding::Gzip) - .max_decoding_message_size(usize::MAX); + .max_decoding_message_size(compression_limits.max_decompressed_size_bytes); let mut builder = RoutesBuilder::default(); builder @@ -217,6 +223,7 @@ impl SourceConfig for OpentelemetryConfig { builder.routes(), cx.shutdown.clone(), self.permit_origin.clone(), + compression_limits, ) .map_err(|error| { error!(message = "Source future failed.", %error); @@ -238,6 +245,7 @@ impl SourceConfig for OpentelemetryConfig { bytes_received, events_received, headers, + compression_limits, ); Some(run_http_server( http_config.address, diff --git a/src/sources/prometheus/remote_write.rs b/src/sources/prometheus/remote_write.rs index 4ba6b14498..66dca2fa55 100644 --- a/src/sources/prometheus/remote_write.rs +++ b/src/sources/prometheus/remote_write.rs @@ -2,6 +2,7 @@ use std::{collections::HashMap, net::SocketAddr}; use bytes::Bytes; use prost::Message; +use crate::sources::util::decompression::CompressionLimits; use vector_lib::config::LogNamespace; use vector_lib::configurable::configurable_component; use vector_lib::ipallowlist::IpAllowlistConfig; @@ -149,9 +150,14 @@ impl RemoteWriteSource { } impl HttpSource for RemoteWriteSource { - fn decode(&self, encoding_header: Option<&str>, body: Bytes) -> Result { + fn decode( + &self, + encoding_header: Option<&str>, + body: Bytes, + limits: &CompressionLimits, + ) -> Result { // Default to snappy decoding the request body. - decode(encoding_header.or(Some("snappy")), body) + decode(encoding_header.or(Some("snappy")), body, limits) } fn build_events( diff --git a/src/sources/splunk_hec/mod.rs b/src/sources/splunk_hec/mod.rs index 1d6d2b6dff..dd4c0e23b9 100644 --- a/src/sources/splunk_hec/mod.rs +++ b/src/sources/splunk_hec/mod.rs @@ -1,7 +1,6 @@ use std::{ collections::{BTreeSet, HashMap}, convert::Infallible, - io::Read, net::{Ipv4Addr, SocketAddr}, sync::Arc, time::Duration, @@ -9,7 +8,6 @@ use std::{ use bytes::{Buf, Bytes}; use chrono::{DateTime, TimeZone, Utc}; -use flate2::read::MultiGzDecoder; use futures::{FutureExt, StreamExt}; use http::StatusCode; use hyper::{service::make_service_fn, Server}; @@ -54,8 +52,10 @@ use crate::{ EventsReceived, HttpBytesReceived, SplunkHecRequestBodyInvalidError, SplunkHecRequestError, }, serde::bool_or_struct, - sources::util::handle_accept_error, source_sender::ClosedError, + sources::util::{ + decompression::{CappedDecoder, CompressionLimits}, handle_accept_error, http::capped_body, http::ErrorMessage, + }, tls::{MaybeTlsSettings, TlsEnableableConfig}, SourceSender, }; @@ -299,6 +299,8 @@ impl SourceConfig for SplunkConfig { /// Shared data for responding to requests. struct SplunkSource { + /// Limits to decompress under, from this component's context. + compression_limits: CompressionLimits, valid_tokens: Arc>, protocol: &'static str, idx_ack: Option>, @@ -310,6 +312,7 @@ struct SplunkSource { impl SplunkSource { fn new(config: &SplunkConfig, protocol: &'static str, cx: SourceContext) -> Self { let log_namespace = cx.log_namespace(config.log_namespace); + let compression_limits = cx.globals.limits.compression; let acknowledgements = cx.do_acknowledgements(config.acknowledgements.enabled.into()); let shutdown = cx.shutdown; let valid_tokens: BTreeSet = config @@ -328,6 +331,7 @@ impl SplunkSource { }); SplunkSource { + compression_limits, valid_tokens: Arc::new(valid_tokens), protocol, idx_ack, @@ -343,6 +347,7 @@ impl SplunkSource { let store_hec_token = self.store_hec_token; let log_namespace = self.log_namespace; let events_received = self.events_received.clone(); + let compression_limits = self.compression_limits; warp::post() .and( @@ -355,7 +360,7 @@ impl SplunkSource { .and(warp::addr::remote()) .and(warp::header::optional::("X-Forwarded-For")) .and(self.gzip()) - .and(warp::body::bytes()) + .and(capped_body(&self.compression_limits)) .and(warp::path::full()) .and_then( move |_, @@ -375,10 +380,10 @@ impl SplunkSource { return Err(Rejection::from(ApiError::MissingChannel)); } - let mut data = Vec::new(); + let data; let (byte_size, body) = if gzip { - MultiGzDecoder::new(body.reader()) - .read_to_end(&mut data) + data = CappedDecoder::gzip(body.reader(), &compression_limits) + .decompress() .map_err(|_| Rejection::from(ApiError::BadRequest))?; (data.len(), String::from_utf8_lossy(data.as_slice())) } else { @@ -451,6 +456,7 @@ impl SplunkSource { let store_hec_token = self.store_hec_token; let events_received = self.events_received.clone(); let log_namespace = self.log_namespace; + let compression_limits = self.compression_limits; warp::post() .and(path!("raw" / "1.0").or(path!("raw"))) @@ -459,7 +465,7 @@ impl SplunkSource { .and(warp::addr::remote()) .and(warp::header::optional::("X-Forwarded-For")) .and(self.gzip()) - .and(warp::body::bytes()) + .and(capped_body(&self.compression_limits)) .and(warp::path::full()) .and_then( move |_, @@ -498,6 +504,7 @@ impl SplunkSource { batch, log_namespace, &events_received, + &compression_limits, )?; if let Some(token) = token.filter(|_| store_hec_token) { event.metadata_mut().set_splunk_hec_token(token.into()); @@ -537,10 +544,15 @@ impl SplunkSource { .and(path!("ack")) .and(self.authorization()) .and(SplunkSource::required_channel()) - .and(warp::body::json()) - .and_then(move |_, channel_id: String, body: HecAckStatusRequest| { + // `warp::body::json()` aggregates the whole body unbounded; cap it first and parse the + // bytes ourselves. Token auth is optional in config, so this endpoint can be reached + // unauthenticated. + .and(capped_body(&self.compression_limits)) + .and_then(move |_, channel_id: String, body: Bytes| { let idx_ack = idx_ack.clone(); async move { + let body: HecAckStatusRequest = serde_json::from_slice(&body) + .map_err(|_| Rejection::from(ApiError::BadRequest))?; if let Some(idx_ack) = idx_ack { let ack_statuses = idx_ack .get_acks_status_from_channel(channel_id, &body.acks) @@ -1042,13 +1054,13 @@ fn raw_event( batch: Option, log_namespace: LogNamespace, events_received: &Registered, + compression_limits: &CompressionLimits, ) -> Result { // Process gzip let message: Value = if gzip { - let mut data = Vec::new(); - match MultiGzDecoder::new(bytes.reader()).read_to_end(&mut data) { - Ok(0) => return Err(ApiError::NoData.into()), - Ok(_) => Value::from(Bytes::from(data)), + match CappedDecoder::gzip(bytes.reader(), compression_limits).decompress() { + Ok(data) if data.is_empty() => return Err(ApiError::NoData.into()), + Ok(data) => Value::from(Bytes::from(data)), Err(error) => { emit!(SplunkHecRequestBodyInvalidError { error }); return Err(ApiError::InvalidDataFormat { event: 0 }.into()); @@ -1249,6 +1261,11 @@ async fn finish_err(rejection: Rejection) -> Result<(Response,), Rejection> { response_json(StatusCode::BAD_REQUEST, splunk_response::ACK_IS_DISABLED) } },)) + } else if let Some(error) = rejection.find::() { + // `capped_body()` rejects an oversized request body with an `ErrorMessage` carrying a + // 413. Without this arm warp would fall through to a generic 500, which would misreport + // a client error as a server fault. + Ok((empty_response(error.status_code()),)) } else { Err(rejection) } @@ -2914,5 +2931,191 @@ mod tests { ); } + /// OBE-11554: both HEC handlers inflated a client-supplied gzip body with an unbounded + /// `read_to_end`. Amplification was measured at 1029:1 on a listener that accepts + /// unauthenticated requests by default, so ~4 MiB of upload exceeded a 4Gi pod limit. + mod gzip_bomb { + use super::*; + + /// One cheap gzip member repeated past the cap. `MultiGzDecoder` walks every concatenated + /// member, so a single member's size does not bound the attack. + fn gzip_bomb() -> Vec { + use std::io::Write as _; + + use vector_common::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&vec![0u8; 1024 * 1024]).unwrap(); + let member = encoder.finish().unwrap(); + + let mut bomb = Vec::new(); + for _ in 0..(DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES / (1024 * 1024) + 1) { + bomb.extend_from_slice(&member); + } + assert!( + bomb.len() < 1024 * 1024, + "the bomb must stay small on the wire to be a meaningful test, got {} bytes", + bomb.len() + ); + bomb + } + + fn gzip(payload: &[u8]) -> Vec { + use std::io::Write as _; + + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(payload).unwrap(); + encoder.finish().unwrap() + } + + async fn post_gzip(address: SocketAddr, api: &str, body: Vec) -> Response { + reqwest::Client::new() + .post(format!("http://{}/{}", address, api)) + .header("Authorization", format!("Splunk {}", TOKEN)) + .header("Content-Encoding", "gzip") + .header("x-splunk-request-channel", "channel") + .body(body) + .send() + .await + .unwrap() + } + + #[tokio::test] + async fn event_endpoint_rejects_gzip_bomb() { + let (_source, address) = source(None).await; + + let response = post_gzip(address, "services/collector/event", gzip_bomb()).await; + + assert_eq!(400, response.status().as_u16()); + // Both the cap trip and a merely unparseable body answer 400, so the status alone + // would pass even with the cap removed. They differ in the body: `ApiError::BadRequest` + // (the cap trip) is empty, while `InvalidDataFormat` carries a JSON document. + assert!( + response.bytes().await.unwrap().is_empty(), + "expected the empty-bodied BadRequest raised by the cap, not a parse failure" + ); + } + + #[tokio::test] + async fn raw_endpoint_rejects_gzip_bomb() { + let (_source, address) = source(None).await; + + let response = post_gzip(address, "services/collector/raw", gzip_bomb()).await; + + assert_eq!(400, response.status().as_u16()); + } + + /// The compressed body itself is now bounded, not just the decompressed output. + /// + /// Sends a handcrafted request declaring an enormous `Content-Length` with no body, so + /// `capped_body()`'s declared-length guard fires before a single body byte is read. This + /// keeps the test free of a real multi-gigabyte upload while still exercising the filter + /// and the `ErrorMessage` arm of `finish_err`. + #[tokio::test] + async fn oversized_declared_body_is_rejected_with_413() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (_source, address) = source(None).await; + + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + let request = format!( + "POST /services/collector/raw HTTP/1.1\r\n\ + Host: {address}\r\n\ + Authorization: Splunk {TOKEN}\r\n\ + x-splunk-request-channel: channel\r\n\ + Content-Length: 999999999999\r\n\ + \r\n" + ); + stream.write_all(request.as_bytes()).await.unwrap(); + stream.flush().await.unwrap(); + + // Without the declared-length guard the server waits for the body that never comes, + // so bound the read: a regression must fail here rather than hang the suite. + let mut response = vec![0u8; 128]; + let n = tokio::time::timeout( + tokio::time::Duration::from_secs(10), + stream.read(&mut response), + ) + .await + .expect("server must answer without waiting for the declared body") + .unwrap(); + let status_line = String::from_utf8_lossy(&response[..n]); + + assert!( + status_line.starts_with("HTTP/1.1 413"), + "expected 413 Payload Too Large, got: {status_line}" + ); + } + + /// The cap must not disturb ordinary gzip traffic on either handler. + #[tokio::test] + async fn ordinary_gzip_body_is_accepted() { + let (_source, address) = source(None).await; + + let event = post_gzip( + address, + "services/collector/event", + gzip(br#"{"event":"hello"}"#), + ) + .await; + assert_eq!(200, event.status().as_u16()); + + let raw = post_gzip(address, "services/collector/raw", gzip(b"hello")).await; + assert_eq!(200, raw.status().as_u16()); + } + } + + /// `capped_body()` replaced `warp::body::bytes()` on both HEC handlers. It collects the body + /// by streaming chunks rather than letting hyper buffer it in one shot, so it is worth pinning + /// that this does not add per-request latency. + /// + /// Measured at 0-3 ms when this was written; the 100 ms bound is deliberately loose so the + /// test catches a systematic regression (a stall waiting on end-of-stream would cost hundreds + /// of ms) without tripping on CI scheduling noise. The median is used so one stalled sample + /// cannot fail the run. + #[cfg(feature = "performance-tests")] + #[tokio::test] + async fn capped_body_does_not_add_request_latency() { + const SAMPLES: usize = 9; + const MAX_MEDIAN: Duration = Duration::from_millis(100); + + let (_source, address) = source(None).await; + let client = reqwest::Client::new(); + + let post = |client: reqwest::Client, address: SocketAddr| async move { + client + .post(format!("http://{}/services/collector/event", address)) + .header("Authorization", format!("Splunk {}", TOKEN)) + .header("x-splunk-request-channel", "channel") + .body(r#"{"event":"hello"}"#) + .send() + .await + .unwrap() + }; + + // Warm the connection pool so we measure the filter, not TCP setup. + assert_eq!(200, post(client.clone(), address).await.status().as_u16()); + + let mut samples = Vec::with_capacity(SAMPLES); + for _ in 0..SAMPLES { + let started = std::time::Instant::now(); + let response = post(client.clone(), address).await; + let elapsed = started.elapsed(); + assert_eq!(200, response.status().as_u16()); + samples.push(elapsed); + } + + samples.sort_unstable(); + let median = samples[SAMPLES / 2]; + + assert!( + median < MAX_MEDIAN, + "capped_body() added per-request latency: median {median:?} over {SAMPLES} requests \ + exceeds {MAX_MEDIAN:?} (samples: {samples:?})" + ); + } + register_validatable_component!(SplunkConfig); } diff --git a/src/sources/util/decompression.rs b/src/sources/util/decompression.rs new file mode 100644 index 0000000000..21b3844368 --- /dev/null +++ b/src/sources/util/decompression.rs @@ -0,0 +1,9 @@ +//! Re-export of the shared decompression limits. +//! +//! The implementation lives in [`vector_common::decompression`] so that both this crate and +//! `lib/codecs` enforce the same limits. Components take a [`CompressionLimits`] from their own +//! context (`cx.globals.limits.compression`) rather than reading process state. +pub use vector_common::decompression::{ + CappedDecoder, CappedReader, CompressionLimits, DecompressedSizeLimitExceeded, + OperationalLimits, DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES, HTTP_ZSTD_WINDOW_LOG_MAX, +}; diff --git a/src/sources/util/grpc/decompression.rs b/src/sources/util/grpc/decompression.rs index 16293df4b0..87732333e6 100644 --- a/src/sources/util/grpc/decompression.rs +++ b/src/sources/util/grpc/decompression.rs @@ -1,6 +1,6 @@ use std::{ cmp, - io::Write, + io::{self, Write}, mem, pin::Pin, task::{Context, Poll}, @@ -23,11 +23,25 @@ use vector_lib::internal_event::{ }; use crate::internal_events::{GrpcError, GrpcInvalidCompressionSchemeError}; +use crate::sources::util::decompression::{ + CompressionLimits, + DecompressedSizeLimitExceeded, +}; // Every gRPC message has a five byte header: // - a compressed flag (u8, 0/1 for compressed/decompressed) // - a length prefix, indicating the number of remaining bytes to read (u32) const GRPC_MESSAGE_HEADER_LEN: usize = mem::size_of::() + mem::size_of::(); +// Fixed container framing a valid frame adds on top of zlib's worst-case expansion. Added to the +// compressed-frame pre-filter so a small cap does not reject a typical gzip frame whose +// decompressed size is within the cap. +// +// gzip's mandatory framing is 18 bytes (10 header + 8 trailer), but its optional FNAME, FCOMMENT +// and FEXTRA fields are unbounded (RFC 1952 section 2.3.1): a gzip frame carrying more than this +// slack in those optional fields could still be rejected here. Encoders don't emit them in +// practice, so 22 covers the realistic case; the prefilter is only a cheap wire-size guard and the +// authoritative per-output cap is still enforced during decompression. +const GRPC_COMPRESSED_FRAME_OVERHEAD_SLACK: usize = 22; const GRPC_ENCODING_HEADER: &str = "grpc-encoding"; const GRPC_ACCEPT_ENCODING_HEADER: &str = "grpc-accept-encoding"; @@ -80,17 +94,67 @@ impl Default for State { } } -fn new_decompressor() -> GzDecoder> { +/// Maps a decompressor `io::Error` to a gRPC [`Status`]: an oversized payload becomes +/// `out_of_range` (a client fault, matching the existing >4GB handling) while anything else falls +/// back to `internal` with `internal_msg`. +fn decompressor_error_to_status(error: &io::Error, internal_msg: &'static str) -> Status { + if DecompressedSizeLimitExceeded::is(error) { + Status::out_of_range("decompressed message exceeds the maximum allowed size") + } else { + Status::internal(internal_msg) + } +} + +/// A `Write` sink that appends into a `Vec` but refuses to grow past `max_len`, so a streaming +/// decompressor errors out *during* decompression rather than first materializing an oversized +/// output and only then having its size checked. +struct LimitedWriter { + buf: Vec, + max_len: usize, +} + +impl LimitedWriter { + const fn new(buf: Vec, max_len: usize) -> Self { + Self { buf, max_len } + } + + fn into_inner(self) -> Vec { + self.buf + } +} + +impl Write for LimitedWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + if self.buf.len().saturating_add(data.len()) > self.max_len { + return Err(io::Error::other(DecompressedSizeLimitExceeded)); + } + self.buf.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn new_decompressor(limits: &CompressionLimits) -> GzDecoder { // Create the backing buffer for the decompressor and set the compression flag to false (0) and pre-allocate // the space for the length prefix, which we'll fill out once we've finalized the decompressor. let buf = vec![0; GRPC_MESSAGE_HEADER_LEN]; - GzDecoder::new(buf) + // Cap the decompressed output so a compression bomb on this unauthenticated gRPC listener + // cannot drive unbounded allocation. The buffer already holds the 5-byte header, so the sink + // may grow to the header plus the decompressed cap; anything larger errors mid-decompression. + GzDecoder::new(LimitedWriter::new( + buf, + GRPC_MESSAGE_HEADER_LEN.saturating_add(limits.max_decompressed_size_bytes), + )) } async fn drive_body_decompression( mut source: Body, mut destination: Sender, + limits: &CompressionLimits, ) -> Result { let mut state = State::default(); let mut buf = BytesMut::new(); @@ -133,6 +197,19 @@ async fn drive_body_decompression( // decompressor incrementally because there's no good reason to make both the internal buffer and // the decompressor buffer expand if we don't have to. if is_compressed { + // Reject a compressed payload whose declared wire size could not + // legitimately decompress within the cap, before we buffer any of it. The + // bound (decompressed cap plus zlib's worst-case expansion, shared with the + // logstash source) keeps a peer from advertising a huge length and + // slow-streaming bytes to grow the decompressor's input buffer unbounded. + let compressed_frame_limit = limits.max_zlib_compressed_frame_size_bytes() + .saturating_add(GRPC_COMPRESSED_FRAME_OVERHEAD_SLACK); + if message_len > compressed_frame_limit { + return Err(Status::out_of_range( + "compressed message length exceeds the maximum allowed size", + )); + } + // We skip the header in the buffer because it doesn't matter to the decompressor and we // recreate it anyways. buf.advance(GRPC_MESSAGE_HEADER_LEN); @@ -141,6 +218,15 @@ async fn drive_body_decompression( remaining: message_len, }; } else { + // Reject an identity (uncompressed) message larger than the cap before + // buffering it to `overall_len`, so a large declared length cannot drive + // unbounded buffering here ahead of tonic's own decode-size limit. + if message_len > limits.max_decompressed_size_bytes { + return Err(Status::out_of_range( + "message length exceeds the maximum allowed size", + )); + } + let overall_len = GRPC_MESSAGE_HEADER_LEN + message_len; state = State::Forward { overall_len }; } @@ -172,9 +258,13 @@ async fn drive_body_decompression( // the decompressor. This is _technically_ synchronous but there's really no way to do it // asynchronously since we already have the data, and that's the only asynchronous part. let to_take = cmp::min(available, *remaining); - let decompressor = decompressor.get_or_insert_with(new_decompressor); - if decompressor.write_all(&buf[..to_take]).is_err() { - return Err(Status::internal("failed to write to decompressor")); + let decompressor = + decompressor.get_or_insert_with(|| new_decompressor(limits)); + if let Err(error) = decompressor.write_all(&buf[..to_take]) { + return Err(decompressor_error_to_status( + &error, + "failed to write to decompressor", + )); } *remaining -= to_take; @@ -188,13 +278,16 @@ async fn drive_body_decompression( let result = decompressor .take() .expect("consumed decompressor when no decompressor was present") - .finish(); - - // The only I/O errors that occur during `finish` should be I/O errors from writing to the internal - // buffer, but `Vec` is infallible in this regard, so this should be impossible without having - // first panicked due to memory exhaustion. - let mut buf = result.map_err(|_| { - Status::internal( + .finish() + .map(LimitedWriter::into_inner); + + // Decompression can fail here either because the payload exceeded the size + // cap (an oversized-request client fault) or, for malformed input, during + // finalization; map the former to `out_of_range` and treat anything else as + // an internal error. + let mut buf = result.map_err(|error| { + decompressor_error_to_status( + &error, "reached impossible error during decompressor finalization", ) })?; @@ -245,12 +338,13 @@ async fn drive_request( destination: Sender, inner: F, bytes_received: Registered, + compression_limits: CompressionLimits, ) -> Result, E> where F: Future, E>>, E: std::fmt::Display, { - let body_decompression = drive_body_decompression(source, destination); + let body_decompression = drive_body_decompression(source, destination, &compression_limits); pin!(inner); pin!(body_decompression); @@ -300,6 +394,7 @@ where pub struct DecompressionAndMetrics { inner: S, bytes_received: Registered, + compression_limits: CompressionLimits, } impl Service> for DecompressionAndMetrics @@ -336,7 +431,14 @@ where let inner = self.inner.call(mapped_req); - drive_request(req_body, destination, inner, self.bytes_received.clone()).boxed() + drive_request( + req_body, + destination, + inner, + self.bytes_received.clone(), + self.compression_limits, + ) + .boxed() } } } @@ -362,8 +464,18 @@ where /// received _and_ processed correctly. /// /// The only supported compression scheme is gzip, which is also the only supported compression scheme in `tonic` itself. -#[derive(Clone, Default)] -pub struct DecompressionAndMetricsLayer; +#[derive(Clone, Copy, Default)] +pub struct DecompressionAndMetricsLayer { + compression_limits: CompressionLimits, +} + +impl DecompressionAndMetricsLayer { + /// Builds the layer with the limits this listener should decompress under. + #[must_use] + pub const fn new(compression_limits: CompressionLimits) -> Self { + Self { compression_limits } + } +} impl Layer for DecompressionAndMetricsLayer { type Service = DecompressionAndMetrics; @@ -372,6 +484,151 @@ impl Layer for DecompressionAndMetricsLayer { DecompressionAndMetrics { inner, bytes_received: register!(BytesReceived::from(Protocol::from("grpc"))), + compression_limits: self.compression_limits, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sources::util::decompression::DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES; + + fn gzip(payload: &[u8]) -> Vec { + use flate2::write::GzEncoder; + let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(payload).unwrap(); + encoder.finish().unwrap() + } + + #[test] + fn limited_writer_accepts_within_limit() { + let mut writer = LimitedWriter::new(Vec::new(), 8); + writer + .write_all(b"12345678") + .expect("exactly at the limit must be accepted"); + assert_eq!(writer.into_inner(), b"12345678"); + } + + #[test] + fn limited_writer_rejects_past_limit() { + let mut writer = LimitedWriter::new(Vec::new(), 8); + let error = writer + .write_all(b"123456789") + .expect_err("one byte past the limit must be rejected"); + + assert!( + DecompressedSizeLimitExceeded::is(&error), + "expected the size-limit marker, got {error}" + ); + } + + /// The cap must fire *during* decompression rather than after materialising the whole output, + /// which is the entire point of the `LimitedWriter` sink. + #[test] + fn gzip_decompressor_rejects_bomb_mid_stream() { + let bomb = gzip(&vec![0u8; 1024 * 1024]); + let mut decoder = GzDecoder::new(LimitedWriter::new(Vec::new(), 4096)); + + let error = decoder + .write_all(&bomb) + .expect_err("a payload inflating past the cap must be rejected"); + + assert!(DecompressedSizeLimitExceeded::is(&error)); + } + + #[test] + fn gzip_decompressor_passes_ordinary_payload() { + let payload = b"hello grpc"; + let mut decoder = GzDecoder::new(LimitedWriter::new(Vec::new(), 4096)); + decoder.write_all(&gzip(payload)).expect("must decompress"); + let out = decoder.finish().map(LimitedWriter::into_inner).unwrap(); + + assert_eq!(out, payload); + } + + /// An oversized payload is a client fault, so it must surface as `out_of_range` rather than + /// being reported as an internal server error. + #[test] + fn size_limit_maps_to_out_of_range_other_errors_to_internal() { + let limit_error = io::Error::other(DecompressedSizeLimitExceeded); + assert_eq!( + decompressor_error_to_status(&limit_error, "internal").code(), + tonic::Code::OutOfRange + ); + + let other = io::Error::other("some unrelated failure"); + assert_eq!( + decompressor_error_to_status(&other, "internal").code(), + tonic::Code::Internal + ); + } + + fn grpc_frame(compressed: bool, declared_len: u32) -> Body { + let mut frame = vec![u8::from(compressed)]; + frame.extend_from_slice(&declared_len.to_be_bytes()); + Body::from(frame) + } + + async fn drive(frame: Body) -> Result { + let (sender, _receiver) = Body::channel(); + drive_body_decompression(frame, sender, &CompressionLimits::default()).await + } + + /// A compressed frame declaring more bytes than could legitimately decompress within the cap + /// must be refused from its header alone, before any of the payload is buffered. + #[tokio::test] + async fn oversized_compressed_frame_length_is_rejected_from_the_header() { + let declared = u32::MAX; + assert!( + declared as usize + > CompressionLimits::default().max_zlib_compressed_frame_size_bytes() + .saturating_add(GRPC_COMPRESSED_FRAME_OVERHEAD_SLACK), + "the declared length must exceed the prefilter for this test to mean anything" + ); + + let status = drive(grpc_frame(true, declared)) + .await + .expect_err("an oversized declared length must be rejected"); + + assert_eq!(status.code(), tonic::Code::OutOfRange); + } + + /// The same guard is needed on the identity path: an uncompressed message declaring a huge + /// length would otherwise be buffered to `overall_len` before tonic's own limit applied. + #[tokio::test] + async fn oversized_identity_frame_length_is_rejected_from_the_header() { + let declared = u32::MAX; + assert!(declared as usize > CompressionLimits::default().max_decompressed_size_bytes); + + let status = drive(grpc_frame(false, declared)) + .await + .expect_err("an oversized identity length must be rejected"); + + assert_eq!(status.code(), tonic::Code::OutOfRange); + } + + /// A legitimate declared length must not be refused by either guard — the frame simply waits + /// for its payload. + #[tokio::test] + async fn ordinary_frame_length_is_accepted() { + for compressed in [true, false] { + let result = drive(grpc_frame(compressed, 64)).await; + assert!( + result.is_ok(), + "a small declared length must pass the guards (compressed={compressed}), got {:?}", + result.err() + ); } } + + /// The new decompressor must carry the global cap, not an unbounded sink. + #[test] + fn new_decompressor_is_capped_at_the_global_limit() { + let decoder = new_decompressor(&CompressionLimits::default()); + assert_eq!( + decoder.get_ref().max_len, + GRPC_MESSAGE_HEADER_LEN + DEFAULT_MAX_DECOMPRESSED_SIZE_BYTES + ); + } } diff --git a/src/sources/util/grpc/mod.rs b/src/sources/util/grpc/mod.rs index a378efdd82..184a469a24 100644 --- a/src/sources/util/grpc/mod.rs +++ b/src/sources/util/grpc/mod.rs @@ -19,6 +19,7 @@ use tower_http::{ use tracing::Span; mod decompression; +use crate::sources::util::decompression::CompressionLimits; pub use self::decompression::{DecompressionAndMetrics, DecompressionAndMetricsLayer}; pub async fn run_grpc_server( @@ -26,6 +27,7 @@ pub async fn run_grpc_server( tls_settings: MaybeTlsSettings, service: S, shutdown: ShutdownSignal, + compression_limits: CompressionLimits, ) -> crate::Result<()> where S: Service, Response = Response, Error = Infallible> @@ -53,7 +55,7 @@ where // use independent `tower` layers when the request body itself (the body type, not the actual bytes) must be // modified or wrapped.. so instead of a cleaner design, we're opting here to bake it all together until the // crates are sufficiently flexible for us to craft a better design. - .layer(DecompressionAndMetricsLayer) + .layer(DecompressionAndMetricsLayer::new(compression_limits)) .add_service(service) .serve_with_incoming_shutdown(stream, shutdown.map(|token| tx.send(token).unwrap())) .await?; @@ -71,6 +73,7 @@ pub async fn run_grpc_server_with_routes( routes: Routes, shutdown: ShutdownSignal, permit_origin: Option, + compression_limits: CompressionLimits, ) -> crate::Result<()> { let span = Span::current(); let (tx, rx) = tokio::sync::oneshot::channel::(); @@ -83,7 +86,7 @@ pub async fn run_grpc_server_with_routes( Server::builder() .layer(build_grpc_trace_layer(span.clone())) - .layer(DecompressionAndMetricsLayer) + .layer(DecompressionAndMetricsLayer::new(compression_limits)) .add_routes(routes) .serve_with_incoming_shutdown(stream, shutdown.map(|token| tx.send(token).unwrap())) .await?; diff --git a/src/sources/util/http/encoding.rs b/src/sources/util/http/encoding.rs index 39051f67ac..0e5b4ee49e 100644 --- a/src/sources/util/http/encoding.rs +++ b/src/sources/util/http/encoding.rs @@ -1,39 +1,84 @@ -use std::io::Read; - -use bytes::{Buf, Bytes}; -use flate2::read::{MultiGzDecoder, ZlibDecoder}; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use futures_util::StreamExt; use snap::raw::Decoder as SnappyDecoder; use warp::http::StatusCode; +use warp::{filters::BoxedFilter, Filter}; use super::error::ErrorMessage; use crate::internal_events::HttpDecompressError; +use crate::sources::util::decompression::{ + CappedDecoder, CompressionLimits, DecompressedSizeLimitExceeded, +}; + +/// Collects a request body into [`Bytes`] while enforcing an in-memory size cap. +/// +/// The cap is the global decompressed-size limit ([`max_decompressed_size_bytes`]): it bounds the +/// raw (still-compressed) body a source buffers before decompression, so a large upload cannot +/// drive unbounded allocation independently of the decompressed-size cap. +pub(crate) fn capped_body(limits: &CompressionLimits) -> BoxedFilter<(Bytes,)> { + let max_body_size = limits.max_decompressed_size_bytes; + let max_body_size_header = u64::try_from(max_body_size).unwrap_or(u64::MAX); + + warp::header::optional::("content-length") + .and_then(move |declared: Option| async move { + if declared.is_some_and(|len| len > max_body_size_header) { + Err(warp::reject::custom(request_body_too_large_error( + max_body_size, + ))) + } else { + Ok(()) + } + }) + .untuple_one() + .and(warp::body::stream()) + .and_then(move |body| async move { + collect_body_with_limit(body, max_body_size) + .await + .map_err(warp::reject::custom) + }) + .boxed() +} -pub fn decode(header: Option<&str>, mut body: Bytes) -> Result { +/// Decompresses the body based on the Content-Encoding header. +/// +/// Supports gzip, deflate, snappy, zstd, and identity (no compression). +/// +/// Caps the decompressed output at `limits` to mitigate decompression-bomb DoS attacks. +/// +/// The cap is a parameter rather than process state so a caller passes the value from its own +/// context, and a test can drive any cap it likes. +pub fn decode( + header: Option<&str>, + mut body: Bytes, + limits: &CompressionLimits, +) -> Result { + let max_decompressed_size = limits.max_decompressed_size_bytes; if let Some(encodings) = header { + // Each round is capped, which also bounds a stacked `Content-Encoding: gzip,gzip,...` + // chain, since every round's output is the next round's input. for encoding in encodings.rsplit(',').map(str::trim) { body = match encoding { "identity" => body, - "gzip" => { - let mut decoded = Vec::new(); - MultiGzDecoder::new(body.reader()) - .read_to_end(&mut decoded) - .map_err(|error| handle_decode_error(encoding, error))?; - decoded.into() - } - "deflate" => { - let mut decoded = Vec::new(); - ZlibDecoder::new(body.reader()) - .read_to_end(&mut decoded) - .map_err(|error| handle_decode_error(encoding, error))?; - decoded.into() - } - "snappy" => SnappyDecoder::new() - .decompress_vec(&body) - .map_err(|error| handle_decode_error(encoding, error))? - .into(), - "zstd" => zstd::decode_all(body.reader()) - .map_err(|error| handle_decode_error(encoding, error))? - .into(), + "gzip" => CappedDecoder::gzip(body.reader(), limits) + .decompress() + .map(Bytes::from) + .map_err(|error| { + emit_decompress_error(encoding, error, max_decompressed_size) + })?, + "deflate" => CappedDecoder::zlib(body.reader(), limits) + .decompress() + .map(Bytes::from) + .map_err(|error| { + emit_decompress_error(encoding, error, max_decompressed_size) + })?, + "snappy" => decompress_snappy(&body, max_decompressed_size)?, + "zstd" => CappedDecoder::zstd_http(body.reader(), limits) + .map_err(|error| emit_decompress_error(encoding, error, max_decompressed_size))? + .decompress() + .map(Bytes::from) + .map_err(|error| { + emit_decompress_error(encoding, error, max_decompressed_size) + })?, encoding => { return Err(ErrorMessage::new( StatusCode::UNSUPPORTED_MEDIA_TYPE, @@ -44,10 +89,138 @@ pub fn decode(header: Option<&str>, mut body: Bytes) -> Result ErrorMessage { +fn decompress_snappy(body: &Bytes, max_decompressed_size: usize) -> Result { + // Snappy stores the decompressed length in the frame header, so reject oversized + // payloads before allocating the output buffer. + let len = snap::raw::decompress_len(body).map_err(|error| { + emit_decompress_error( + "snappy", + std::io::Error::other(error), + max_decompressed_size, + ) + })?; + if len > max_decompressed_size { + return Err(decompressed_too_large_error( + "snappy", + max_decompressed_size, + )); + } + let decoded = SnappyDecoder::new().decompress_vec(body).map_err(|error| { + emit_decompress_error( + "snappy", + std::io::Error::other(error), + max_decompressed_size, + ) + })?; + Ok(decoded.into()) +} + +/// Spare capacity added to the initial buffer so a third or later chunk can be appended without +/// reallocating right away. +const ADDITIONAL_CAPACITY_FOR_CHUNKS_BEYOND_FIRST_TWO: usize = 16 * 1024; + +/// Collects the body into [`Bytes`] under `max_body_size`, mirroring the fast paths of hyper's +/// `to_bytes`. Single-chunk bodies avoid the `BytesMut` allocation; a buffer sized for both chunks +/// plus an arbitrary 16 KiB (to try to avoid having to reallocate multiple times once other chunks +/// arrive) is only allocated once a second chunk arrives. +async fn collect_body_with_limit(body: S, max_body_size: usize) -> Result +where + S: futures_util::Stream>, + B: Buf, +{ + futures_util::pin_mut!(body); + + let mut total_body_size: usize = 0; + let mut admit_chunk_within_limit = |chunk: Result| -> Result { + let chunk = chunk.map_err(|error| { + ErrorMessage::new( + StatusCode::BAD_REQUEST, + format!("Failed reading request body: {}", error), + ) + })?; + + total_body_size = total_body_size.saturating_add(chunk.remaining()); + if total_body_size > max_body_size { + return Err(request_body_too_large_error(max_body_size)); + } + + Ok(chunk) + }; + + let Some(chunk) = body.next().await else { + return Ok(Bytes::new()); + }; + let mut first = admit_chunk_within_limit(chunk)?; + + let Some(chunk) = body.next().await else { + return Ok(first.copy_to_bytes(first.remaining())); + }; + let second = admit_chunk_within_limit(chunk)?; + + let mut bytes = BytesMut::with_capacity( + first.remaining() + second.remaining() + ADDITIONAL_CAPACITY_FOR_CHUNKS_BEYOND_FIRST_TWO, + ); + bytes.put(first); + bytes.put(second); + + while let Some(chunk) = body.next().await { + bytes.put(admit_chunk_within_limit(chunk)?); + } + + Ok(bytes.freeze()) +} + +fn ensure_body_within_limit( + body: &Bytes, + encoding: &str, + max_decompressed_size: usize, +) -> Result<(), ErrorMessage> { + if body.len() > max_decompressed_size { + return Err(decompressed_too_large_error( + encoding, + max_decompressed_size, + )); + } + Ok(()) +} + +fn request_body_too_large_error(max: usize) -> ErrorMessage { + ErrorMessage::new( + StatusCode::PAYLOAD_TOO_LARGE, + format!("Request body exceeds limit of {} bytes.", max), + ) +} + +fn decompressed_too_large_error(encoding: &str, max: usize) -> ErrorMessage { + ErrorMessage::new( + StatusCode::PAYLOAD_TOO_LARGE, + format!( + "Decompressed {} body exceeds limit of {} bytes.", + encoding, max + ), + ) +} + +/// Maps a decompression failure to a response. If `error` is a `DecompressedSizeLimitExceeded` +/// (the decompressed output exceeded the configured size cap), it becomes a `413 Payload Too +/// Large` reporting the cap that was actually enforced, matching the request-body and snappy size +/// errors. Any other decode failure emits an `HttpDecompressError` event and becomes a +/// `422 Unprocessable Entity`. +/// +/// Callers whose error is not already an [`std::io::Error`] (e.g. snappy) wrap it via +/// [`std::io::Error::other`]. +fn emit_decompress_error( + encoding: &str, + error: std::io::Error, + max_decompressed_size: usize, +) -> ErrorMessage { + if DecompressedSizeLimitExceeded::is(&error) { + return decompressed_too_large_error(encoding, max_decompressed_size); + } emit!(HttpDecompressError { encoding, error: &error @@ -57,3 +230,209 @@ fn handle_decode_error(encoding: &str, error: impl std::error::Error) -> ErrorMe format!("Failed decompressing payload with {} decoder.", encoding), ) } + +#[cfg(test)] +mod tests { + /// Limits are a parameter now, so a test simply states the cap it wants. + fn limits(max_decompressed_size_bytes: usize) -> CompressionLimits { + CompressionLimits::with_max_decompressed_size_bytes(max_decompressed_size_bytes) + } + + use std::io::Write; + + use flate2::{write::GzEncoder, write::ZlibEncoder, Compression}; + use futures_util::stream; + + use super::*; + + const LIMIT: usize = 64 * 1024; + + /// Asserts the rejection came from the guard that stops the allocation *before* it happens + /// (the per-encoding streaming cap, or snappy's declared-length pre-check), rather than from + /// the `ensure_body_within_limit` backstop, which reports "identity" and only fires once the + /// whole payload has already been materialised in memory. + fn assert_rejected_by_streaming_cap(error: &ErrorMessage, encoding: &str) { + let rendered = error.to_string(); + assert!( + rendered.contains(&format!("Decompressed {encoding} body")), + "expected rejection by the {encoding} cap before allocating, got: {rendered}" + ); + } + + fn gzip(plaintext: &[u8]) -> Bytes { + let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(plaintext).unwrap(); + Bytes::from(encoder.finish().unwrap()) + } + + fn deflate(plaintext: &[u8]) -> Bytes { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(plaintext).unwrap(); + Bytes::from(encoder.finish().unwrap()) + } + + // ---- positive cases: ordinary traffic must be unaffected ---- + + #[test] + fn gzip_within_limit_is_decoded() { + let decoded = decode(Some("gzip"), gzip(b"hello"), &limits(LIMIT)).expect("must decode"); + assert_eq!(decoded, Bytes::from_static(b"hello")); + } + + #[test] + fn deflate_within_limit_is_decoded() { + let decoded = + decode(Some("deflate"), deflate(b"hello"), &limits(LIMIT)).expect("must decode"); + assert_eq!(decoded, Bytes::from_static(b"hello")); + } + + #[test] + fn snappy_within_limit_is_decoded() { + let body = Bytes::from(snap::raw::Encoder::new().compress_vec(b"hello").unwrap()); + let decoded = decode(Some("snappy"), body, &limits(LIMIT)).expect("must decode"); + assert_eq!(decoded, Bytes::from_static(b"hello")); + } + + /// zstd is exercised at a production-scale limit on purpose. `zstd_http` derives the decoder + /// window from the limit (clamped to RFC 9659's 8 MiB), so at a small limit the window clamp + /// binds tighter than the size cap and would refuse even a legitimate frame. At the real + /// 100 MiB default the clamp sits at 8 MiB, which is what this models. + const ZSTD_LIMIT: usize = 8 * 1024 * 1024; + + #[test] + fn zstd_within_limit_is_decoded() { + let body = Bytes::from(zstd::encode_all(&b"hello"[..], 1).unwrap()); + let decoded = decode(Some("zstd"), body, &limits(ZSTD_LIMIT)).expect("must decode"); + assert_eq!(decoded, Bytes::from_static(b"hello")); + } + + #[test] + fn identity_within_limit_passes_through() { + let decoded = decode( + Some("identity"), + Bytes::from_static(b"hello"), + &limits(LIMIT), + ) + .unwrap(); + assert_eq!(decoded, Bytes::from_static(b"hello")); + } + + // ---- negative cases: oversized payloads must be rejected, not buffered ---- + + #[test] + fn gzip_exceeding_limit_returns_413() { + let body = gzip(&vec![0u8; LIMIT + 1]); + let error = decode(Some("gzip"), body, &limits(LIMIT)).expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + assert_rejected_by_streaming_cap(&error, "gzip"); + } + + #[test] + fn deflate_exceeding_limit_returns_413() { + let body = deflate(&vec![0u8; LIMIT + 1]); + let error = decode(Some("deflate"), body, &limits(LIMIT)).expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + assert_rejected_by_streaming_cap(&error, "deflate"); + } + + /// Snappy declares its output length in the frame header, so this must be rejected without + /// ever allocating the output buffer. + #[test] + fn snappy_exceeding_limit_returns_413_before_allocating() { + let body = Bytes::from( + snap::raw::Encoder::new() + .compress_vec(&vec![0u8; LIMIT + 1]) + .unwrap(), + ); + let error = decode(Some("snappy"), body, &limits(LIMIT)).expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + assert_rejected_by_streaming_cap(&error, "snappy"); + } + + /// Concatenated level-1 frames each fit the 8 MiB window clamp, so the aggregate output is + /// what the size cap has to catch — not the window guard. + #[test] + fn zstd_exceeding_limit_returns_413() { + let frame = zstd::encode_all(vec![0u8; 1024 * 1024].as_slice(), 1).unwrap(); + let mut bomb = Vec::new(); + for _ in 0..(ZSTD_LIMIT / (1024 * 1024) + 1) { + bomb.extend_from_slice(&frame); + } + + let error = decode(Some("zstd"), Bytes::from(bomb), &limits(ZSTD_LIMIT)) + .expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + assert_rejected_by_streaming_cap(&error, "zstd"); + } + + /// An uncompressed body over the cap must be rejected too, otherwise `identity` would be a + /// trivial bypass of the whole mechanism. + #[test] + fn identity_exceeding_limit_returns_413() { + let body = Bytes::from(vec![0u8; LIMIT + 1]); + let error = decode(Some("identity"), body, &limits(LIMIT)).expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[test] + fn missing_content_encoding_exceeding_limit_returns_413() { + let body = Bytes::from(vec![0u8; LIMIT + 1]); + let error = decode(None, body, &limits(LIMIT)).expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + } + + /// Stacking encodings must not multiply the amplification: every round is capped. + #[test] + fn stacked_encodings_are_capped_at_every_round() { + let outer = gzip(&gzip(&vec![0u8; LIMIT + 1])); + let error = decode(Some("gzip,gzip"), outer, &limits(LIMIT)).expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + assert_rejected_by_streaming_cap(&error, "gzip"); + } + + /// A malformed payload must stay a 422, distinct from the 413 the cap raises — otherwise the + /// size tests above could be passing for the wrong reason. + #[test] + fn malformed_payload_is_422_not_413() { + let error = decode( + Some("gzip"), + Bytes::from_static(b"not gzip"), + &limits(LIMIT), + ) + .expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[test] + fn unsupported_encoding_is_415() { + let error = decode(Some("br"), Bytes::from_static(b"x"), &limits(LIMIT)) + .expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + // ---- body collection ---- + + #[tokio::test] + async fn collect_body_within_limit_succeeds() { + let chunks: Vec> = vec![ + Ok(Bytes::from_static(b"foo")), + Ok(Bytes::from_static(b"bar")), + ]; + let collected = collect_body_with_limit(stream::iter(chunks), LIMIT) + .await + .expect("must collect"); + assert_eq!(collected, Bytes::from_static(b"foobar")); + } + + /// The running total must trip mid-stream, so no single chunk needs to exceed the cap. + #[tokio::test] + async fn collect_body_rejects_oversized_stream() { + let chunk = Bytes::from(vec![0u8; LIMIT / 2]); + let chunks: Vec> = + vec![Ok(chunk.clone()), Ok(chunk.clone()), Ok(chunk)]; + let error = collect_body_with_limit(stream::iter(chunks), LIMIT) + .await + .expect_err("must be rejected"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + } +} diff --git a/src/sources/util/http/mod.rs b/src/sources/util/http/mod.rs index ae01187b78..f0372f6242 100644 --- a/src/sources/util/http/mod.rs +++ b/src/sources/util/http/mod.rs @@ -22,6 +22,14 @@ mod query; #[cfg(feature = "sources-utils-http-auth")] pub use auth::{HttpSourceAuth, HttpSourceAuthConfig}; +#[cfg(any( + feature = "sources-aws_kinesis_firehose", + feature = "sources-datadog_agent", + feature = "sources-opentelemetry", + feature = "sources-splunk_hec", + feature = "sources-utils-http-prelude", +))] +pub(crate) use encoding::capped_body; #[cfg(feature = "sources-utils-http-encoding")] pub use encoding::decode; #[cfg(feature = "sources-utils-http-error")] diff --git a/src/sources/util/http/prelude.rs b/src/sources/util/http/prelude.rs index 50713b417b..800b545704 100644 --- a/src/sources/util/http/prelude.rs +++ b/src/sources/util/http/prelude.rs @@ -44,9 +44,10 @@ use crate::{ use super::{ auth::{HttpSourceAuth, HttpSourceAuthConfig}, - encoding::decode, + encoding::{capped_body, decode}, error::ErrorMessage, }; +use crate::sources::util::decompression::CompressionLimits; pub trait HttpSource: Clone + Send + Sync + 'static { // This function can be defined to enrich events with additional HTTP @@ -70,8 +71,13 @@ pub trait HttpSource: Clone + Send + Sync + 'static { path: &str, ) -> Result, ErrorMessage>; - fn decode(&self, encoding_header: Option<&str>, body: Bytes) -> Result { - decode(encoding_header, body) + fn decode( + &self, + encoding_header: Option<&str>, + body: Bytes, + limits: &CompressionLimits, + ) -> Result { + decode(encoding_header, body, limits) } #[allow(clippy::too_many_arguments)] @@ -93,6 +99,9 @@ pub trait HttpSource: Clone + Send + Sync + 'static { let protocol = tls.http_protocol_name(); let auth = HttpSourceAuth::try_from(auth)?; let path = path.to_owned(); + // Limits come from this component's context, so a deployment controls the cap. + // `CompressionLimits` is `Copy`, so it can simply be captured by the filter closures. + let compression_limits = cx.globals.limits.compression; let acknowledgements = cx.do_acknowledgements(acknowledgements); let enable_source_ip = self.enable_source_ip(); @@ -132,7 +141,7 @@ pub trait HttpSource: Clone + Send + Sync + 'static { .and(warp::header::optional::("authorization")) .and(warp::header::optional::("content-encoding")) .and(warp::header::headers_cloned()) - .and(warp::body::bytes()) + .and(capped_body(&compression_limits)) .and(warp::query::>()) .and(warp::filters::ext::optional()) .and_then( @@ -148,7 +157,7 @@ pub trait HttpSource: Clone + Send + Sync + 'static { let events = auth .is_valid(&auth_header) - .and_then(|()| self.decode(encoding_header.as_deref(), body)) + .and_then(|()| self.decode(encoding_header.as_deref(), body, &compression_limits)) .and_then(|body| { emit!(HttpBytesReceived { byte_size: body.len(), diff --git a/src/sources/util/mod.rs b/src/sources/util/mod.rs index 155bdbf568..1ee5da3c6b 100644 --- a/src/sources/util/mod.rs +++ b/src/sources/util/mod.rs @@ -3,6 +3,7 @@ mod body_decoding; #[cfg(feature = "sources-vector")] pub mod jwt_auth; +pub mod decompression; mod encoding_config; #[cfg(all(unix, feature = "sources-dnstap"))] pub mod framestream; diff --git a/src/sources/vector/mod.rs b/src/sources/vector/mod.rs index 85e7fc6953..a50d127624 100644 --- a/src/sources/vector/mod.rs +++ b/src/sources/vector/mod.rs @@ -24,8 +24,8 @@ use crate::{ serde::bool_or_struct, sources::{ util::{ - add_auth_metadata, grpc::run_grpc_server, Auth, AuthConfig, AuthContext, AuthError, - AuthEventError, EventValidator, + add_auth_metadata, decompression::CompressionLimits, grpc::run_grpc_server, + Auth, AuthConfig, AuthContext, AuthError, AuthEventError, EventValidator, }, Source, }, @@ -348,6 +348,8 @@ impl GenerateConfig for VectorConfig { #[typetag::serde(name = "vector")] impl SourceConfig for VectorConfig { async fn build(&self, cx: SourceContext) -> crate::Result { + // From this component's context, so the deployment controls the cap. + let compression_limits: CompressionLimits = cx.globals.limits.compression; let tls_settings = MaybeTlsSettings::from_config(self.tls.as_ref(), true)?; let acknowledgements = cx.do_acknowledgements(self.acknowledgements); let log_namespace = cx.log_namespace(self.log_namespace); @@ -366,11 +368,20 @@ impl SourceConfig for VectorConfig { auth_metrics, }) .accept_compressed(tonic::codec::CompressionEncoding::Gzip) - // Tonic added a default of 4MB in 0.9. This replaces the old behavior. - .max_decoding_message_size(usize::MAX); + // Tonic added a default of 4MB in 0.9. Bound this by the global decompressed-size cap + // rather than `usize::MAX` so a single oversized message cannot drive unbounded + // allocation on this unauthenticated listener. + .max_decoding_message_size(compression_limits.max_decompressed_size_bytes); let source = - run_grpc_server(self.address, tls_settings, service, cx.shutdown).map_err(|error| { + run_grpc_server( + self.address, + tls_settings, + service, + cx.shutdown, + compression_limits, + ) + .map_err(|error| { error!(message = "Source future failed.", %error); }); diff --git a/src/topology/builder.rs b/src/topology/builder.rs index 8db8a17b90..0f24dc3fa9 100644 --- a/src/topology/builder.rs +++ b/src/topology/builder.rs @@ -11,7 +11,9 @@ use tokio::{ time::{timeout, Duration}, }; use tracing::Instrument; +use vector_common::decompression::OperationalLimitsOverride; use vector_config::NamedComponent; +use vector_lib::config::GlobalOptions; use vector_lib::config::LogNamespace; use vector_lib::internal_event::{ self, CountByteSize, EventsSent, InternalEventHandle as _, Registered, @@ -178,6 +180,20 @@ impl<'a> Builder<'a> { CHECKPT_STORE.clone() } + /// Global options for one component, with its `limits` override resolved against the global + /// ones. + /// + /// Handing components a pre-resolved `GlobalOptions` keeps every read site unchanged: they + /// still take `cx.globals.limits`, and never need to know an override existed. Raises are + /// reported to the user by `config::validation::warnings`, which runs before this. + fn globals_for(&self, over: &OperationalLimitsOverride) -> GlobalOptions { + resolve_globals( + &self.config.global, + over, + self.config.allow_component_limit_overrides, + ) + } + /// Loads, or reloads the enrichment tables. /// The tables are stored in the `ENRICHMENT_TABLES` global variable. async fn load_enrichment_tables(&mut self) -> &'static vector_lib::enrichment::TableRegistry { @@ -356,7 +372,7 @@ impl<'a> Builder<'a> { let context = SourceContext::new( key.clone(), - self.config.global.clone(), + self.globals_for(&source.limits), shutdown_signal, pipeline, ProxyConfig::merge_with_env(&self.config.global.proxy, &source.proxy), @@ -493,7 +509,7 @@ impl<'a> Builder<'a> { let context = TransformContext { key: Some(key.clone()), - globals: self.config.global.clone(), + globals: self.globals_for(&transform.limits), enrichment_tables: enrichment_tables.clone(), schema_definitions, merged_schema_definition: merged_definition.clone(), @@ -608,7 +624,7 @@ impl<'a> Builder<'a> { let cx = SinkContext { healthcheck, - globals: self.config.global.clone(), + globals: self.globals_for(&sink.limits), proxy: ProxyConfig::merge_with_env(&self.config.global.proxy, sink.proxy()), schema: self.config.schema, app_name: crate::get_app_name().to_string(), @@ -1104,3 +1120,95 @@ fn build_task_transform( (task, outputs) } + +/// Applies a component's `limits` override to the global options it will be built with. +/// +/// Split out from [`Builder::globals_for`] so the resolution can be tested without standing up a +/// whole topology. +fn resolve_globals( + global: &GlobalOptions, + over: &OperationalLimitsOverride, + allow_raise: bool, +) -> GlobalOptions { + let mut globals = global.clone(); + if !over.is_empty() { + let (resolved, _raises) = global.limits.resolve(over, allow_raise); + globals.limits = resolved; + } + globals +} + +#[cfg(test)] +mod limit_override_tests { + use vector_common::decompression::{ + CompressionLimits, CompressionLimitsOverride, OperationalLimits, OperationalLimitsOverride, + }; + + use super::{resolve_globals, GlobalOptions}; + + fn global_with(max: usize) -> GlobalOptions { + GlobalOptions { + limits: OperationalLimits { + compression: CompressionLimits::with_max_decompressed_size_bytes(max), + }, + ..Default::default() + } + } + + fn asking(max: usize) -> OperationalLimitsOverride { + OperationalLimitsOverride { + compression: CompressionLimitsOverride { + max_decompressed_size_bytes: Some(max), + }, + } + } + + /// The override has to reach the component. Every component reads `cx.globals.limits`, so the + /// resolved value must be what lands there — not the untouched global. + #[test] + fn a_component_override_reaches_the_globals_it_is_built_with() { + let globals = resolve_globals(&global_with(4096), &asking(1024), false); + + assert_eq!(globals.limits.compression.max_decompressed_size_bytes, 1024); + } + + /// A component saying nothing must be handed the deployment's limits untouched. + #[test] + fn no_override_leaves_the_globals_alone() { + let global = global_with(4096); + let globals = resolve_globals(&global, &OperationalLimitsOverride::default(), false); + + assert_eq!(globals.limits, global.limits); + } + + /// Without the start option, a component cannot build itself a looser limit than the operator + /// allowed, however its config is written. + #[test] + fn a_raise_does_not_reach_the_component_unless_permitted() { + let clamped = resolve_globals(&global_with(4096), &asking(1 << 30), false); + assert_eq!( + clamped.limits.compression.max_decompressed_size_bytes, 4096, + "the global ceiling must survive" + ); + + let granted = resolve_globals(&global_with(4096), &asking(1 << 30), true); + assert_eq!( + granted.limits.compression.max_decompressed_size_bytes, + 1 << 30, + "--allow-component-limit-overrides must actually grant the raise" + ); + } + + /// Resolution must touch only `limits`; anything else in `GlobalOptions` belongs to the + /// deployment and must pass through unchanged. + #[test] + fn resolution_changes_nothing_but_the_limits() { + let global = global_with(4096); + let resolved = resolve_globals(&global, &asking(1024), false); + + assert_eq!(resolved.data_dir, global.data_dir); + assert_eq!(resolved.log_schema, global.log_schema); + assert_eq!(resolved.timezone, global.timezone); + assert_eq!(resolved.proxy, global.proxy); + } +} diff --git a/src/validate.rs b/src/validate.rs index 99896e68d2..e2611a6912 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -30,6 +30,14 @@ pub struct Opts { #[arg(short, long)] pub deny_warnings: bool, + /// Validate as though Vector were started with `--allow-component-limit-overrides`. + /// + /// Without this, a component asking for a limit looser than `limits.*` is reported as being + /// clamped, because that is what a default run does. Pass it to describe the run you actually + /// intend to make. + #[arg(long, env = "VECTOR_ALLOW_COMPONENT_LIMIT_OVERRIDES")] + pub allow_component_limit_overrides: bool, + /// Vector config files in TOML format to validate. #[arg( id = "config-toml", @@ -150,9 +158,12 @@ pub fn validate_config(opts: &Opts, fmt: &mut Formatter) -> Option { fmt.title(format!("Failed to load {:?}", &paths_list)); fmt.sub_error(errors); }; - let builder = config::load_builder_from_paths(&paths) + let mut builder = config::load_builder_from_paths(&paths) .map_err(&mut report_error) .ok()?; + // Not a config key, so it has to be applied here rather than read from the file. Without it + // `validate` would describe a default run even when asked about a permissive one. + builder.allow_component_limit_overrides = opts.allow_component_limit_overrides; config::init_log_schema(builder.global.log_schema.clone(), true); // Build diff --git a/tests/e2e/datadog/metrics/mod.rs b/tests/e2e/datadog/metrics/mod.rs index 875d74f0fc..28b2a442ab 100644 --- a/tests/e2e/datadog/metrics/mod.rs +++ b/tests/e2e/datadog/metrics/mod.rs @@ -1,3 +1,9 @@ +// Tests decode payloads this process just encoded, so there is no untrusted input and nothing to +// cap. They deliberately keep using the raw decoders: leaving them untouched means they stay an +// independent regression check on the capped wrappers, rather than testing those wrappers against +// themselves. +#![allow(clippy::disallowed_types)] + use base64::{prelude::BASE64_STANDARD, Engine}; use bytes::Bytes; use flate2::read::ZlibDecoder;