Skip to content

Commit 5737d78

Browse files
committed
Consider Content-Length
1 parent 49d0974 commit 5737d78

5 files changed

Lines changed: 310 additions & 114 deletions

File tree

docs/guide/rule-engines/javascript.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Your JavaScript can return:
4848
- **Boolean**: `true` to allow, `false` to deny
4949
- **Object with message**: `{allow: false, deny_message: "Custom error"}`
5050
- **Just a message**: `{deny_message: "Blocked"}` (implies deny)
51-
- **Object with byte limit**: `{allow: {max_tx_bytes: 1024}}` (allow but limit request upload to N bytes)
51+
- **Object with byte limit**: `{allow: {max_tx_bytes: 1024}}` (allow but limit request upload to N bytes, returns 413 if Content-Length exceeds limit)
5252

5353
```javascript
5454
// Simple boolean
@@ -147,6 +147,10 @@ uploadHosts.includes(r.host)
147147
: r.host.endsWith('.example.com')
148148
```
149149

150+
**Behavior:**
151+
- If the request includes a `Content-Length` header that exceeds the limit, the client receives a `413 Payload Too Large` error immediately without contacting the upstream server
152+
- If no `Content-Length` header is present (e.g., chunked encoding), the request body is truncated at the limit as it streams to the upstream server
153+
150154
## When to Use
151155

152156
Best for:

docs/guide/rule-engines/line-processor.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Your processor must respond with one line per request:
3232
- **Boolean strings**: `"true"` (allow) or `"false"` (deny)
3333
- **JSON object**: `{"allow": false, "deny_message": "Blocked by policy"}`
3434
- **JSON with message only**: `{"deny_message": "Blocked"}` (implies deny)
35-
- **JSON with byte limit**: `{"allow": {"max_tx_bytes": 1024}}` (allow but limit request upload to N bytes)
35+
- **JSON with byte limit**: `{"allow": {"max_tx_bytes": 1024}}` (allow but limit request upload to N bytes, returns 413 if Content-Length exceeds limit)
3636
- **Any other text**: Treated as deny with that text as the message (e.g., `"Access denied"` becomes a deny with message "Access denied")
3737

3838
## Command Line Usage
@@ -66,6 +66,8 @@ for line in sys.stdin:
6666
print("true")
6767
elif req['host'] in upload_hosts:
6868
# Limit upload endpoints to 1KB requests
69+
# Returns 413 error if Content-Length exceeds limit
70+
# Truncates body if no Content-Length header
6971
response = {"allow": {"max_tx_bytes": 1024}}
7072
print(json.dumps(response))
7173
else:

src/proxy.rs

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,24 @@ pub fn create_connect_403_response_with_context(context: Option<String>) -> Vec<
5858
// Use the limited body module for request size limiting
5959
use crate::limited_body::LimitedBody;
6060

