Skip to content

Commit 915dba7

Browse files
freeinternet865freeinternet865
andauthored
Fix Apps Script decoded range probe handling (#337)
Accept a synthetic first range probe when Content-Range proves the whole entity was returned, even if Apps Script decoded the body and left compressed Content-Range metadata intact. The response is then rewritten to HTTP 200 with Content-Range removed and Content-Length based on the decoded body, avoiding an unnecessary fallback full GET. Keep strict validation for real client Range requests and later chunks. Also recognize localized Apps Script bandwidth quota errors. Co-authored-by: freeinternet865 <free@internet865.com>
1 parent 567937f commit 915dba7

1 file changed

Lines changed: 92 additions & 5 deletions

File tree

src/domain_fronter.rs

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

14901506
fn 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

21852206
fn 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

Comments
 (0)