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
36 changes: 36 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,42 @@ handled by the inference interception path:
External inference endpoints that do not use `inference.local` are treated like
ordinary network traffic and must be allowed by policy.

In proxy-required networks, the supervisor chains the upstream dial through a
corporate forward proxy with HTTP CONNECT instead of connecting directly, once
policy and SSRF checks pass. The proxy configuration is an operator-owned
boundary read from reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` /
`OPENSHELL_UPSTREAM_HTTP_PROXY` / `OPENSHELL_UPSTREAM_NO_PROXY` variables that
compute drivers write in their required-variable tier; sandbox and template
environment cannot override them. The conventional `HTTPS_PROXY`/`HTTP_PROXY`/
`NO_PROXY` variables a sandbox controls are ignored on this path. Reserved
`NO_PROXY` destinations, loopback, and host-gateway aliases always dial
directly. Only `http://` proxy URLs are supported. Local DNS resolution and
SSRF validation still run before the proxied dial; the CONNECT target sent to
the corporate proxy is the requested hostname. 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 reserved variable that is present but
invalid — a present-but-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 a fully unset
variable 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`).

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
stages them as a root-only secret mounted at a fixed path and exports only
that path in `OPENSHELL_UPSTREAM_PROXY_AUTH_FILE`. The supervisor reads the
file and builds the `Proxy-Authorization: Basic` header; a credential that is
empty, contains control characters, or is not in `user:pass` form is fatal on
both sides. The reserved proxy variables —
including the auth-file path — are stripped from workload child processes.

## Credentials

Provider credentials are stored at the gateway and fetched by the supervisor at
Expand Down
259 changes: 259 additions & 0 deletions crates/openshell-core/src/driver_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,158 @@ pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key";
/// Container-side mount path for the per-sandbox JWT token.
pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt";

/// Container-side mount path for the corporate upstream-proxy credentials.
///
/// The file holds the `user:pass` userinfo used to build the
/// `Proxy-Authorization` header. It is delivered through a root-only secret
/// mount so the credential never appears in container environment/metadata.
pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy";

/// A validated corporate upstream-proxy address.
///
/// Produced by [`parse_upstream_proxy_url`], which is the single source of
/// truth for what counts as a valid upstream proxy URL. Compute drivers use
/// it to reject bad operator config at sandbox-create time, and the
/// in-container supervisor applies the same rules to the reserved
/// `OPENSHELL_UPSTREAM_*` variables so a value one side accepts is never
/// rejected (or silently ignored) by the other.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamProxyAddr {
/// Proxy hostname, IPv4, or IPv6 address (IPv6 without brackets).
pub host: String,
/// Proxy TCP port (defaults to 80 when the URL has none).
pub port: u16,
}

/// Why an upstream proxy URL was rejected by [`parse_upstream_proxy_url`].
///
/// Kept as a typed error so each consumer (driver config validation,
/// supervisor startup) can phrase the message for its own surface while
/// enforcing identical semantics.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UpstreamProxyUrlError {
/// The value is empty or whitespace-only.
#[error("proxy URL is empty")]
Empty,
/// The value does not parse as a URL.
#[error("not a valid proxy URL: {0}")]
Invalid(url::ParseError),
/// The URL uses a scheme other than `http` (TLS and SOCKS proxies are
/// not supported by the sandbox supervisor).
#[error(
"unsupported proxy scheme '{0}': only http:// forward proxies are \
supported by the sandbox supervisor"
)]
UnsupportedScheme(String),
/// The URL embeds `user:pass@` credentials, which would leak into config
/// and container metadata. Credentials must come from the proxy auth file.
#[error("proxy URL must not embed credentials; supply them via the proxy auth file")]
InlineCredentials,
/// The URL has no host component.
#[error("proxy URL is missing a proxy host")]
MissingHost,
}

/// Parse and validate a corporate upstream-proxy URL.
///
/// A bare `host:port` (no `://`) is normalized to `http://host:port`. Only
/// `http://` proxies are accepted, inline userinfo is rejected, and the port
/// defaults to 80.
///
/// # Errors
///
/// Returns an [`UpstreamProxyUrlError`] describing the first rule the value
/// violates.
pub fn parse_upstream_proxy_url(raw: &str) -> Result<UpstreamProxyAddr, UpstreamProxyUrlError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(UpstreamProxyUrlError::Empty);
}
let parsed = if trimmed.contains("://") {
url::Url::parse(trimmed)
} else {
url::Url::parse(&format!("http://{trimmed}"))
}
.map_err(UpstreamProxyUrlError::Invalid)?;

