@@ -715,9 +715,9 @@ impl DomainFronter {
715715 /// by relay() already (we skip cache for it).
716716 /// 2. Probe with `Range: bytes=0-<chunk-1>`.
717717 /// 3. 200 back (origin doesn't support ranges) → return as-is.
718- /// 4. 206 back → parse Content-Range total. If the body fits in
719- /// the first probe (total <= chunk or body >= total), rewrite
720- /// the 206 to a 200 so the client — which never asked for a
718+ /// 4. 206 back → parse Content-Range total. If Content-Range says
719+ /// the entity fits in the first probe, rewrite the 206 to a 200
720+ /// so the client — which never asked for a
721721 /// range — doesn't choke on a stray Partial Content. (x.com
722722 /// and Cloudflare turnstile in particular reject unsolicited
723723 /// 206 on XHR/fetch.)
@@ -1481,10 +1481,26 @@ fn validate_probe_range(
14811481 return None ;
14821482 }
14831483 let range = parse_content_range ( headers) ?;
1484- if range. start != 0 || range. end > requested_end || ! content_range_matches_body ( range , body . len ( ) ) {
1484+ if range. start != 0 || range. end > requested_end {
14851485 return None ;
14861486 }
1487- Some ( range)
1487+ if content_range_matches_body ( range, body. len ( ) )
1488+ || probe_range_covers_complete_entity ( range, requested_end)
1489+ {
1490+ return Some ( range) ;
1491+ }
1492+ None
1493+ }
1494+
1495+ fn probe_range_covers_complete_entity ( range : ContentRange , requested_end : u64 ) -> bool {
1496+ // Apps Script may decode a gzip body while preserving the origin's
1497+ // compressed Content-Range. For the synthetic first probe only, a
1498+ // 0..total-1 range within the requested chunk is enough to prove we
1499+ // already have the complete entity; later chunks still require exact
1500+ // Content-Range/body length validation in extract_exact_range_body().
1501+ range. start == 0
1502+ && range. end . saturating_add ( 1 ) >= range. total
1503+ && range. total <= requested_end. saturating_add ( 1 )
14881504}
14891505
14901506fn checked_stitched_range_capacity ( total : u64 ) -> Option < usize > {
@@ -2180,6 +2196,11 @@ fn looks_like_quota_error(msg: &str) -> bool {
21802196 || lower. contains ( "rate limit" )
21812197 || lower. contains ( "too many times" )
21822198 || lower. contains ( "service invoked" )
2199+ || lower. contains ( "bandwidth" )
2200+ || lower. contains ( "bandbreitenkontingent" )
2201+ || lower. contains ( "datenübertragungsrate" )
2202+ || lower. contains ( "transfer rate" )
2203+ || lower. contains ( "limit exceeded" )
21832204}
21842205
21852206fn mask_script_id ( id : & str ) -> String {
@@ -2516,6 +2537,59 @@ mod tests {
25162537 assert_eq ! ( parse_content_range_total( & headers) , None ) ;
25172538 }
25182539
2540+ #[ test]
2541+ fn validate_probe_range_accepts_decoded_full_entity_body_mismatch ( ) {
2542+ let mut raw = b"HTTP/1.1 206 Partial Content\r \n \
2543+ Content-Range: bytes 0-11247/11248\r \n \
2544+ Content-Type: text/javascript\r \n \
2545+ Vary: Accept-Encoding\r \n \
2546+ Content-Length: 45812\r \n \r \n "
2547+ . to_vec ( ) ;
2548+ raw. extend ( std:: iter:: repeat ( b'x' ) . take ( 45_812 ) ) ;
2549+
2550+ let ( status, headers, body) = split_response ( & raw ) . unwrap ( ) ;
2551+ assert_eq ! (
2552+ validate_probe_range( status, & headers, body, RANGE_PARALLEL_CHUNK_BYTES - 1 ) ,
2553+ Some ( ContentRange {
2554+ start: 0 ,
2555+ end: 11_247 ,
2556+ total: 11_248 ,
2557+ } ) ,
2558+ ) ;
2559+
2560+ let rewritten = rewrite_206_to_200 ( & raw ) ;
2561+ let ( status, headers, body) = split_response ( & rewritten) . unwrap ( ) ;
2562+ assert_eq ! ( status, 200 ) ;
2563+ assert_eq ! ( body. len( ) , 45_812 ) ;
2564+ assert ! ( !headers
2565+ . iter( )
2566+ . any( |( k, _) | k. eq_ignore_ascii_case( "content-range" ) ) ) ;
2567+ assert_eq ! (
2568+ headers
2569+ . iter( )
2570+ . find( |( k, _) | k. eq_ignore_ascii_case( "content-length" ) )
2571+ . map( |( _, v) | v. as_str( ) ) ,
2572+ Some ( "45812" ) ,
2573+ ) ;
2574+ }
2575+
2576+ #[ test]
2577+ fn validate_probe_range_rejects_missing_content_range ( ) {
2578+ assert ! ( validate_probe_range( 206 , & [ ] , b"hello" , 4 ) . is_none( ) ) ;
2579+ }
2580+
2581+ #[ test]
2582+ fn validate_probe_range_rejects_nonzero_start ( ) {
2583+ let headers = vec ! [ ( "Content-Range" . to_string( ) , "bytes 1-4/20" . to_string( ) ) ] ;
2584+ assert ! ( validate_probe_range( 206 , & headers, b"hell" , 4 ) . is_none( ) ) ;
2585+ }
2586+
2587+ #[ test]
2588+ fn validate_probe_range_rejects_end_past_requested_end ( ) {
2589+ let headers = vec ! [ ( "Content-Range" . to_string( ) , "bytes 0-5/20" . to_string( ) ) ] ;
2590+ assert ! ( validate_probe_range( 206 , & headers, b"hello!" , 4 ) . is_none( ) ) ;
2591+ }
2592+
25192593 #[ test]
25202594 fn validate_probe_range_rejects_body_length_mismatch ( ) {
25212595 let headers = vec ! [ ( "Content-Range" . to_string( ) , "bytes 0-4/20" . to_string( ) ) ] ;
@@ -2532,6 +2606,16 @@ mod tests {
25322606 assert_eq ! ( checked_stitched_range_capacity( u64 :: MAX ) , None ) ;
25332607 }
25342608
2609+ #[ test]
2610+ fn extract_exact_range_body_rejects_body_length_mismatch ( ) {
2611+ let raw = b"HTTP/1.1 206 Partial Content\r \n \
2612+ Content-Range: bytes 5-9/20\r \n \
2613+ Content-Length: 3\r \n \r \n \
2614+ hey";
2615+ let err = extract_exact_range_body ( raw, 5 , 9 , 20 ) . unwrap_err ( ) ;
2616+ assert_eq ! ( err, "Content-Range/body length mismatch" ) ;
2617+ }
2618+
25352619 #[ test]
25362620 fn extract_exact_range_body_rejects_mismatched_content_range ( ) {
25372621 let raw = b"HTTP/1.1 206 Partial Content\r \n \
@@ -2564,6 +2648,9 @@ hello";
25642648 assert ! ( !should_blacklist( 200 , "" ) ) ;
25652649 assert ! ( !should_blacklist( 502 , "bad gateway" ) ) ;
25662650 assert ! ( looks_like_quota_error( "Exception: Service invoked too many times per day" ) ) ;
2651+ assert ! ( looks_like_quota_error(
2652+ "Exception: Bandbreitenkontingent überschritten: https://example.com. Verringern Sie die Datenübertragungsrate."
2653+ ) ) ;
25672654 assert ! ( !looks_like_quota_error( "bad url" ) ) ;
25682655 }
25692656
0 commit comments