61+
/// Result of applying byte limit check to a request
62+
pub enum ByteLimitResult {
63+
/// Request is within limit (or no Content-Length), proceed with wrapped body
64+
WithinLimit(Box<Request<BoxBody<Bytes, HyperError>>>),
65+
/// Request exceeds limit based on Content-Length header
66+
ExceedsLimit { content_length: u64, max_bytes: u64 },
67+
}
68+
6169
/// Applies a byte limit to an outgoing request by wrapping its body.
6270
///
63-
/// This function calculates the size of the request headers (request line + headers)
64-
/// and subtracts it from the total `max_bytes` limit to determine how many bytes
65-
/// can be used for the body. The request body is then wrapped in a `LimitedBody`
66-
/// that enforces this limit.
71+
/// This function first checks the Content-Length header as a heuristic to detect
72+
/// requests that would exceed the limit. If Content-Length indicates the request
73+
/// is oversized, it returns `ByteLimitResult::ExceedsLimit` so the caller can
74+
/// reject the request with a 413 error. This prevents the request from hanging
75+
/// and provides immediate feedback to the client.
76+
///
77+
/// If no Content-Length is present or the request is within limits, the body is
78+
/// wrapped in a `LimitedBody` that enforces truncation as a fallback.
6779
///
6880
/// # Arguments
6981
///
@@ -72,11 +84,11 @@ use crate::limited_body::LimitedBody;
7284
///
7385
/// # Returns
7486
///
75-
/// A new request with the body wrapped in a `LimitedBody` enforcing the limit.
87+
/// `ByteLimitResult` indicating whether to proceed or reject the request
7688
pub fn apply_request_byte_limit(
7789
req: Request<BoxBody<Bytes, HyperError>>,
7890
max_bytes: u64,
79-
) -> Request<BoxBody<Bytes, HyperError>> {
91+
) -> ByteLimitResult {
8092
let (parts, body) = req.into_parts();
8193

8294
// Calculate request header size to subtract from max_tx_bytes
@@ -99,6 +111,30 @@ pub fn apply_request_byte_limit(
99111
// Final "\r\n" separator between headers and body
100112
let total_header_size = request_line_size + headers_size + 2;
101113

114+
// Check Content-Length as a heuristic to reject oversized requests early
115+
// This both provides convenience (immediate error) and prevents hangs
116+
if let Some(content_length) = parts
117+
.headers
118+
.get(hyper::header::CONTENT_LENGTH)
119+
.and_then(|v| v.to_str().ok())
120+
.and_then(|s| s.parse::<u64>().ok())
121+
{
122+
let total_size = total_header_size + content_length;
123+
if total_size > max_bytes {
124+
debug!(
125+
content_length = content_length,
126+
header_size = total_header_size,
127+
total_size = total_size,
128+
max_bytes = max_bytes,
129+
"Request exceeds byte limit based on Content-Length"
130+
);
131+
return ByteLimitResult::ExceedsLimit {
132+
content_length,
133+
max_bytes,
134+
};
135+
}
136+
}
137+
102138
// Subtract header size from total limit to get body limit
103139
let body_limit = max_bytes.saturating_sub(total_header_size);
104140

@@ -110,7 +146,10 @@ pub fn apply_request_byte_limit(
110146
);
111147

112148
let limited_body = LimitedBody::new(body, body_limit);
113-
Request::from_parts(parts, BodyExt::boxed(limited_body))
149+
ByteLimitResult::WithinLimit(Box::new(Request::from_parts(
150+
parts,
151+
BodyExt::boxed(limited_body),
152+
)))
114153
}
115154

116155
// Shared HTTP/HTTPS client for upstream requests
@@ -547,7 +586,23 @@ async fn proxy_request(
547586

548587
// Apply byte limit to outgoing request if specified, converting to BoxBody
549588
let new_req = if let Some(max_bytes) = max_tx_bytes {
550-
apply_request_byte_limit(prepared_req, max_bytes)
589+
match apply_request_byte_limit(prepared_req, max_bytes) {
590+
ByteLimitResult::WithinLimit(req) => *req,
591+
ByteLimitResult::ExceedsLimit {
592+
content_length,
593+
max_bytes,
594+
} => {
595+
// Request exceeds limit based on Content-Length - reject immediately
596+
let message = format!(
597+
"Request body size ({} bytes) exceeds maximum allowed ({} bytes)",
598+
content_length, max_bytes
599+
);
600+
return Ok(create_error_response(
601+
StatusCode::PAYLOAD_TOO_LARGE,
602+
&message,
603+
)?);
604+
}
605+
}
551606
} else {
552607
// Convert to BoxBody for consistent types
553608
let (parts, body) = prepared_req.into_parts();

src/proxy_tls.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,23 @@ async fn proxy_https_request(
530530

531531
// Apply byte limit to outgoing request if specified, converting to BoxBody
532532
let new_req = if let Some(max_bytes) = max_tx_bytes {
533-
apply_request_byte_limit(prepared_req, max_bytes)
533+
match apply_request_byte_limit(prepared_req, max_bytes) {
534+
crate::proxy::ByteLimitResult::WithinLimit(req) => *req,
535+
crate::proxy::ByteLimitResult::ExceedsLimit {
536+
content_length,
537+
max_bytes,
538+
} => {
539+
// Request exceeds limit based on Content-Length - reject immediately
540+
let message = format!(
541+
"Request body size ({} bytes) exceeds maximum allowed ({} bytes)",
542+
content_length, max_bytes
543+
);
544+
return Ok(crate::proxy::create_error_response(
545+
StatusCode::PAYLOAD_TOO_LARGE,
546+
&message,
547+
)?);
548+
}
549+
}
534550
} else {
535551
// Convert to BoxBody for consistent types
536552
let (parts, body) = prepared_req.into_parts();

0 commit comments

Comments
 (0)