Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,19 @@ webpki-roots = "0.26"
lru = "0.12"
rand = "0.8"
anyhow = "1.0"
base64 = "0.22"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] }
chrono = "0.4"
dirs = "6.0.0"
hyper-rustls = "0.27.7"
tower-service = "0.3"
tls-parser = "0.12.2"
camino = "1.1.11"
filetime = "0.2"
ctrlc = "3.4"
percent-encoding = "2.3"
ipnet = "2"
url = "2.5"
v8 = "129"
serde = { version = "1.0", features = ["derive"] }
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Or download a pre-built binary from the [releases page](https://github.com/coder
- 🌐 **HTTP/HTTPS interception** - Transparent proxy with TLS certificate injection
- 🛡️ **DNS exfiltration protection** - Prevents data leakage through DNS queries
- 🔧 **Multiple evaluation approaches** - JS expressions or custom programs
- 🏢 **Upstream proxy support** - Chain httpjail's egress through a corporate proxy
- 🖥️ **Cross-platform** - Native support for Linux and macOS

## Quick Start
Expand Down Expand Up @@ -61,8 +62,30 @@ httpjail --server --js "true"

# Run Docker containers with network isolation (Linux only)
httpjail --js "r.host === 'api.github.com'" --docker-run -- --rm alpine:latest wget -qO- https://api.github.com

# Route httpjail's own egress through an upstream (corporate) proxy
HTTPS_PROXY=http://proxy.corp:3128 httpjail --js "true" -- curl https://api.github.com
# Basic authentication is supported: http://user:pass@proxy.corp:3128
```

### Upstream (corporate) proxy

When httpjail itself runs in an environment with no direct internet access, set
the `HTTP_PROXY` and/or `HTTPS_PROXY` environment variables to route httpjail's
outbound requests through an upstream proxy. Rule evaluation still happens
locally on the intercepted traffic; only the re-originated request is forwarded
through the proxy.

- `http://host:port` and bare `host:port` (http assumed) forms are accepted.
Reaching the proxy itself over TLS (`https://proxy`) is not supported.
- Basic authentication is supported via `http://user:pass@host:port`.
- `NO_PROXY` lists destinations to contact directly, with curl-compatible
matching (domains and subdomains, `*`, IPv4/IPv6 CIDR).
- HTTPS destinations are reached via a `CONNECT` tunnel through the proxy, while
plain HTTP destinations are forwarded in absolute-form.
- In weak mode, httpjail overwrites proxy env vars inside the jailed process to
point sandboxed processes at httpjail itself.

## Documentation

Docs are stored in the `docs/` directory and served
Expand All @@ -82,6 +105,7 @@ Table of Contents:
- [TLS Interception](https://coder.github.io/httpjail/advanced/tls-interception.html)
- [DNS Exfiltration](https://coder.github.io/httpjail/advanced/dns-exfiltration.html)
- [Server Mode](https://coder.github.io/httpjail/advanced/server-mode.html)
- [Upstream Proxy](https://coder.github.io/httpjail/advanced/upstream-proxy.html)

## License

Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [TLS Interception](./advanced/tls-interception.md)
- [DNS Exfiltration](./advanced/dns-exfiltration.md)
- [Server Mode](./advanced/server-mode.md)
- [Upstream Proxy](./advanced/upstream-proxy.md)

---

Expand Down
119 changes: 119 additions & 0 deletions docs/advanced/upstream-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Upstream Proxy

By default httpjail contacts destination servers directly. When httpjail itself
runs in an environment that has no direct internet access — for example behind a
corporate proxy — you can route httpjail's own outbound requests through an
upstream proxy with the `HTTP_PROXY` and/or `HTTPS_PROXY` environment variables.

Rule evaluation still happens locally on the intercepted traffic. Only the
request that httpjail re-originates towards the real destination is forwarded
through the upstream proxy.

```bash
# Route httpjail's HTTPS egress through a corporate proxy
HTTPS_PROXY=http://proxy.corp:3128 httpjail --js "true" -- curl https://api.github.com

# Route both HTTP and HTTPS egress through the same proxy
HTTP_PROXY=http://proxy.corp:3128 HTTPS_PROXY=http://proxy.corp:3128 \
httpjail --js "true" -- ./my-app

# With Basic authentication
HTTPS_PROXY=http://user:pass@proxy.corp:3128 httpjail --js "true" -- ./my-app
```

## Accepted formats

| Form | Example | Notes |
| --- | --- | --- |
| `http://host:port` | `http://proxy.corp:3128` | Plain HTTP proxy |
| `host:port` | `proxy.corp:3128` | Bare authority, `http` scheme assumed |
| With credentials | `http://user:pass@proxy.corp:3128` | Sends `Proxy-Authorization: Basic ...` |

`HTTP_PROXY` is used for `http://` destinations. `HTTPS_PROXY` is used for
`https://` destinations. Credentials are never written to the logs.

Note that the value describes how httpjail reaches the proxy, not the scheme of
the destinations it covers: `HTTPS_PROXY=http://proxy.corp:3128` is the normal
configuration and sends HTTPS destinations through a plain HTTP proxy. Reaching
the proxy itself over TLS (an `https://` proxy URL) is not supported and is
rejected with an error.

## Bypassing the proxy with `NO_PROXY`

`NO_PROXY` lists destinations that httpjail contacts directly instead of through
the upstream proxy. The syntax follows curl 8.14.1.

| Form | Example | Notes |
| --- | --- | --- |
| Domain | `example.com` | Matches the domain and its subdomains, not `notexample.com` |
| Leading dot | `.example.com` | One leading dot is ignored; same as above |
| Wildcard | `*` | Only when the whole list is exactly `*`: no proxy is used at all |
| IPv4 CIDR | `192.168.0.0/16` | Only for destinations written as an IP literal |
| IPv6 CIDR | `2001:db8::/32` | Only for destinations written as an IP literal |
| Address | `192.168.1.1` | Without a prefix length, an exact address match. Also matches host names ending in it, since an entry is read as a domain whenever the destination is a host name |

Entries are separated by commas, matched case-insensitively, and one trailing dot
is ignored on both the entry and the destination. Rule evaluation is unaffected:
a bypassed request is still checked against your rules, it just reaches the
destination directly.

Not supported:

- **Ports in entries.** `example.com:8080` matches nothing, because entries are
compared against the destination's host name only. It does not fall back to
matching `example.com`.
- **Globs, schemes and paths.** `*.example.com` and `https://example.com` match
nothing. Use `example.com`, which already covers subdomains.
- **Matching resolved addresses.** A CIDR entry applies only when the destination
itself is an IP literal; host names are never resolved to check them.

A mistyped CIDR (`10.0.0.0/8x`, `10.0.0.0/33`) is reported as a configuration
error at startup rather than silently ignored. Entries that simply cannot match,
such as the unsupported forms above, are ignored and logged at debug level.

`NO_PROXY` is only read when at least one of `HTTP_PROXY` / `HTTPS_PROXY` is set.

### Differences from curl

| Item | curl 8.14.1 | httpjail |
| --- | --- | --- |
| Variable precedence | `no_proxy`, then `NO_PROXY` | `NO_PROXY`, then `no_proxy`, consistent with the other proxy variables |
| Whitespace-only value | Counts as set, so the other spelling is not consulted | Counts as unset, falling through to the other spelling |
| Whitespace between entries | Stops parsing the list, silently discarding the rest | Separates entries, like a comma |
| `/0` prefix | Treated as an exact address match | Matches the whole address family |
| Mistyped CIDR | Silently ignored | Configuration error at startup |
| IPv6 prefix not a multiple of 8 | Inverted before curl 8.17.0 | Matches correctly, as curl 8.17.0 and later do |

## How it works

- **HTTPS destinations** are reached by issuing a `CONNECT` to the upstream
proxy to obtain a raw TCP tunnel; httpjail then performs the destination TLS
handshake over that tunnel. TLS is validated against Mozilla's webpki roots
plus the httpjail CA, exactly as for a direct connection.
- **Plain HTTP destinations** are forwarded to the proxy in absolute-form, with
the `Proxy-Authorization` header attached when credentials are configured. The
header is never sent to a destination that `NO_PROXY` bypasses, nor to an HTTPS
destination, whose request travels inside the tunnel to the origin server.
- Only connection setup (the TCP connect and the `CONNECT` exchange) is bounded
by a timeout. The established tunnel carries no timeout, so long-running
connections such as WebSocket and gRPC keep working.

## Relationship to jailed process proxy variables

The proxy environment variables configure httpjail's own egress. In weak mode,
httpjail overwrites `HTTP_PROXY` and `HTTPS_PROXY` inside the jailed process to
point sandboxed processes at httpjail itself.

```
[ jailed process ] --> [ httpjail ] --HTTP_PROXY/HTTPS_PROXY--> [ corporate proxy ] --> internet
```

The jailed process talks to httpjail; the proxy env vars only affect the hop
from httpjail to the outside world.

None of the parent's proxy variables are passed on to the jailed process, in any
mode. `HTTP_PROXY`, `HTTPS_PROXY` and `ALL_PROXY` are removed so the process
cannot reach the upstream proxy directly or read its credentials, and `NO_PROXY`
is replaced with the local addresses only. Inheriting `NO_PROXY` would let the
process connect straight to every destination it named, with no rule evaluation
at all. In weak mode httpjail then sets the proxy variables to its own address.
38 changes: 29 additions & 9 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ httpjail's behavior can be configured through command-line options, environment
httpjail follows a simple configuration hierarchy:

1. **Command-line options** - Highest priority, override everything
2. **Environment variables** - Set by httpjail for the jailed process
2. **Environment variables** - Configure httpjail and the jailed process

## Key Configuration Areas

Expand Down Expand Up @@ -72,26 +72,46 @@ httpjail --proc ./rate-limiter.py \

## Environment Variables

### Set by httpjail
### Set for the jailed process

These are automatically set in the jailed process:
These are set in the jailed process where applicable. In weak mode, httpjail
sets proxy variables so applications talk to httpjail. On Linux strong mode,
traffic is redirected transparently without setting proxy variables.

| Variable | Description | Example |
| --------------- | ---------------------------- | ------------------------ |
| `HTTP_PROXY` | HTTP proxy address | `http://127.0.0.1:34567` |
| `HTTPS_PROXY` | HTTPS proxy address | `http://127.0.0.1:34567` |
| `SSL_CERT_FILE` | CA certificate path | `/tmp/httpjail-ca.pem` |
| `SSL_CERT_DIR` | CA certificate directory | `/tmp/httpjail-certs/` |
| `NO_PROXY` | Bypass proxy for these hosts | `localhost,127.0.0.1` |
| `NO_PROXY` | Bypass proxy for these hosts | `localhost,127.0.0.1,::1` |

### Controlling httpjail
The parent's proxy variables are never inherited by the jailed process:
`HTTP_PROXY`, `HTTPS_PROXY` and `ALL_PROXY` are removed (in both spellings) so
the process cannot reach the upstream proxy directly or read its credentials, and
`NO_PROXY` is set to the local addresses only rather than merged with the
parent's value, which would let the process bypass httpjail.

### Consumed by httpjail

These affect httpjail's behavior:

| Variable | Description | Example |
| ------------------ | -------------------------- | -------------------------------- |
| `RUST_LOG` | Logging level | `debug`, `info`, `warn`, `error` |
| `HTTPJAIL_CA_CERT` | Custom CA certificate path | `/etc/pki/custom-ca.pem` |
| Variable | Description | Example |
| ------------------------ | -------------------------------------- | -------------------------------- |
| `RUST_LOG` | Logging level | `debug`, `info`, `warn`, `error` |
| `HTTPJAIL_CA_CERT` | Custom CA certificate path | `/etc/pki/custom-ca.pem` |
| `HTTP_PROXY` | Upstream proxy for httpjail HTTP egress | `http://proxy.corp:3128` |
| `HTTPS_PROXY` | Upstream proxy for httpjail HTTPS egress | `http://proxy.corp:3128` |
| `NO_PROXY` | Destinations httpjail contacts directly | `internal.corp,10.0.0.0/8` |

`NO_PROXY` appears in both tables and means two different things. In the table
above it is what httpjail *sets* for the jailed process, so that the process does
not send its localhost traffic to httpjail. Here it is what httpjail *reads* for
its own egress, to decide which destinations to reach without the upstream proxy.
The value you set is used only for httpjail's own egress; it is not passed on to
the jailed process, which would let that process bypass httpjail entirely.

See [Upstream Proxy](../advanced/upstream-proxy.md) for details.

## Platform-Specific Configuration

Expand Down
5 changes: 5 additions & 0 deletions src/jail/linux/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,11 @@ impl DockerLinux {
let mut cmd = Command::new("docker");
cmd.arg("run");

// The parent process may use proxy env vars for httpjail's own egress.
// Do not leak those credentials or settings into the Docker CLI process;
// Docker network isolation routes container traffic through httpjail.
crate::jail::remove_parent_proxy_env(&mut cmd);

// Use our isolated Docker network
cmd.args(["--network", &network_name]);

Expand Down
5 changes: 5 additions & 0 deletions src/jail/linux/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,11 @@ impl Jail for LinuxJail {
cmd.env(key, value);
}

// The parent process may use proxy env vars for httpjail's own egress.
// Do not leak those credentials or settings into the jailed command;
// native Linux isolation redirects traffic transparently.
crate::jail::remove_parent_proxy_env(&mut cmd);

// Preserve SUDO environment variables for consistency with macOS
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
cmd.env("SUDO_USER", sudo_user);
Expand Down
52 changes: 52 additions & 0 deletions src/jail/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use anyhow::Result;
use rand::Rng;
use std::process::Command;

pub mod weak;

Expand All @@ -9,6 +10,39 @@ pub mod linux;
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub mod managed;

/// Proxy environment variables that configure httpjail's *own* egress and must
/// never reach a jailed process.
///
/// `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` can carry the upstream proxy's
/// credentials, and a jailed process that honored them would talk to that proxy
/// instead of to httpjail. `NO_PROXY` is worse: it names destinations to reach
/// directly, so an inherited value lets the process skip httpjail entirely and
/// escape rule evaluation.
///
/// Both spellings of each name are listed. Tools differ in which they read —
/// curl prefers the lowercase one — so removing only the uppercase spelling
/// would leave the hole open.
pub const PARENT_PROXY_ENV_VARS: [&str; 8] = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"NO_PROXY",
"no_proxy",
];

/// Drop every variable in [`PARENT_PROXY_ENV_VARS`] from a child's environment.
///
/// Callers that need a proxy variable set for the child (weak mode points the
/// process at httpjail) assign it *after* calling this.
pub fn remove_parent_proxy_env(cmd: &mut Command) {
for key in PARENT_PROXY_ENV_VARS {
cmd.env_remove(key);
}
}

/// Trait for platform-specific jail implementations
#[allow(dead_code)]
pub trait Jail: Send + Sync {
Expand Down Expand Up @@ -213,4 +247,22 @@ mod tests {
// We generated 1000 unique IDs
assert_eq!(ids.len(), 1000);
}

/// Every proxy variable must be stripped in both spellings. Missing the
/// lowercase one is the dangerous case: curl reads it first, so a leftover
/// `no_proxy` would let a jailed process skip httpjail.
#[test]
fn parent_proxy_env_covers_both_spellings() {
for name in ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] {
assert!(
PARENT_PROXY_ENV_VARS.contains(&name),
"{name} is not stripped from jailed processes"
);
let lower = name.to_ascii_lowercase();
assert!(
PARENT_PROXY_ENV_VARS.contains(&lower.as_str()),
"{lower} is not stripped from jailed processes"
);
}
}
}
Loading