From 1535f869522066e89a63884a521725d68f71ee24 Mon Sep 17 00:00:00 2001 From: fewensa Date: Sat, 12 Sep 2026 18:51:28 +0800 Subject: [PATCH] Harden typed Content-Range parsing codeon: version: 1 authority: FWN-335 description: |- Validate Content-Range with HTTP OWS-aware singleton parsing, preserving canonical formatting while rejecting malformed separators, invalid bounds, controls, overflow, and duplicate fields. Add focused protocol tests for canonical byte and unsatisfied forms, and update user-facing range metadata documentation for the client/server facade behavior. --- README.md | 13 +++++++++---- crates/rttp-protocol/src/range.rs | 22 ++++++++++++++++++--- crates/rttp-protocol/tests/range.rs | 30 +++++++++++++++++++++++++++++ crates/rttp-server/README.md | 7 ++++++- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 39b49d43..ee12f227 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,14 @@ client `206 Partial Content` and `416 Range Not Satisfiable` are visible through `Response::is_partial_content()` and `Response::is_range_not_satisfiable()`. -`Response::content_range()` parses `Content-Range` into `ContentRange`, using -`start` and `end` for satisfiable ranges such as `bytes 10-19/200`, and no -`start` or `end` for unsatisfied ranges such as `bytes */200`. +`Response::content_range()` parses a bounded singleton `Content-Range` into +`ContentRange`, using `start` and `end` for satisfiable byte ranges such as +`bytes 10-19/200`, and no `start` or `end` for unsatisfied ranges such as +`bytes */200`. The typed parser accepts HTTP optional whitespace around the +field value, emits canonical `bytes start-end/length` or `bytes */length` +formatting, and rejects duplicate fields, unsupported units, malformed +separators, control bytes, overflow, inverted ranges, and ranges whose last +byte is not below a known complete length. `If-Range` is available through bounded request helpers that compose with the range helpers: `if_range_etag(etag)` writes a single strong entity-tag @@ -1890,7 +1895,7 @@ gain additional HTTP/2 header-block handling. | Accept-Encoding | Client `accept_encoding`, `accept_encoding_with_q`, and gzip/deflate/br/identity helpers format bounded `Accept-Encoding` request metadata through the shared `rttp-protocol` type; server `Request::accept_encoding()` and `HttpRequest::accept_encoding()` parse received fields into `HttpRequestAcceptEncodings` | No compression, decompression, content negotiation, retries, or transport changes | | Upgrade and tunnel handoff | `CONNECT` returns the tunnel socket after a successful `200`; `upgrade()` returns the socket after `101 Switching Protocols` and skips interim `1xx` responses | Upgraded protocols are handed to the caller and are not parsed by `rttp_client` | | Redirects | Auto-redirect covers 301, 302, 303, 307, and 308 method/body behavior, relative and absolute `Location` resolution, same- and cross-authority header handling, loop detection, and redirect bounds | Redirects are HTTP client behavior, not a browser policy implementation | -| Byte ranges | `range`, `range_from`, `range_suffix`, `if_range_etag`, and `if_range_date` emit bounded HTTP/1.1 range request metadata; `Response::content_range`, `accept_ranges`, `is_partial_content`, and `is_range_not_satisfiable` expose `Content-Range`, `Accept-Ranges`, `206`, and `416` metadata while preserving raw headers | No Range request generation from `Accept-Ranges`, client-side `If-Range` evaluation, partial response engine, byte serving, content slicing, download resume, automatic retry/replay, cache storage, redirect handling, status-policy behavior, multipart range generation, or automatic cache validation policy | +| Byte ranges | `range`, `range_from`, `range_suffix`, `if_range_etag`, and `if_range_date` emit bounded HTTP/1.1 range request metadata; `Response::content_range`, `accept_ranges`, `is_partial_content`, and `is_range_not_satisfiable` expose bounded typed `Content-Range`, `Accept-Ranges`, `206`, and `416` metadata while preserving raw headers | No Range request generation from `Accept-Ranges`, client-side `If-Range` evaluation, partial response engine, byte serving, content slicing, download resume, automatic retry/replay, cache storage, redirect handling, status-policy behavior, multipart range generation, or automatic cache validation policy | | Accept-Patch | `Response::accept_patch` parses repeated bounded `Accept-Patch` response fields through the shared `AcceptPatch` type into ordered `MediaType` values while preserving raw headers on parse errors | No PATCH routing, payload decoding, media-type negotiation, method selection, retry, or automatic follow-up request | | Accept-Post | `Response::accept_post` parses repeated bounded `Accept-Post` response fields through the shared `AcceptPost` type into ordered `MediaType` values while preserving raw headers on parse errors | No POST routing, payload decoding, media-type negotiation, method selection, retry, or automatic follow-up request | | Conditional requests | `if_none_match`, `if_match`, `if_modified_since`, and `if_unmodified_since` emit bounded HTTP/1.1 validators; the date helpers validate and emit through the shared protocol `IfModifiedSince` and `IfUnmodifiedSince` types; `Response::is_not_modified`, `is_precondition_failed`, typed bounded `etag`, `delta_base`, and `last_modified` expose `304`/`412` and delta-base metadata while preserving raw headers | One ETag validator per helper call, `If-Range` is range-scoped, no cache storage, no cached-entity lookup, no automatic revalidation, no delta application, and no cache-control engine | diff --git a/crates/rttp-protocol/src/range.rs b/crates/rttp-protocol/src/range.rs index 956901bd..6fe1f4d9 100644 --- a/crates/rttp-protocol/src/range.rs +++ b/crates/rttp-protocol/src/range.rs @@ -156,9 +156,8 @@ impl ContentRange { )); } - validate_value(value, MAX_CONTENT_RANGE_VALUE_BYTES, "Content-Range") - .map_err(ContentRangeParseError::new)?; - let value = value.trim(); + validate_content_range_value(value).map_err(ContentRangeParseError::new)?; + let value = trim_http_ows(value); let Some((unit, range)) = value.split_once(' ') else { return Err(ContentRangeParseError::new( "invalid Content-Range header value", @@ -259,6 +258,23 @@ fn validate_value(value: &str, maximum_length: usize, name: &str) -> Result<(), Ok(()) } +fn validate_content_range_value(value: &str) -> Result<(), String> { + if value.len() > MAX_CONTENT_RANGE_VALUE_BYTES { + return Err("Content-Range header value is too large".to_string()); + } + if value + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err("invalid Content-Range header value".to_string()); + } + Ok(()) +} + +fn trim_http_ows(value: &str) -> &str { + value.trim_matches([' ', '\t']) +} + fn parse_range_member(value: &str) -> Result { if value.is_empty() { return Err(RangeParseError::new("invalid Range member")); diff --git a/crates/rttp-protocol/tests/range.rs b/crates/rttp-protocol/tests/range.rs index ce49a80b..2c6dc1db 100644 --- a/crates/rttp-protocol/tests/range.rs +++ b/crates/rttp-protocol/tests/range.rs @@ -59,6 +59,30 @@ fn content_range_parses_satisfied_unknown_and_unsatisfied_forms() { assert_eq!("bytes 0-499/1234", satisfied.header_value()); } +#[test] +fn content_range_trims_http_ows_and_formats_canonical_values() { + let content_range = + ContentRange::parse("\tBYTES 0003-0006/0010 ").expect("OWS-padded content range"); + let unsatisfied = ContentRange::parse(" bytes */0010\t").expect("OWS-padded unsatisfied range"); + + assert_eq!( + ContentRange::Bytes { + start: 3, + end: 6, + complete_length: Some(10), + }, + content_range + ); + assert_eq!("bytes 3-6/10", content_range.header_value()); + assert_eq!( + ContentRange::Unsatisfied { + complete_length: 10, + }, + unsatisfied + ); + assert_eq!("bytes */10", unsatisfied.header_value()); +} + #[test] fn content_range_rejects_repeated_field_values() { assert!(ContentRange::parse_values(["bytes 0-1/4", "bytes 2-3/4"]).is_err()); @@ -82,6 +106,12 @@ fn range_and_content_range_reject_invalid_syntax_controls_and_overflow() { "bytes 0-2/2", "bytes */*", "bytes */18446744073709551616", + "bytes\t0-1/2", + "bytes 0-1 /\t2", + "bytes 0-1/2, bytes 2-3/4", + "bytes 0-1/2/", + "bytes 0-1", + "bytes 0-1/", "bytes 0-1/2\n", ] { assert!( diff --git a/crates/rttp-server/README.md b/crates/rttp-server/README.md index 5628c1de..271d11f5 100644 --- a/crates/rttp-server/README.md +++ b/crates/rttp-server/README.md @@ -650,7 +650,12 @@ These helpers only declare and inspect metadata. RTTP does not generate `Range` requests, create a partial response engine, serve files, resume downloads, or choose redirect, retry, or status-policy behavior. Resolved range sets are serialized by `HttpResponse::partial_content` and -`HttpResponse::partial_content_ranges`. +`HttpResponse::partial_content_ranges`. `HttpResponse::content_range()` parses +attached singleton `Content-Range` fields into `HttpContentRange`, accepting +satisfied byte ranges and `bytes */length` unsatisfied ranges, canonicalizing +typed formatting, and rejecting duplicates, unsupported units, malformed +separators, control bytes, overflow, inverted ranges, and ranges beyond a known +complete length while leaving raw fields available through `HttpResponse::header`. ## Accept-Patch response metadata