if !parsed.scheme().eq_ignore_ascii_case("http") {
return Err(UpstreamProxyUrlError::UnsupportedScheme(
parsed.scheme().to_string(),
));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(UpstreamProxyUrlError::InlineCredentials);
}
let host = match parsed.host() {
// `Host::Ipv6` renders without brackets, which is what socket
// connect APIs expect.
Some(url::Host::Ipv6(ip)) => ip.to_string(),
Some(host) => host.to_string(),
None => return Err(UpstreamProxyUrlError::MissingHost),
};
if host.is_empty() {
return Err(UpstreamProxyUrlError::MissingHost);
}
Ok(UpstreamProxyAddr {
host,
port: parsed.port().unwrap_or(80),
})
}

/// Why an upstream proxy credential was rejected by
/// [`parse_upstream_proxy_credential`].
///
/// Variants carry no payload so an error can never leak credential content
/// into logs or user-facing messages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum UpstreamProxyCredentialError {
/// The credential is empty or whitespace-only.
#[error("credential is empty")]
Empty,
/// The credential contains control characters (CR, LF, NUL, tab, ...)
/// that could inject additional HTTP headers.
#[error("credential contains control characters")]
ControlCharacters,
/// The credential has no `:` separating user from password.
#[error("credential must use the user:pass form (missing ':')")]
MissingSeparator,
/// The credential has an empty user before the `:` separator.
#[error("credential must use the user:pass form (empty user)")]
EmptyUser,
}

/// Validate a corporate upstream-proxy credential read from the proxy auth
/// file, returning the trimmed `user:pass` value.
///
/// Single source of truth for what counts as a valid proxy credential: the
/// compute driver applies it at sandbox-create time (before staging the
/// secret) and the in-container supervisor applies it again before building
/// the `Proxy-Authorization: Basic` header, so a credential one side accepts
/// is never rejected by the other.
///
/// Surrounding whitespace (including the conventional trailing newline) is
/// trimmed. The user part must be non-empty; the password may be empty and
/// may itself contain `:` (per RFC 7617 the first `:` is the separator).
///
/// # Errors
///
/// Returns an [`UpstreamProxyCredentialError`] describing the first rule the
/// value violates. Errors never contain the credential itself.
pub fn parse_upstream_proxy_credential(raw: &str) -> Result<&str, UpstreamProxyCredentialError> {
let credential = raw.trim();
if credential.is_empty() {
return Err(UpstreamProxyCredentialError::Empty);
}
if credential.contains(|c: char| c.is_control()) {
return Err(UpstreamProxyCredentialError::ControlCharacters);
}
match credential.split_once(':') {
None => Err(UpstreamProxyCredentialError::MissingSeparator),
Some(("", _)) => Err(UpstreamProxyCredentialError::EmptyUser),
Some(_) => Ok(credential),
}
}

/// Return the XDG state path for a driver's sandbox JWT token file.
///
/// The resulting path is `$XDG_STATE_HOME/openshell/<driver_subdir>[/<namespace>]/<sandbox_id>/sandbox.jwt`.
Expand Down Expand Up @@ -160,3 +312,110 @@ pub fn supervisor_image_tag(image: &str) -> Option<&str> {
pub fn supervisor_image_should_refresh(image: &str) -> bool {
matches!(supervisor_image_tag(image), Some("dev" | "latest"))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn upstream_proxy_url_accepts_http_with_port() {
let addr = parse_upstream_proxy_url("http://proxy.corp.com:8080").unwrap();
assert_eq!(addr.host, "proxy.corp.com");
assert_eq!(addr.port, 8080);
}

#[test]
fn upstream_proxy_url_defaults_scheme_and_port() {
let addr = parse_upstream_proxy_url("proxy.corp.com").unwrap();
assert_eq!(addr.host, "proxy.corp.com");
assert_eq!(addr.port, 80);
let addr = parse_upstream_proxy_url("proxy.corp.com:3128").unwrap();
assert_eq!(addr.port, 3128);
}

#[test]
fn upstream_proxy_url_ipv6_host_is_bracket_free() {
let addr = parse_upstream_proxy_url("http://[fd00::1]:8080").unwrap();
assert_eq!(addr.host, "fd00::1");
assert_eq!(addr.port, 8080);
}

#[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(_))
));
}
}

#[test]
fn upstream_proxy_url_rejects_inline_credentials() {
for url in ["http://user:pass@proxy:8080", "http://user@proxy:8080"] {
assert_eq!(
parse_upstream_proxy_url(url),
Err(UpstreamProxyUrlError::InlineCredentials)
);
}
}

#[test]
fn upstream_proxy_url_rejects_empty_and_invalid() {
assert_eq!(
parse_upstream_proxy_url(" "),
Err(UpstreamProxyUrlError::Empty)
);
assert!(matches!(
parse_upstream_proxy_url("http://proxy:notaport"),
Err(UpstreamProxyUrlError::Invalid(_))
));
assert!(parse_upstream_proxy_url("http://").is_err());
}

