From ddc8c16cb34c9730b9a4922413fbf027e401052d Mon Sep 17 00:00:00 2001 From: Arthur Le Moigne Date: Wed, 1 Sep 2021 10:08:29 +0200 Subject: [PATCH] Fix quality parse error in Accept-Encoding HTTP header (#2344) --- CHANGES.md | 7 +- actix-http/CHANGES.md | 5 +- actix-http/src/encoding/decoder.rs | 3 +- .../src/header/shared/content_encoding.rs | 56 ++--- actix-http/src/header/shared/quality_item.rs | 29 ++- src/middleware/compress.rs | 217 +++++++++++++++--- tests/test_server.rs | 19 ++ 7 files changed, 259 insertions(+), 77 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 5325caf48..217ec4f78 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,10 +4,15 @@ ### Added * Re-export actix-service `ServiceFactory` in `dev` module. [#2325] -### Changes +### Changed * Minimum supported Rust version (MSRV) is now 1.51. +* Compress middleware will return 406 Not Acceptable when no content encoding is acceptable to the client. [#2344] + +### Fixed +* Fix quality parse error in Accept-Encoding header. [#2344] [#2325]: https://github.com/actix/actix-web/pull/2325 +[#2344]: https://github.com/actix/actix-web/pull/2344 ## 4.0.0-beta.8 - 2021-06-26 diff --git a/actix-http/CHANGES.md b/actix-http/CHANGES.md index 63172e56d..f4efef54a 100644 --- a/actix-http/CHANGES.md +++ b/actix-http/CHANGES.md @@ -1,22 +1,23 @@ # Changes ## Unreleased - 2021-xx-xx -### Changes +### Changed * Minimum supported Rust version (MSRV) is now 1.51. ### Fixed * Remove slice creation pointing to potential uninitialized data on h1 encoder. [#2364] * Remove `Into` bound on `Encoder` body types. [#2375] +* Fix quality parse error in Accept-Encoding header. [#2344] [#2364]: https://github.com/actix/actix-web/pull/2364 [#2375]: https://github.com/actix/actix-web/pull/2375 +[#2344]: https://github.com/actix/actix-web/pull/2344 ## 3.0.0-beta.8 - 2021-08-09 ### Fixed * Potential HTTP request smuggling vulnerabilities. [RUSTSEC-2021-0081](https://github.com/rustsec/advisory-db/pull/977) - ## 3.0.0-beta.8 - 2021-06-26 ### Changed * Change compression algorithm features flags. [#2250] diff --git a/actix-http/src/encoding/decoder.rs b/actix-http/src/encoding/decoder.rs index d3e304836..81e97d916 100644 --- a/actix-http/src/encoding/decoder.rs +++ b/actix-http/src/encoding/decoder.rs @@ -1,6 +1,7 @@ //! Stream decoders. use std::{ + convert::TryFrom, future::Future, io::{self, Write as _}, pin::Pin, @@ -80,7 +81,7 @@ where let encoding = headers .get(&CONTENT_ENCODING) .and_then(|val| val.to_str().ok()) - .map(ContentEncoding::from) + .and_then(|x| ContentEncoding::try_from(x).ok()) .unwrap_or(ContentEncoding::Identity); Self::new(stream, encoding) diff --git a/actix-http/src/header/shared/content_encoding.rs b/actix-http/src/header/shared/content_encoding.rs index b9c1d2795..375e8c2fa 100644 --- a/actix-http/src/header/shared/content_encoding.rs +++ b/actix-http/src/header/shared/content_encoding.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, str::FromStr}; +use std::{convert::TryFrom, error, fmt, str::FromStr}; use http::header::InvalidHeaderValue; @@ -8,6 +8,20 @@ use crate::{ HttpMessage, }; +/// Error return when a content encoding is unknown. +/// +/// Example: 'compress' +#[derive(Debug)] +pub struct ContentEncodingParseError; + +impl fmt::Display for ContentEncodingParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Unsupported content encoding") + } +} + +impl error::Error for ContentEncodingParseError {} + /// Represents a supported content encoding. #[derive(Copy, Clone, PartialEq, Debug)] pub enum ContentEncoding { @@ -37,7 +51,7 @@ impl ContentEncoding { matches!(self, ContentEncoding::Identity | ContentEncoding::Auto) } - /// Convert content encoding to string + /// Convert content encoding to string. #[inline] pub fn as_str(self) -> &'static str { match self { @@ -48,18 +62,6 @@ impl ContentEncoding { ContentEncoding::Identity | ContentEncoding::Auto => "identity", } } - - /// Default Q-factor (quality) value. - #[inline] - pub fn quality(self) -> f64 { - match self { - ContentEncoding::Br => 1.1, - ContentEncoding::Gzip => 1.0, - ContentEncoding::Deflate => 0.9, - ContentEncoding::Identity | ContentEncoding::Auto => 0.1, - ContentEncoding::Zstd => 0.0, - } - } } impl Default for ContentEncoding { @@ -69,31 +71,33 @@ impl Default for ContentEncoding { } impl FromStr for ContentEncoding { - type Err = Infallible; + type Err = ContentEncodingParseError; fn from_str(val: &str) -> Result { - Ok(Self::from(val)) - } -} - -impl From<&str> for ContentEncoding { - fn from(val: &str) -> ContentEncoding { let val = val.trim(); if val.eq_ignore_ascii_case("br") { - ContentEncoding::Br + Ok(ContentEncoding::Br) } else if val.eq_ignore_ascii_case("gzip") { - ContentEncoding::Gzip + Ok(ContentEncoding::Gzip) } else if val.eq_ignore_ascii_case("deflate") { - ContentEncoding::Deflate + Ok(ContentEncoding::Deflate) } else if val.eq_ignore_ascii_case("zstd") { - ContentEncoding::Zstd + Ok(ContentEncoding::Zstd) } else { - ContentEncoding::default() + Err(ContentEncodingParseError) } } } +impl TryFrom<&str> for ContentEncoding { + type Error = ContentEncodingParseError; + + fn try_from(val: &str) -> Result { + val.parse() + } +} + impl IntoHeaderValue for ContentEncoding { type Error = InvalidHeaderValue; diff --git a/actix-http/src/header/shared/quality_item.rs b/actix-http/src/header/shared/quality_item.rs index 240a0afa2..63fa02e7b 100644 --- a/actix-http/src/header/shared/quality_item.rs +++ b/actix-http/src/header/shared/quality_item.rs @@ -1,11 +1,14 @@ use std::{ cmp, convert::{TryFrom, TryInto}, - fmt, str, + fmt, + str::{self, FromStr}, }; use derive_more::{Display, Error}; +use crate::error::ParseError; + const MAX_QUALITY: u16 = 1000; const MAX_FLOAT_QUALITY: f32 = 1.0; @@ -113,12 +116,12 @@ impl fmt::Display for QualityItem { } } -impl str::FromStr for QualityItem { - type Err = crate::error::ParseError; +impl FromStr for QualityItem { + type Err = ParseError; - fn from_str(qitem_str: &str) -> Result, crate::error::ParseError> { + fn from_str(qitem_str: &str) -> Result { if !qitem_str.is_ascii() { - return Err(crate::error::ParseError::Header); + return Err(ParseError::Header); } // Set defaults used if parsing fails. @@ -139,7 +142,7 @@ impl str::FromStr for QualityItem { if parts[0].len() < 2 { // Can't possibly be an attribute since an attribute needs at least a name followed // by an equals sign. And bare identifiers are forbidden. - return Err(crate::error::ParseError::Header); + return Err(ParseError::Header); } let start = &parts[0][0..2]; @@ -148,25 +151,21 @@ impl str::FromStr for QualityItem { let q_val = &parts[0][2..]; if q_val.len() > 5 { // longer than 5 indicates an over-precise q-factor - return Err(crate::error::ParseError::Header); + return Err(ParseError::Header); } - let q_value = q_val - .parse::() - .map_err(|_| crate::error::ParseError::Header)?; + let q_value = q_val.parse::().map_err(|_| ParseError::Header)?; if (0f32..=1f32).contains(&q_value) { quality = q_value; raw_item = parts[1]; } else { - return Err(crate::error::ParseError::Header); + return Err(ParseError::Header); } } } - let item = raw_item - .parse::() - .map_err(|_| crate::error::ParseError::Header)?; + let item = raw_item.parse::().map_err(|_| ParseError::Header)?; // we already checked above that the quality is within range Ok(QualityItem::new(item, Quality::from_f32(quality))) @@ -224,7 +223,7 @@ mod tests { } } - impl str::FromStr for Encoding { + impl FromStr for Encoding { type Err = crate::error::ParseError; fn from_str(s: &str) -> Result { use Encoding::*; diff --git a/src/middleware/compress.rs b/src/middleware/compress.rs index a9128bc47..0e61a8e7e 100644 --- a/src/middleware/compress.rs +++ b/src/middleware/compress.rs @@ -2,10 +2,10 @@ use std::{ cmp, + convert::TryFrom, future::Future, marker::PhantomData, pin::Pin, - str::FromStr, task::{Context, Poll}, }; @@ -13,16 +13,18 @@ use actix_http::{ body::{MessageBody, ResponseBody}, encoding::Encoder, http::header::{ContentEncoding, ACCEPT_ENCODING}, + StatusCode, }; use actix_service::{Service, Transform}; -use actix_utils::future::{ok, Ready}; +use actix_utils::future::{ok, Either, Ready}; use futures_core::ready; +use once_cell::sync::Lazy; use pin_project::pin_project; use crate::{ dev::BodyEncoding, service::{ServiceRequest, ServiceResponse}, - Error, + Error, HttpResponse, }; /// Middleware for compressing response payloads. @@ -78,34 +80,78 @@ pub struct CompressMiddleware { encoding: ContentEncoding, } +static SUPPORTED_ALGORITHM_NAMES: Lazy = Lazy::new(|| { + let mut encoding = vec![]; + + #[cfg(feature = "compress-brotli")] + { + encoding.push("br"); + } + + #[cfg(feature = "compress-gzip")] + { + encoding.push("gzip"); + encoding.push("deflate"); + } + + #[cfg(feature = "compress-zstd")] + encoding.push("zstd"); + + assert!( + !encoding.is_empty(), + "encoding can not be empty unless __compress feature has been explicitly enabled by itself" + ); + + encoding.join(", ") +}); + impl Service for CompressMiddleware where - B: MessageBody, S: Service, Error = Error>, + B: MessageBody, { type Response = ServiceResponse>>; type Error = Error; - type Future = CompressResponse; + type Future = Either, Ready>>; actix_service::forward_ready!(service); #[allow(clippy::borrow_interior_mutable_const)] fn call(&self, req: ServiceRequest) -> Self::Future { // negotiate content-encoding - let encoding = if let Some(val) = req.headers().get(&ACCEPT_ENCODING) { - if let Ok(enc) = val.to_str() { - AcceptEncoding::parse(enc, self.encoding) - } else { - ContentEncoding::Identity - } - } else { - ContentEncoding::Identity - }; + let encoding_result = req + .headers() + .get(&ACCEPT_ENCODING) + .and_then(|val| val.to_str().ok()) + .map(|enc| AcceptEncoding::try_parse(enc, self.encoding)); - CompressResponse { - encoding, - fut: self.service.call(req), - _phantom: PhantomData, + match encoding_result { + // Missing header => fallback to identity + None => Either::left(CompressResponse { + encoding: ContentEncoding::Identity, + fut: self.service.call(req), + _phantom: PhantomData, + }), + + // Valid encoding + Some(Ok(encoding)) => Either::left(CompressResponse { + encoding, + fut: self.service.call(req), + _phantom: PhantomData, + }), + + // There is an HTTP header but we cannot match what client as asked for + Some(Err(_)) => { + let res = HttpResponse::with_body( + StatusCode::NOT_ACCEPTABLE, + SUPPORTED_ALGORITHM_NAMES.as_str(), + ); + let enc = ContentEncoding::Identity; + + Either::right(ok(req.into_response(res.map_body(move |head, body| { + Encoder::response(enc, head, ResponseBody::Other(body.into())) + })))) + } } } } @@ -114,7 +160,6 @@ where pub struct CompressResponse where S: Service, - B: MessageBody, { #[pin] fut: S::Future, @@ -151,6 +196,7 @@ where struct AcceptEncoding { encoding: ContentEncoding, + // TODO: use Quality or QualityItem quality: f64, } @@ -177,26 +223,56 @@ impl PartialOrd for AcceptEncoding { impl PartialEq for AcceptEncoding { fn eq(&self, other: &AcceptEncoding) -> bool { - self.quality == other.quality + self.encoding == other.encoding && self.quality == other.quality } } +/// Parse q-factor from quality strings. +/// +/// If parse fail, then fallback to default value which is 1. +/// More details available here: +fn parse_quality(parts: &[&str]) -> f64 { + for part in parts { + if part.trim().starts_with("q=") { + return part[2..].parse().unwrap_or(1.0); + } + } + + 1.0 +} + +#[derive(Debug, PartialEq, Eq)] +enum AcceptEncodingError { + /// This error occurs when client only support compressed response and server do not have any + /// algorithm that match client accepted algorithms. + CompressionAlgorithmMismatch, +} + impl AcceptEncoding { fn new(tag: &str) -> Option { let parts: Vec<&str> = tag.split(';').collect(); let encoding = match parts.len() { 0 => return None, - _ => ContentEncoding::from(parts[0]), - }; - let quality = match parts.len() { - 1 => encoding.quality(), - _ => f64::from_str(parts[1]).unwrap_or(0.0), + _ => match ContentEncoding::try_from(parts[0]) { + Err(_) => return None, + Ok(x) => x, + }, }; + + let quality = parse_quality(&parts[1..]); + if quality <= 0.0 || quality > 1.0 { + return None; + } + Some(AcceptEncoding { encoding, quality }) } - /// Parse a raw Accept-Encoding header value into an ordered list. - pub fn parse(raw: &str, encoding: ContentEncoding) -> ContentEncoding { + /// Parse a raw Accept-Encoding header value into an ordered list then return the best match + /// based on middleware configuration. + pub fn try_parse( + raw: &str, + encoding: ContentEncoding, + ) -> Result { let mut encodings = raw .replace(' ', "") .split(',') @@ -206,13 +282,90 @@ impl AcceptEncoding { encodings.sort(); for enc in encodings { - if encoding == ContentEncoding::Auto { - return enc.encoding; - } else if encoding == enc.encoding { - return encoding; + if encoding == ContentEncoding::Auto || encoding == enc.encoding { + return Ok(enc.encoding); } } - ContentEncoding::Identity + // Special case if user cannot accept uncompressed data. + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding + // TODO: account for whitespace + if raw.contains("*;q=0") || raw.contains("identity;q=0") { + return Err(AcceptEncodingError::CompressionAlgorithmMismatch); + } + + Ok(ContentEncoding::Identity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! assert_parse_eq { + ($raw:expr, $result:expr) => { + assert_eq!( + AcceptEncoding::try_parse($raw, ContentEncoding::Auto), + Ok($result) + ); + }; + } + + macro_rules! assert_parse_fail { + ($raw:expr) => { + assert!(AcceptEncoding::try_parse($raw, ContentEncoding::Auto).is_err()); + }; + } + + #[test] + fn test_parse_encoding() { + // Test simple case + assert_parse_eq!("br", ContentEncoding::Br); + assert_parse_eq!("gzip", ContentEncoding::Gzip); + assert_parse_eq!("deflate", ContentEncoding::Deflate); + assert_parse_eq!("zstd", ContentEncoding::Zstd); + + // Test space, trim, missing values + assert_parse_eq!("br,,,,", ContentEncoding::Br); + assert_parse_eq!("gzip , br, zstd", ContentEncoding::Gzip); + + // Test float number parsing + assert_parse_eq!("br;q=1 ,", ContentEncoding::Br); + assert_parse_eq!("br;q=1.0 , br", ContentEncoding::Br); + + // Test wildcard + assert_parse_eq!("*", ContentEncoding::Identity); + assert_parse_eq!("*;q=1.0", ContentEncoding::Identity); + } + + #[test] + fn test_parse_encoding_qfactor_ordering() { + assert_parse_eq!("gzip, br, zstd", ContentEncoding::Gzip); + assert_parse_eq!("zstd, br, gzip", ContentEncoding::Zstd); + + assert_parse_eq!("gzip;q=0.4, br;q=0.6", ContentEncoding::Br); + assert_parse_eq!("gzip;q=0.8, br;q=0.4", ContentEncoding::Gzip); + } + + #[test] + fn test_parse_encoding_qfactor_invalid() { + // Out of range + assert_parse_eq!("gzip;q=-5.0", ContentEncoding::Identity); + assert_parse_eq!("gzip;q=5.0", ContentEncoding::Identity); + + // Disabled + assert_parse_eq!("gzip;q=0", ContentEncoding::Identity); + } + + #[test] + fn test_parse_compression_required() { + // Check we fallback to identity if there is an unsupported compression algorithm + assert_parse_eq!("compress", ContentEncoding::Identity); + + // User do not want any compression + assert_parse_fail!("compress, identity;q=0"); + assert_parse_fail!("compress, identity;q=0.0"); + assert_parse_fail!("compress, *;q=0"); + assert_parse_fail!("compress, *;q=0.0"); } } diff --git a/tests/test_server.rs b/tests/test_server.rs index afea39dd9..beb8ff0f5 100644 --- a/tests/test_server.rs +++ b/tests/test_server.rs @@ -1077,3 +1077,22 @@ async fn test_data_drop() { assert_eq!(num.load(Ordering::SeqCst), 0); } + +#[actix_rt::test] +async fn test_accept_encoding_no_match() { + let srv = actix_test::start_with(actix_test::config().h1(), || { + App::new() + .wrap(Compress::default()) + .service(web::resource("/").route(web::to(move || HttpResponse::Ok().finish()))) + }); + + let response = srv + .get("/") + .append_header((ACCEPT_ENCODING, "compress, identity;q=0")) + .no_decompress() + .send() + .await + .unwrap(); + + assert_eq!(response.status().as_u16(), 406); +}