Skip to content
Open
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
38 changes: 28 additions & 10 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,12 @@ loopback always dial directly; add driver-injected host aliases (e.g.
proxy cannot reach the container host. `NO_PROXY` matching is port-aware and
resolution-aware: an entry with a `:port` qualifier only bypasses that port,
and IP/CIDR entries also match hostnames through their validated resolved
addresses, with the direct dial limited to the addresses the entry contains. Only `http://` proxy URLs in explicit
`http://host:port` form are supported — the scheme and port are both
required, and a path, query, or fragment is rejected. Local DNS resolution
addresses, with the direct dial limited to the addresses the entry contains. `http://` and `https://` proxy URLs in explicit
`scheme://host:port` form are supported — the scheme and port are both
required, and a path, query, or fragment is rejected. For an `https://` proxy
the supervisor wraps the connection to the proxy in TLS before the CONNECT
handshake, verifying the proxy certificate against the built-in and system
roots plus the optional operator CA bundle (see below). Local DNS resolution
and SSRF validation still run before the proxied dial, and the CONNECT
target sent to the corporate proxy is a validated resolved address, so the
proxy performs no DNS resolution of its own and the tunnel stays bound to
Expand All @@ -142,16 +145,31 @@ enhancement and out of scope.) The workload child's proxy variables are
unaffected — they are always rewritten to point at the local policy proxy.

The configuration is fail-closed: a setting that is present but invalid — an
empty value, an unsupported or malformed proxy URL, an unreadable auth file,
a malformed credential, or an auth file or `NO_PROXY` list set while no proxy
URL is configured — is fatal to supervisor startup instead of being treated
as unset, so a misconfiguration can never silently degrade to direct dialing
or unauthenticated proxy access. Only an omitted argument means "no proxy".
The driver validates the same rules at sandbox-create time through
validators shared with the supervisor
empty value, an unsupported or malformed proxy URL, an unreadable auth file or
CA bundle, a malformed credential, or an auth file, `NO_PROXY` list, or CA
bundle set while no proxy URL is configured — is fatal to supervisor startup
instead of being treated as unset, so a misconfiguration can never silently
degrade to direct dialing or unauthenticated proxy access. Only an omitted
argument means "no proxy". The driver validates the same rules at
sandbox-create time through validators shared with the supervisor
(`openshell_core::driver_utils::parse_upstream_proxy_url` and
`parse_upstream_proxy_credential`).

An optional operator CA bundle (`--upstream-proxy-ca-bundle`, a PEM path the
driver bind-mounts read-only into the sandbox) extends the trust boundary for
corporate proxies. A CA certificate is not secret, so unlike the auth file it
travels as a plain read-only bind mount rather than a driver secret. It is
trusted in two places: the TLS handshake with an `https://` proxy, and —
because a TLS-intercepting proxy (mitmproxy, squid `ssl-bump`) re-signs
tunneled server certificates with the same CA — the sandbox combined trust
bundle (`write_ca_files`) and the L7 upstream re-encryption store
(`build_upstream_client_config`). Folding it into both means intercepted
upstream handshakes succeed and sandbox workload processes trust the re-signed
certificates; trusting it only for the proxy-listener handshake would leave
every intercepted upstream connection failing. The bundle is valid with either
an `http://` or `https://` proxy (an intercepting proxy can be reached over
plain HTTP) and is fail-closed: an unreadable or certificate-free file is fatal.

Proxy credentials are never embedded in the URL: an inline `user:pass@` is
rejected because it would be stored in `gateway.toml` and exposed in container
metadata. Operators supply credentials via `proxy_auth_file`; the driver
Expand Down
85 changes: 64 additions & 21 deletions crates/openshell-core/src/driver_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt";
/// mount so the credential never appears in container environment/metadata.
pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy";

/// Container-side mount path for the corporate proxy CA bundle.
///
/// Drivers with a `proxy_ca_bundle` operator setting bind-mount the host PEM
/// file here (read-only) and pass the path on the supervisor's argv via
/// `--upstream-proxy-ca-bundle`. The supervisor trusts it for the TLS
/// handshake with an `https://` corporate egress proxy and for server
/// certificates re-signed by a TLS-intercepting proxy. Unlike the proxy
/// credential, a CA certificate is not secret, so a plain read-only bind
/// mount is used rather than a driver secret.
pub const PROXY_CA_MOUNT_PATH: &str = "/etc/openshell/tls/proxy/ca-bundle.pem";

