Skip to content

Commit 3b21df1

Browse files
authored
feat(sandbox): load system CA certificates for upstream TLS connections (#862)
The proxy's upstream TLS client only trusted Mozilla root CAs (webpki-roots), which prevented TLS termination from working with internal/corporate hosts using private CA certificates. Load system CA certificates from the container's trust store (e.g. /etc/ssl/certs/ca-certificates.crt) in addition to webpki-roots. This allows custom sandbox images to include corporate CAs via update-ca-certificates. Signed-off-by: Matthias Osswald <mat.osswald@sap.com>
1 parent 25d2530 commit 3b21df1

4 files changed

Lines changed: 158 additions & 13 deletions

File tree

architecture/gateway-security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ The sandbox proxy automatically detects and terminates TLS on outbound HTTPS con
425425
1. **Ephemeral sandbox CA**: a per-sandbox CA (`CN=OpenShell Sandbox CA, O=OpenShell`) is generated at sandbox startup. This CA is completely independent of the cluster mTLS CA.
426426
2. **Trust injection**: the sandbox CA is written to the sandbox filesystem and injected via `NODE_EXTRA_CA_CERTS` and `SSL_CERT_FILE` so processes inside the sandbox trust it.
427427
3. **Dynamic leaf certs**: for each target hostname, the proxy generates and caches a leaf certificate signed by the sandbox CA (up to 256 entries).
428-
4. **Upstream verification**: the proxy verifies upstream server certificates against Mozilla root CAs (`webpki-roots`), not against the cluster CA.
428+
4. **Upstream verification**: the proxy verifies upstream server certificates against Mozilla root CAs (`webpki-roots`) and system CA certificates from the container's trust store, not against the cluster CA. Custom sandbox images can add corporate/internal CAs via `update-ca-certificates`.
429429

430430
This capability is orthogonal to gateway mTLS -- it operates only on sandbox-to-internet traffic and uses entirely separate key material. See [Policy Language](security-policy.md) for configuration details.
431431

architecture/sandbox.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ flowchart TD
9494
- Generate ephemeral CA via `SandboxCa::generate()` using `rcgen`
9595
- Write CA cert PEM and combined bundle (system CAs + sandbox CA) to `/etc/openshell-tls/`
9696
- Add the TLS directory to `policy.filesystem.read_only` so Landlock allows the child to read it
97-
- Build upstream `ClientConfig` with Mozilla root CAs via `webpki_roots`
97+
- Build upstream `ClientConfig` with Mozilla root CAs (`webpki_roots`) plus system CA certificates from the container's trust store (e.g. corporate CAs added via `update-ca-certificates`)
9898
- Create `Arc<ProxyTlsState>` wrapping a `CertCache` and the upstream config
9999

100100
6. **Network namespace** (Linux, proxy mode only):
@@ -1057,7 +1057,7 @@ TLS termination is automatic. The proxy peeks the first bytes of every CONNECT t
10571057

10581058
**Connection flow (when TLS is detected):**
10591059
1. `tls_terminate_client()`: Accept TLS from the sandboxed client using a `ServerConfig` with the hostname-specific leaf cert. ALPN: `http/1.1`.
1060-
2. `tls_connect_upstream()`: Connect TLS to the real upstream using a `ClientConfig` with Mozilla root CAs (`webpki_roots`). ALPN: `http/1.1`.
1060+
2. `tls_connect_upstream()`: Connect TLS to the real upstream using a `ClientConfig` with Mozilla root CAs (`webpki_roots`) and system CA certificates. ALPN: `http/1.1`.
10611061
3. Proxy now holds plaintext on both sides. If L7 config is present, runs `relay_with_inspection()`. Otherwise, runs `relay_passthrough_with_credentials()` for credential injection without L7 evaluation.
10621062

10631063
System CA bundles are searched at well-known paths: `/etc/ssl/certs/ca-certificates.crt` (Debian/Ubuntu), `/etc/pki/tls/certs/ca-bundle.crt` (RHEL), `/etc/ssl/ca-bundle.pem` (openSUSE), `/etc/ssl/cert.pem` (Alpine/macOS).

crates/openshell-sandbox/src/l7/tls.rs

Lines changed: 150 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,28 @@ pub async fn tls_connect_upstream(
197197
Ok(tls_stream)
198198
}
199199

200-
/// Build a rustls `ClientConfig` with Mozilla root CAs for upstream connections.
201-
pub fn build_upstream_client_config() -> Arc<ClientConfig> {
200+
/// Build a rustls `ClientConfig` with Mozilla + system root CAs for upstream connections.
201+
///
202+
/// `system_ca_bundle` is the pre-read PEM contents of the system CA bundle
203+
/// (from [`read_system_ca_bundle`]). Pass the same string to [`write_ca_files`]
204+
/// to avoid reading the bundle from disk twice.
205+
pub fn build_upstream_client_config(system_ca_bundle: &str) -> Arc<ClientConfig> {
202206
let mut root_store = rustls::RootCertStore::empty();
203207
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
204208

209+
// System bundles typically overlap with webpki-roots (Mozilla roots);
210+
// duplicates are harmless and ensure we also pick up any custom/corporate CAs.
211+
let (added, ignored) = load_pem_certs_into_store(&mut root_store, system_ca_bundle);
212+
if added > 0 {
213+
tracing::debug!(added, "Loaded system CA certificates for upstream TLS");
214+
}
215+
if ignored > 0 {
216+
tracing::warn!(
217+
ignored,
218+
"Some system CA certificates could not be parsed and were ignored"
219+
);
220+
}
221+
205222
let mut config = ClientConfig::builder()
206223
.with_root_certificates(root_store)
207224
.with_no_client_auth();
@@ -216,15 +233,23 @@ pub fn build_upstream_client_config() -> Arc<ClientConfig> {
216233
/// 1. Standalone CA cert PEM (for `NODE_EXTRA_CA_CERTS` which is additive)
217234
/// 2. Combined bundle: system CAs + sandbox CA (for `SSL_CERT_FILE` which replaces default)
218235
///
236+
/// `system_ca_bundle` is the pre-read PEM contents of the system CA bundle
237+
/// (from [`read_system_ca_bundle`]). Pass the same string to
238+
/// [`build_upstream_client_config`] to avoid reading the bundle from disk twice.
239+
///
219240
/// Returns `(ca_cert_path, combined_bundle_path)`.
220-
pub fn write_ca_files(ca: &SandboxCa, output_dir: &Path) -> Result<(PathBuf, PathBuf)> {
241+
pub fn write_ca_files(
242+
ca: &SandboxCa,
243+
output_dir: &Path,
244+
system_ca_bundle: &str,
245+
) -> Result<(PathBuf, PathBuf)> {
221246
std::fs::create_dir_all(output_dir).into_diagnostic()?;
222247

223248
let ca_cert_path = output_dir.join("openshell-ca.pem");
224249
std::fs::write(&ca_cert_path, ca.cert_pem()).into_diagnostic()?;
225250

226-
// Read system CA bundle and append our CA
227-
let mut combined = read_system_ca_bundle();
251+
// Combine system CAs with our sandbox CA
252+
let mut combined = system_ca_bundle.to_string();
228253
if !combined.is_empty() && !combined.ends_with('\n') {
229254
combined.push('\n');
230255
}
@@ -236,8 +261,36 @@ pub fn write_ca_files(ca: &SandboxCa, output_dir: &Path) -> Result<(PathBuf, Pat
236261
Ok((ca_cert_path, combined_path))
237262
}
238263

264+
/// Load PEM-encoded certificates from a string into a root certificate store.
265+
///
266+
/// Returns `(added, ignored)` counts. Invalid or unparseable certificates
267+
/// are silently ignored, matching the behavior of
268+
/// `RootCertStore::add_parsable_certificates`.
269+
fn load_pem_certs_into_store(
270+
root_store: &mut rustls::RootCertStore,
271+
pem_data: &str,
272+
) -> (usize, usize) {
273+
if pem_data.is_empty() {
274+
return (0, 0);
275+
}
276+
let mut reader = BufReader::new(pem_data.as_bytes());
277+
// Collect all results so we can count PEM blocks that fail base64
278+
// decoding — rustls_pemfile::certs silently drops those, so without
279+
// this they wouldn't be reflected in the `ignored` count.
280+
let all_results: Vec<_> = rustls_pemfile::certs(&mut reader).collect();
281+
let pem_errors = all_results.iter().filter(|r| r.is_err()).count();
282+
let certs: Vec<CertificateDer<'static>> =
283+
all_results.into_iter().filter_map(Result::ok).collect();
284+
let (added, ignored) = root_store.add_parsable_certificates(certs);
285+
(added, ignored + pem_errors)
286+
}
287+
239288
/// Read the system CA bundle from well-known paths.
240-
fn read_system_ca_bundle() -> String {
289+
///
290+
/// Returns the PEM contents of the first non-empty bundle found, or an empty
291+
/// string if none of the well-known paths exist. Call once and pass the result
292+
/// to both [`write_ca_files`] and [`build_upstream_client_config`].
293+
pub fn read_system_ca_bundle() -> String {
241294
for path in SYSTEM_CA_PATHS {
242295
if let Ok(contents) = std::fs::read_to_string(path)
243296
&& !contents.is_empty()
@@ -373,7 +426,97 @@ mod tests {
373426
#[test]
374427
fn upstream_config_alpn() {
375428
let _ = rustls::crypto::ring::default_provider().install_default();
376-
let config = build_upstream_client_config();
429+
let config = build_upstream_client_config("");
377430
assert_eq!(config.alpn_protocols, vec![b"http/1.1".to_vec()]);
378431
}
432+
433+
/// Helper: generate a self-signed CA and return its PEM string.
434+
fn generate_ca_pem() -> String {
435+
SandboxCa::generate().unwrap().ca_cert_pem
436+
}
437+
438+
#[test]
439+
fn load_pem_certs_single_ca() {
440+
let pem = generate_ca_pem();
441+
let mut store = rustls::RootCertStore::empty();
442+
let (added, ignored) = load_pem_certs_into_store(&mut store, &pem);
443+
assert_eq!(added, 1);
444+
assert_eq!(ignored, 0);
445+
}
446+
447+
#[test]
448+
fn load_pem_certs_multiple_cas() {
449+
let bundle = format!(
450+
"{}\n{}\n{}\n",
451+
generate_ca_pem(),
452+
generate_ca_pem(),
453+
generate_ca_pem()
454+
);
455+
let mut store = rustls::RootCertStore::empty();
456+
let (added, ignored) = load_pem_certs_into_store(&mut store, &bundle);
457+
assert_eq!(added, 3);
458+
assert_eq!(ignored, 0);
459+
}
460+
461+
#[test]
462+
fn load_pem_certs_empty_string() {
463+
let mut store = rustls::RootCertStore::empty();
464+
let (added, ignored) = load_pem_certs_into_store(&mut store, "");
465+
assert_eq!(added, 0);
466+
assert_eq!(ignored, 0);
467+
}
468+
469+
#[test]
470+
fn load_pem_certs_garbage_input() {
471+
let mut store = rustls::RootCertStore::empty();
472+
let (added, ignored) = load_pem_certs_into_store(&mut store, "this is not PEM data at all");
473+
assert_eq!(added, 0);
474+
assert_eq!(ignored, 0);
475+
}
476+
477+
#[test]
478+
fn load_pem_certs_malformed_pem_block() {
479+
let malformed = "-----BEGIN CERTIFICATE-----\nNOTBASE64!!!\n-----END CERTIFICATE-----\n";
480+
let mut store = rustls::RootCertStore::empty();
481+
let (added, ignored) = load_pem_certs_into_store(&mut store, malformed);
482+
assert_eq!(added, 0);
483+
assert_eq!(ignored, 1);
484+
}
485+
486+
#[test]
487+
fn load_pem_certs_mixed_valid_and_invalid() {
488+
let malformed = "-----BEGIN CERTIFICATE-----\nNOTBASE64!!!\n-----END CERTIFICATE-----\n";
489+
let bundle = format!(
490+
"{}\n{}{}\n",
491+
generate_ca_pem(),
492+
malformed,
493+
generate_ca_pem()
494+
);
495+
let mut store = rustls::RootCertStore::empty();
496+
let (added, ignored) = load_pem_certs_into_store(&mut store, &bundle);
497+
assert_eq!(added, 2);
498+
assert_eq!(ignored, 1);
499+
}
500+
501+
#[test]
502+
fn write_ca_files_includes_sandbox_ca() {
503+
let ca = SandboxCa::generate().unwrap();
504+
let dir = tempfile::tempdir().unwrap();
505+
let (ca_path, bundle_path) = write_ca_files(&ca, dir.path(), "").unwrap();
506+
507+
// Standalone CA cert file should exist and be valid PEM
508+
let ca_pem = std::fs::read_to_string(&ca_path).unwrap();
509+
assert!(ca_pem.starts_with("-----BEGIN CERTIFICATE-----"));
510+
511+
// Combined bundle should contain at least the sandbox CA
512+
let bundle_pem = std::fs::read_to_string(&bundle_path).unwrap();
513+
assert!(bundle_pem.contains(ca.cert_pem()));
514+
515+
// Bundle should be parseable as PEM certificates
516+
let mut reader = BufReader::new(bundle_pem.as_bytes());
517+
assert!(
518+
rustls_pemfile::certs(&mut reader).any(|r| r.is_ok()),
519+
"bundle should contain at least one cert",
520+
);
521+
}
379522
}

crates/openshell-sandbox/src/lib.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ pub(crate) fn ocsf_ctx() -> &'static SandboxContext {
8787

8888
use crate::identity::BinaryIdentityCache;
8989
use crate::l7::tls::{
90-
CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, write_ca_files,
90+
CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle,
91+
write_ca_files,
9192
};
9293
use crate::opa::OpaEngine;
9394
use crate::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy};
@@ -315,13 +316,14 @@ pub async fn run_sandbox(
315316
match SandboxCa::generate() {
316317
Ok(ca) => {
317318
let tls_dir = std::path::Path::new("/etc/openshell-tls");
318-
match write_ca_files(&ca, tls_dir) {
319+
let system_ca_bundle = read_system_ca_bundle();
320+
match write_ca_files(&ca, tls_dir, &system_ca_bundle) {
319321
Ok(paths) => {
320322
// /etc/openshell-tls is subsumed by the /etc baseline
321323
// path injected by enrich_*_baseline_paths(), so no
322324
// explicit Landlock entry is needed here.
323325

324-
let upstream_config = build_upstream_client_config();
326+
let upstream_config = build_upstream_client_config(&system_ca_bundle);
325327
let cert_cache = CertCache::new(ca);
326328
let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config));
327329
ocsf_emit!(

0 commit comments

Comments
 (0)