Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/guide/rule-engines/javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Your JavaScript can return:
- **Boolean**: `true` to allow, `false` to deny
- **Object with message**: `{allow: false, deny_message: "Custom error"}`
- **Just a message**: `{deny_message: "Blocked"}` (implies deny)
- **Object with byte limit**: `{allow: {max_tx_bytes: 1024}}` (allow but limit request upload to N bytes)

```javascript
// Simple boolean
Expand All @@ -59,6 +60,9 @@ false // Deny

// Conditional with message
r.host === 'facebook.com' ? {deny_message: 'Social media blocked'} : true

// Limit request upload size to 1KB (headers + body)
({allow: {max_tx_bytes: 1024}})
```

## Common Patterns
Expand Down Expand Up @@ -132,6 +136,17 @@ const requestString = `${r.method} ${r.host}${r.path}`;
patterns.some(pattern => pattern.test(requestString))
```

### Upload Size Limiting

```javascript
// Limit upload requests to 1KB (including headers and body)
const uploadHosts = ['uploads.example.com', 'upload.github.com'];

uploadHosts.includes(r.host)
? {allow: {max_tx_bytes: 1024}}
: r.host.endsWith('.example.com')
```

## When to Use

Best for:
Expand Down
6 changes: 6 additions & 0 deletions docs/guide/rule-engines/line-processor.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Your processor must respond with one line per request:
- **Boolean strings**: `"true"` (allow) or `"false"` (deny)
- **JSON object**: `{"allow": false, "deny_message": "Blocked by policy"}`
- **JSON with message only**: `{"deny_message": "Blocked"}` (implies deny)
- **JSON with byte limit**: `{"allow": {"max_tx_bytes": 1024}}` (allow but limit request upload to N bytes)
- **Any other text**: Treated as deny with that text as the message (e.g., `"Access denied"` becomes a deny with message "Access denied")

## Command Line Usage
Expand All @@ -56,12 +57,17 @@ httpjail --proc "./filter.sh --strict" -- your-command
import sys, json

allowed_hosts = {'github.com', 'api.github.com'}
upload_hosts = {'uploads.example.com'}

for line in sys.stdin:
try:
req = json.loads(line)
if req['host'] in allowed_hosts:
print("true")
elif req['host'] in upload_hosts:
# Limit upload endpoints to 1KB requests
response = {"allow": {"max_tx_bytes": 1024}}
print(json.dumps(response))
else:
# Can return JSON for custom messages
response = {"allow": False, "deny_message": f"{req['host']} not allowed"}
Expand Down
142 changes: 138 additions & 4 deletions src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,104 @@ pub fn create_connect_403_response_with_context(context: Option<String>) -> Vec<
response.into_bytes()
}

/// A body wrapper that limits the total bytes transmitted
/// Tracks both header and body bytes, terminating when limit is exceeded
use hyper::body::{Body, Frame, SizeHint};
use std::pin::Pin;
use std::task::{Context, Poll};

pub struct LimitedBody {
inner: BoxBody<Bytes, HyperError>,
bytes_transmitted: u64,
max_bytes: u64,
}

impl LimitedBody {
pub fn new(inner: BoxBody<Bytes, HyperError>, max_bytes: u64, header_size: u64) -> Self {
Self {
inner,
bytes_transmitted: header_size,
max_bytes,
}
}
}

impl Body for LimitedBody {
type Data = Bytes;
type Error = HyperError;

fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
// Check if we've exceeded the limit
if self.bytes_transmitted >= self.max_bytes {
debug!(
"Byte limit exceeded: {} >= {} bytes, terminating connection",
self.bytes_transmitted, self.max_bytes
);
return Poll::Ready(None); // Terminate the stream
}

// Poll the inner body
match Pin::new(&mut self.inner).poll_frame(cx) {
Poll::Ready(Some(Ok(frame))) => {
if let Some(data) = frame.data_ref() {
let frame_size = data.len() as u64;
let new_total = self.bytes_transmitted + frame_size;

if new_total > self.max_bytes {
// This frame would exceed the limit
let bytes_remaining = self.max_bytes - self.bytes_transmitted;
debug!(
"Frame would exceed byte limit: {} + {} > {}, truncating to {} bytes",
self.bytes_transmitted, frame_size, self.max_bytes, bytes_remaining
);

if bytes_remaining > 0 {
// Send partial frame
self.bytes_transmitted = self.max_bytes;
let truncated = data.slice(0..bytes_remaining as usize);
Poll::Ready(Some(Ok(Frame::data(truncated))))
} else {
// No bytes remaining, terminate
Poll::Ready(None)
}
} else {
// Frame fits within limit
self.bytes_transmitted = new_total;
Poll::Ready(Some(Ok(frame)))
}
} else {
// Non-data frame (trailers, etc.), pass through
Poll::Ready(Some(Ok(frame)))
}
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}

fn is_end_stream(&self) -> bool {
self.bytes_transmitted >= self.max_bytes || self.inner.is_end_stream()
}

fn size_hint(&self) -> SizeHint {
let remaining = self.max_bytes.saturating_sub(self.bytes_transmitted);
let inner_hint = self.inner.size_hint();

// Return the minimum of the inner hint and our remaining bytes
let mut hint = SizeHint::new();
if let Some(inner_upper) = inner_hint.upper() {
hint.set_upper(std::cmp::min(inner_upper, remaining));
} else {
hint.set_upper(remaining);
}
hint
}
}

// Shared HTTP/HTTPS client for upstream requests
static HTTPS_CLIENT: OnceLock<
Client<
Expand Down Expand Up @@ -457,8 +555,11 @@ pub async fn handle_http_request(
.await;
match evaluation.action {
Action::Allow => {
debug!("Request allowed: {}", full_url);
match proxy_request(req, &full_url).await {
debug!(
"Request allowed: {} (max_tx_bytes: {:?})",
full_url, evaluation.max_tx_bytes
);
match proxy_request(req, &full_url, evaluation.max_tx_bytes).await {
Ok(resp) => Ok(resp),
Err(e) => {
error!("Proxy error: {}", e);
Expand All @@ -476,12 +577,46 @@ pub async fn handle_http_request(
async fn proxy_request(
req: Request<Incoming>,
full_url: &str,
max_tx_bytes: Option<u64>,
) -> Result<Response<BoxBody<Bytes, HyperError>>> {
// Parse the target URL
let target_uri = full_url.parse::<Uri>()?;

// Prepare request for upstream
let new_req = prepare_upstream_request(req, target_uri);
let mut new_req = prepare_upstream_request(req, target_uri.clone());

// Apply byte limit to outgoing request if specified
if let Some(max_bytes) = max_tx_bytes {
let (parts, body) = new_req.into_parts();

// Calculate request header size
// Request line: "GET /path HTTP/1.1\r\n"
let method_str = parts.method.as_str();
let path_str = parts
.uri
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/");
let request_line_size = format!("{} {} HTTP/1.1\r\n", method_str, path_str).len() as u64;

// Headers: each header is "name: value\r\n"
let headers_size: u64 = parts
.headers
.iter()
.map(|(name, value)| name.as_str().len() as u64 + 2 + value.len() as u64 + 2)
.sum();

// Final "\r\n" separator
let total_header_size = request_line_size + headers_size + 2;

debug!(
"Applying request byte limit: {} bytes (headers: {} bytes)",
max_bytes, total_header_size
);

let limited_body = LimitedBody::new(body, max_bytes, total_header_size);
new_req = Request::from_parts(parts, BodyExt::boxed(limited_body));
Comment thread
ammario marked this conversation as resolved.
Outdated
}

// Use the shared HTTP/HTTPS client
let client = get_client();
Expand Down Expand Up @@ -524,7 +659,6 @@ async fn proxy_request(
.insert(HTTPJAIL_HEADER, HTTPJAIL_HEADER_VALUE.parse().unwrap());

let boxed_body = body.boxed();

Ok(Response::from_parts(parts, boxed_body))
}

Expand Down
40 changes: 37 additions & 3 deletions src/proxy_tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ async fn handle_decrypted_https_request(
match evaluation.action {
Action::Allow => {
debug!("Request allowed: {}", full_url);
match proxy_https_request(req, &host).await {
match proxy_https_request(req, &host, evaluation.max_tx_bytes).await {
Ok(resp) => Ok(resp),
Err(e) => {
error!("Proxy error: {}", e);
Expand All @@ -512,6 +512,7 @@ async fn handle_decrypted_https_request(
async fn proxy_https_request(
req: Request<Incoming>,
host: &str,
max_tx_bytes: Option<u64>,
) -> Result<Response<BoxBody<Bytes, HyperError>>> {
// Build the target URL
let path = req
Expand All @@ -525,7 +526,41 @@ async fn proxy_https_request(
debug!("Forwarding request to: {}", target_url);

// Prepare request for upstream using common function
let new_req = crate::proxy::prepare_upstream_request(req, target_uri);
let mut new_req = crate::proxy::prepare_upstream_request(req, target_uri);

// Apply byte limit to outgoing request if specified
if let Some(max_bytes) = max_tx_bytes {
use http_body_util::BodyExt;
let (parts, body) = new_req.into_parts();

// Calculate request header size
// Request line: "GET /path HTTP/1.1\r\n"
let method_str = parts.method.as_str();
let path_str = parts
.uri
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/");
let request_line_size = format!("{} {} HTTP/1.1\r\n", method_str, path_str).len() as u64;

// Headers: each header is "name: value\r\n"
let headers_size: u64 = parts
.headers
.iter()
.map(|(name, value)| name.as_str().len() as u64 + 2 + value.len() as u64 + 2)
.sum();

// Final "\r\n" separator
let total_header_size = request_line_size + headers_size + 2;

debug!(
"Applying request byte limit: {} bytes (headers: {} bytes)",
max_bytes, total_header_size
);

let limited_body = crate::proxy::LimitedBody::new(body, max_bytes, total_header_size);
new_req = Request::from_parts(parts, BodyExt::boxed(limited_body));
}

// Use the shared HTTP/HTTPS client from proxy module
let client = crate::proxy::get_client();
Expand Down Expand Up @@ -592,7 +627,6 @@ async fn proxy_https_request(
.insert(HTTPJAIL_HEADER, HTTPJAIL_HEADER_VALUE.parse().unwrap());

let boxed_body = body.boxed();

Ok(Response::from_parts(parts, boxed_body))
}

Expand Down
8 changes: 8 additions & 0 deletions src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,35 @@ pub enum Action {
pub struct EvaluationResult {
pub action: Action,
pub context: Option<String>,
pub max_tx_bytes: Option<u64>,
}

impl EvaluationResult {
pub fn allow() -> Self {
Self {
action: Action::Allow,
context: None,
max_tx_bytes: None,
}
}

pub fn deny() -> Self {
Self {
action: Action::Deny,
context: None,
max_tx_bytes: None,
}
}

pub fn with_context(mut self, context: String) -> Self {
self.context = Some(context);
self
}

pub fn with_max_tx_bytes(mut self, max_tx_bytes: u64) -> Self {
self.max_tx_bytes = Some(max_tx_bytes);
self
}
}

/// Trait for rule engines that evaluate HTTP requests.
Expand Down
Loading
Loading