Skip to content

Commit b47afd6

Browse files
authored
fix(actix-http): parse header quality values as exact decimals (#4314)
1 parent 1f0c38b commit b47afd6

2 files changed

Lines changed: 81 additions & 7 deletions

File tree

‎actix-http/CHANGES.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
## Unreleased
44

55
- Fix `QualityItem` parsing to recognize quality parameters before other parameters and ignore semicolons inside quoted values.
6+
- Parse header quality values as exact decimals and reject invalid numeric syntax.
7+
- Round float quality conversions to the nearest thousandth to preserve all three decimal places.
68
- Update `zstd` dependency to `0.14`.
79
- Update `brotli` dependency to `9`.
8-
- Round float quality conversions to the nearest thousandth to preserve all three decimal places.
910

1011
## 3.18.13
1112

‎actix-http/src/header/shared/quality_item.rs‎

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,34 @@ impl<T: str::FromStr> str::FromStr for QualityItem<T> {
141141
let mut quality = Quality::MAX;
142142

143143
if let Some((start, end, value)) = quality_parameter {
144-
// reject q-values longer than allowed by the spec (3 d.p.)
145-
if value.len() > 5 {
146-
return Err(ParseError::Header);
147-
}
144+
// Parse decimal digits directly to preserve the specified precision.
145+
quality = match value.as_bytes() {
146+
[b'0'] => Quality::ZERO,
147+
[b'1'] => Quality::MAX,
148+
149+
[b'0', b'.', digits @ ..]
150+
if digits.len() <= 3 && digits.iter().all(u8::is_ascii_digit) =>
151+
{
152+
let mut value = 0;
153+
let mut place = 100;
154+
155+
for &digit in digits {
156+
value += u16::from(digit - b'0') * place;
157+
place /= 10;
158+
}
159+
160+
Quality(value)
161+
}
162+
163+
[b'1', b'.', digits @ ..]
164+
if digits.len() <= 3 && digits.iter().all(|&digit| digit == b'0') =>
165+
{
166+
Quality::MAX
167+
}
168+
169+
_ => return Err(ParseError::Header),
170+
};
148171

149-
let value = value.parse::<f32>().map_err(|_| ParseError::Header)?;
150-
quality = Quality::try_from(value).map_err(|_| ParseError::Header)?;
151172
raw_item = if end == q_item_str.len() {
152173
Cow::Borrowed(q_item_str[..start].trim())
153174
} else {
@@ -405,6 +426,58 @@ mod tests {
405426
"gzip; q=2".parse::<QualityItem<Encoding>>().unwrap_err();
406427
}
407428

429+
#[test]
430+
fn quality_value_rejects_invalid_numeric_syntax() {
431+
for value in [
432+
"", ".5", ".0001", "+0.5", "-0", "-0.0", "00.5", "00001", "1e-1", "1e-4", "0.0000",
433+
"1.0000", "1.001", "0.0.1", "NaN", "inf", " 0.5", "\"0.5\"",
434+
] {
435+
let input = format!("gzip; q={value}");
436+
437+
assert!(
438+
input.parse::<QualityItem<Encoding>>().is_err(),
439+
"accepted invalid quality in {input:?}"
440+
);
441+
}
442+
}
443+
444+
#[test]
445+
fn quality_value_accepts_valid_numeric_syntax() {
446+
for (value, expected) in [
447+
("0", Quality::ZERO),
448+
("0.", Quality::ZERO),
449+
("0.0", Quality::ZERO),
450+
("0.00", Quality::ZERO),
451+
("0.000", Quality::ZERO),
452+
("0.001", Quality::MIN),
453+
("0.01", Quality(10)),
454+
("0.1", Quality(100)),
455+
("0.10", Quality(100)),
456+
("0.100", Quality(100)),
457+
("0.999", Quality(999)),
458+
("1", Quality::MAX),
459+
("1.", Quality::MAX),
460+
("1.0", Quality::MAX),
461+
("1.00", Quality::MAX),
462+
("1.000", Quality::MAX),
463+
] {
464+
let input = format!("gzip; q={value}");
465+
let parsed = input.parse::<QualityItem<Encoding>>().unwrap();
466+
467+
assert_eq!(parsed.quality, expected, "{input}");
468+
}
469+
}
470+
471+
#[test]
472+
fn quality_value_preserves_thousandths() {
473+
for value in 0..1000 {
474+
let input = format!("gzip; q=0.{value:03}");
475+
let parsed = input.parse::<QualityItem<Encoding>>().unwrap();
476+
477+
assert_eq!(parsed.quality, Quality(value), "{input}");
478+
}
479+
}
480+
408481
#[test]
409482
fn test_quality_item_ordering() {
410483
let x = "gzip; q=0.5".parse::<QualityItem<Encoding>>().unwrap();

0 commit comments

Comments
 (0)