/// A validated corporate upstream-proxy address.
///
/// Produced by [`parse_upstream_proxy_url`], which is the single source of
Expand All @@ -103,6 +114,9 @@ pub struct UpstreamProxyAddr {
pub host: String,
/// Proxy TCP port (always explicit in the accepted URL grammar).
pub port: u16,
/// `true` when the proxy URL used the `https://` scheme, so the supervisor
/// wraps the connection to the proxy in TLS before the CONNECT handshake.
pub secure: bool,
}

/// Why an upstream proxy URL was rejected by [`parse_upstream_proxy_url`].
Expand All @@ -123,11 +137,11 @@ pub enum UpstreamProxyUrlError {
/// `http://host:port` contract exactly.
#[error("proxy URL must include an explicit scheme, e.g. http://proxy.corp.com:3128")]
MissingScheme,
/// The URL uses a scheme other than `http` (TLS and SOCKS proxies are
/// The URL uses a scheme other than `http` or `https` (SOCKS proxies are
/// not supported by the sandbox supervisor).
#[error(
"unsupported proxy scheme '{0}': only http:// forward proxies are \
supported by the sandbox supervisor"
"unsupported proxy scheme '{0}': only http:// and https:// forward \
proxies are supported by the sandbox supervisor"
)]
UnsupportedScheme(String),
/// The URL has no explicit port. Corporate proxies rarely listen on the
Expand Down Expand Up @@ -157,11 +171,13 @@ pub enum UpstreamProxyUrlError {

/// Parse and validate a corporate upstream-proxy URL.
///
/// The accepted grammar is exactly `http://host:port`: the scheme and the
/// port must both be explicit, only `http://` proxies are accepted, and
/// inline userinfo is rejected. The URL must address the proxy only: a path
/// (other than a bare trailing `/`), query, or fragment is rejected rather
/// than silently discarded.
/// The accepted grammar is exactly `http://host:port` or `https://host:port`:
/// the scheme and the port must both be explicit, only `http://` and
/// `https://` proxies are accepted, and inline userinfo is rejected. The URL
/// must address the proxy only: a path (other than a bare trailing `/`),
/// query, or fragment is rejected rather than silently discarded. The
/// returned [`UpstreamProxyAddr::secure`] records whether the `https://`
/// scheme was used so the supervisor knows to TLS-wrap the proxy connection.
///
/// # Errors
///
Expand All @@ -177,11 +193,15 @@ pub fn parse_upstream_proxy_url(raw: &str) -> Result<UpstreamProxyAddr, Upstream
}
let parsed = url::Url::parse(trimmed).map_err(UpstreamProxyUrlError::Invalid)?;

if !parsed.scheme().eq_ignore_ascii_case("http") {
let secure = if parsed.scheme().eq_ignore_ascii_case("https") {
true
} else if parsed.scheme().eq_ignore_ascii_case("http") {
false
} else {
return Err(UpstreamProxyUrlError::UnsupportedScheme(
parsed.scheme().to_string(),
));
}
};
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(UpstreamProxyUrlError::InlineCredentials);
}
Expand Down Expand Up @@ -209,14 +229,14 @@ pub fn parse_upstream_proxy_url(raw: &str) -> Result<UpstreamProxyAddr, Upstream
if !authority_has_explicit_port(trimmed) {
return Err(UpstreamProxyUrlError::MissingPort);
}
// Explicit-port presence was verified above; `port()` is `None` only
// when the URL spells out the scheme default (`:80`), which the url crate
// normalizes away.
let port = parsed.port().unwrap_or(80);
// Explicit-port presence was verified above; `port()` is `None` only when
// the URL spells out the scheme default (`:80` for http, `:443` for
// https), which the url crate normalizes away.
let port = parsed.port().unwrap_or(if secure { 443 } else { 80 });
if port == 0 {
return Err(UpstreamProxyUrlError::ZeroPort);
}
Ok(UpstreamProxyAddr { host, port })
Ok(UpstreamProxyAddr { host, port, secure })
}

/// Return `true` when the raw URL's authority carries an explicit `:port`.
Expand Down Expand Up @@ -467,6 +487,20 @@ mod tests {
let addr = parse_upstream_proxy_url("http://proxy.corp.com:8080").unwrap();
assert_eq!(addr.host, "proxy.corp.com");
assert_eq!(addr.port, 8080);
assert!(!addr.secure, "http:// is not TLS-wrapped");
}