#[test]
fn upstream_proxy_credential_accepts_user_pass_and_trims() {
assert_eq!(
parse_upstream_proxy_credential("user:pass\n"),
Ok("user:pass")
);
// The password may be empty and may contain further colons.
assert_eq!(parse_upstream_proxy_credential("user:"), Ok("user:"));
assert_eq!(
parse_upstream_proxy_credential("user:p@:ss"),
Ok("user:p@:ss")
);
}

#[test]
fn upstream_proxy_credential_rejects_empty() {
for raw in ["", " ", "\n"] {
assert_eq!(
parse_upstream_proxy_credential(raw),
Err(UpstreamProxyCredentialError::Empty)
);
}
}

#[test]
fn upstream_proxy_credential_rejects_control_characters() {
for raw in ["user:pa\r\nss", "user:pa\0ss", "user:pa\tss"] {
assert_eq!(
parse_upstream_proxy_credential(raw),
Err(UpstreamProxyCredentialError::ControlCharacters)
);
}
}

#[test]
fn upstream_proxy_credential_rejects_malformed_user_pass_form() {
assert_eq!(
parse_upstream_proxy_credential("userpass"),
Err(UpstreamProxyCredentialError::MissingSeparator)
);
assert_eq!(
parse_upstream_proxy_credential(":pass"),
Err(UpstreamProxyCredentialError::EmptyUser)
);
}
}
33 changes: 33 additions & 0 deletions crates/openshell-core/src/sandbox_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,36 @@ pub const SANDBOX_UID: &str = "OPENSHELL_SANDBOX_UID";
/// Used alongside UID for PVC init container `chown` operations and when the
/// supervisor drops privileges to a group other than the UID's primary group.
pub const SANDBOX_GID: &str = "OPENSHELL_SANDBOX_GID";

/// Corporate forward-proxy URL the supervisor chains TLS (CONNECT) egress
/// through, e.g. `http://proxy.corp.com:8080`.
///
/// This is an operator-owned egress boundary. Compute drivers set it from
/// gateway configuration in the supervisor's required-variable tier so that
/// sandbox spec/template environment cannot override it. The supervisor reads
/// *only* this reserved name — never the conventional `HTTPS_PROXY` /
/// `ALL_PROXY` variables, which the sandbox creator controls and which are
/// reserved for pointing the workload child at the local policy proxy.
pub const UPSTREAM_HTTPS_PROXY: &str = "OPENSHELL_UPSTREAM_HTTPS_PROXY";

/// Corporate forward-proxy URL the supervisor uses for plain-HTTP egress.
///
/// Operator-owned counterpart to [`UPSTREAM_HTTPS_PROXY`]; see that constant
/// for the trust-boundary rationale.
pub const UPSTREAM_HTTP_PROXY: &str = "OPENSHELL_UPSTREAM_HTTP_PROXY";

/// Comma-separated `NO_PROXY`-style list of destinations the supervisor dials
/// directly instead of chaining through the corporate proxy.
///
/// Operator-owned counterpart to [`UPSTREAM_HTTPS_PROXY`]; see that constant
/// for the trust-boundary rationale.
pub const UPSTREAM_NO_PROXY: &str = "OPENSHELL_UPSTREAM_NO_PROXY";

/// Filesystem path (inside the sandbox) to the corporate proxy credentials.
///
/// The file holds `user:pass` userinfo the supervisor turns into a
/// `Proxy-Authorization: Basic` header. Credentials are delivered through this
/// root-only file rather than embedded in the proxy URL, so they never appear
/// in container environment or metadata. Compute drivers write this in the
/// required-variable tier; the value is a path, not a secret.
pub const UPSTREAM_PROXY_AUTH_FILE: &str = "OPENSHELL_UPSTREAM_PROXY_AUTH_FILE";
15 changes: 15 additions & 0 deletions crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,21 @@ 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://` URLs are supported. |
| `OPENSHELL_SANDBOX_HTTP_PROXY` | `--sandbox-http-proxy` | unset | Corporate forward proxy URL for plain HTTP requests. |
| `OPENSHELL_SANDBOX_NO_PROXY` | `--sandbox-no-proxy` | unset | Comma-separated `NO_PROXY` list (hostnames, domain suffixes, IPs, CIDRs) dialed directly instead of through the corporate proxy. |
| `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. |

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

This is an operator-owned egress boundary: the supervisor reads it from reserved
`OPENSHELL_UPSTREAM_*` variables written at highest priority, so sandbox and
template environment cannot override it, and the conventional
`HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox controls do not steer
it. Credentials must be supplied through `proxy_auth_file`; an inline
`user:pass@` in the URL is rejected at startup.

## Rootless-Specific Adaptations

Expand Down
Loading
Loading