#[test]
fn upstream_proxy_url_accepts_https_with_port() {
let addr = parse_upstream_proxy_url("https://proxy.corp.com:3130").unwrap();
assert_eq!(addr.host, "proxy.corp.com");
assert_eq!(addr.port, 3130);
assert!(addr.secure, "https:// is TLS-wrapped");
// An explicit scheme-default port (:443) is accepted even though the
// url crate normalizes it away in the parsed form.
let addr = parse_upstream_proxy_url("https://proxy.corp.com:443").unwrap();
assert_eq!(addr.port, 443);
assert!(addr.secure);
}

#[test]
Expand Down Expand Up @@ -526,12 +560,21 @@ mod tests {
}

#[test]
fn upstream_proxy_url_rejects_tls_and_socks_schemes() {
for url in ["https://proxy:443", "socks5://proxy:1080"] {
assert!(matches!(
parse_upstream_proxy_url(url),
Err(UpstreamProxyUrlError::UnsupportedScheme(_))
));
fn upstream_proxy_url_rejects_socks_schemes() {
// http:// and https:// are supported; only other schemes (SOCKS, etc.)
// are rejected.
for url in [
"socks5://proxy:1080",
"socks4://proxy:1080",
"ftp://proxy:21",
] {
assert!(
matches!(
parse_upstream_proxy_url(url),
Err(UpstreamProxyUrlError::UnsupportedScheme(_))
),
"{url}"
);
}
}

Expand Down
7 changes: 4 additions & 3 deletions crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,15 +345,16 @@ Podman resources after out-of-band container removal or label drift.
| `OPENSHELL_PODMAN_TLS_CA` | `--podman-tls-ca` | unset | Host path to the CA certificate mounted for sandbox mTLS. |
| `OPENSHELL_PODMAN_TLS_CERT` | `--podman-tls-cert` | unset | Host path to the client certificate mounted for sandbox mTLS. |
| `OPENSHELL_PODMAN_TLS_KEY` | `--podman-tls-key` | unset | Host path to the client private key mounted for sandbox mTLS. |
| `OPENSHELL_SANDBOX_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://host:port` URLs are supported (scheme and port required). Plain-HTTP requests always dial directly. |
| `OPENSHELL_SANDBOX_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Credential-free `http://host:port` and `https://host:port` URLs are supported (scheme and port required). For an `https://` proxy the supervisor TLS-wraps the proxy connection, verifying the proxy certificate against the built-in and system roots plus `--sandbox-proxy-ca-bundle`. Plain-HTTP requests always dial directly. |
| `OPENSHELL_SANDBOX_NO_PROXY` | `--sandbox-no-proxy` | unset | Comma-separated `NO_PROXY` list (hostnames, domain suffixes, IPs, CIDRs, each with an optional `:port` qualifier) dialed directly instead of through the corporate proxy. IP/CIDR entries also match hostnames through their validated DNS resolution. |
| `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. Requires the insecure-auth acknowledgement below. |
| `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set; rejected when it is not. |
| `OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME` | `--sandbox-proxy-connect-by-hostname` | unset | Send the destination hostname in CONNECT requests instead of a validated IP. Last resort for proxies whose ACLs filter on hostnames: the proxy then resolves the name itself, so sandbox SSRF/`allowed_ips` validation no longer binds the connection. |
| `OPENSHELL_SANDBOX_PROXY_CA_BUNDLE` | `--sandbox-proxy-ca-bundle` | unset | Path (on the gateway host) to a PEM CA bundle trusted for the corporate proxy. Bind-mounted read-only into the sandbox (a CA certificate is not secret). Trusted for the `https://` proxy TLS handshake and, because TLS-intercepting proxies re-sign tunneled certificates, folded into the sandbox trust bundle and upstream verification. Requires a proxy URL; the file must exist and hold at least one certificate. |

Through the gateway, the same settings are the `https_proxy`, `no_proxy`,
`proxy_auth_file`, `proxy_auth_allow_insecure`, and
`proxy_connect_by_hostname` keys under `[openshell.drivers.podman]`; see
`proxy_auth_file`, `proxy_auth_allow_insecure`, `proxy_connect_by_hostname`,
and `proxy_ca_bundle` keys under `[openshell.drivers.podman]`; see
`docs/reference/gateway-config.mdx`.

This is an operator-owned egress boundary: the driver passes the settings on
Expand Down
Loading
Loading