diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index 7e9986ab..1fddcea2 100644 --- a/crates/openshell-sandbox/data/sandbox-policy.rego +++ b/crates/openshell-sandbox/data/sandbox-policy.rego @@ -129,22 +129,13 @@ binary_allowed(policy, exec) if { b.path == ancestor } -# Binary matching: cmdline exact path (script interpreters — e.g. node runs claude script). -# When /usr/local/bin/claude has shebang #!/usr/bin/env node, the exe is /usr/bin/node -# but cmdline contains /usr/local/bin/claude as an argv entry. -binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] - not contains(b.path, "*") - cp := exec.cmdline_paths[_] - b.path == cp -} - -# Binary matching: glob pattern against path, any ancestor, or any cmdline path. +# Binary matching: glob pattern against exe path or any ancestor. +# NOTE: cmdline_paths are intentionally excluded — argv[0] is trivially +# spoofable via execve and must not be used as a grant-access signal. binary_allowed(policy, exec) if { some b in policy.binaries contains(b.path, "*") - all_paths := array.concat(array.concat([exec.path], exec.ancestors), exec.cmdline_paths) + all_paths := array.concat([exec.path], exec.ancestors) some p in all_paths glob.match(b.path, ["/"], p) } diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 6d83195e..61b026f5 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -51,9 +51,15 @@ impl L7Provider for RestProvider { } /// Parse one HTTP/1.1 request from the stream. +/// +/// Reads one byte at a time to stop exactly at the `\r\n\r\n` header +/// terminator. A multi-byte read could consume bytes belonging to a +/// subsequent pipelined request, and those overflow bytes would be +/// forwarded upstream without L7 policy evaluation -- a request +/// smuggling vulnerability. Byte-at-a-time overhead is negligible for +/// the typical 200-800 byte headers on L7-inspected REST endpoints. async fn parse_http_request(client: &mut C) -> Result> { let mut buf = Vec::with_capacity(4096); - let mut tmp = [0u8; 1024]; loop { if buf.len() > MAX_HEADER_BYTES { @@ -62,24 +68,19 @@ async fn parse_http_request(client: &mut C) -> Result n, + let byte = match client.read_u8().await { + Ok(b) => b, Err(e) if buf.is_empty() && is_benign_close(&e) => return Ok(None), + Err(e) if buf.is_empty() && e.kind() == std::io::ErrorKind::UnexpectedEof => { + return Ok(None); // Clean close before any data + } Err(e) => return Err(miette::miette!("{e}")), }; - if n == 0 { - if buf.is_empty() { - return Ok(None); // Clean connection close - } - return Err(miette!( - "Client disconnected mid-request after {} bytes", - buf.len() - )); - } - buf.extend_from_slice(&tmp[..n]); + buf.push(byte); - // Check for end of headers - if buf.windows(4).any(|w| w == b"\r\n\r\n") { + // Check for end of headers -- `ends_with` is sufficient because + // we append exactly one byte per iteration. + if buf.ends_with(b"\r\n\r\n") { break; } } @@ -109,7 +110,7 @@ async fn parse_http_request(client: &mut C) -> Result Result<()> { None }; - // Create and chown each read_write path + // Create and chown each read_write path. + // + // SECURITY: use symlink_metadata (lstat) to inspect each path *before* + // calling chown. chown follows symlinks, so a malicious container image + // could place a symlink (e.g. /sandbox -> /etc/shadow) to trick the + // root supervisor into transferring ownership of arbitrary files. + // The TOCTOU window between lstat and chown is not exploitable because + // no untrusted process is running yet (the child has not been forked). for path in &policy.filesystem.read_write { - if !path.exists() { + // Check for symlinks before touching the path. Character/block devices + // (e.g. /dev/null) are legitimate read_write entries and must be allowed. + if let Ok(meta) = std::fs::symlink_metadata(path) { + if meta.file_type().is_symlink() { + return Err(miette::miette!( + "read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)", + path.display() + )); + } + } else { debug!(path = %path.display(), "Creating read_write directory"); std::fs::create_dir_all(path).into_diagnostic()?; } diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index 705f0e62..a86fd683 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -1098,9 +1098,11 @@ network_policies: } #[test] - fn cmdline_path_matches_script_binary() { - // Simulates: node runs /usr/local/bin/my-tool (a script with shebang) - // exe = /usr/bin/node, cmdline contains /usr/local/bin/my-tool + fn cmdline_path_does_not_grant_access() { + // Simulates: node runs /usr/local/bin/my-tool (a script with shebang). + // exe = /usr/bin/node, cmdline contains /usr/local/bin/my-tool. + // cmdline_paths are attacker-controlled (argv[0] spoofing) and must + // NOT be used as a grant-access signal. let cmdline_data = r" network_policies: script_test: @@ -1121,11 +1123,9 @@ network_policies: }; let decision = engine.evaluate_network(&input).unwrap(); assert!( - decision.allowed, - "Expected allow via cmdline path match, got deny: {}", - decision.reason + !decision.allowed, + "cmdline_paths must not grant network access (argv[0] is spoofable)" ); - assert_eq!(decision.matched_policy.as_deref(), Some("script_test")); } #[test] @@ -1156,7 +1156,7 @@ network_policies: } #[test] - fn cmdline_glob_pattern_matches() { + fn cmdline_glob_pattern_does_not_grant_access() { let glob_data = r#" network_policies: glob_test: @@ -1177,9 +1177,8 @@ network_policies: }; let decision = engine.evaluate_network(&input).unwrap(); assert!( - decision.allowed, - "Expected glob to match cmdline path, got deny: {}", - decision.reason + !decision.allowed, + "cmdline_paths must not match globs for granting access (argv[0] is spoofable)" ); } @@ -1190,10 +1189,10 @@ network_policies: let input = NetworkInput { host: "api.anthropic.com".into(), port: 443, - binary_path: PathBuf::from("/usr/bin/node"), + binary_path: PathBuf::from("/usr/local/bin/claude"), binary_sha256: "unused".into(), ancestors: vec![], - cmdline_paths: vec![PathBuf::from("/usr/local/bin/claude")], + cmdline_paths: vec![], }; let decision = engine.evaluate_network(&input).unwrap(); assert!( diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index cb10b8ca..b93d125a 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -360,7 +360,18 @@ pub fn drop_privileges(policy: &SandboxPolicy) -> Result<()> { _ => None, }; + // If no user/group is configured and we are running as root, fall back to + // "sandbox:sandbox" instead of silently keeping root. This covers the + // local/dev-mode path where policies are loaded from disk and never pass + // through the server-side `ensure_sandbox_process_identity` normalization. + // For non-root runtimes, the no-op is safe -- we are already unprivileged. if user_name.is_none() && group_name.is_none() { + if nix::unistd::geteuid().is_root() { + let mut fallback = policy.clone(); + fallback.process.run_as_user = Some("sandbox".into()); + fallback.process.run_as_group = Some("sandbox".into()); + return drop_privileges(&fallback); + } return Ok(()); } @@ -522,7 +533,15 @@ mod tests { run_as_user: None, run_as_group: None, }); - assert!(drop_privileges(&policy).is_ok()); + if nix::unistd::geteuid().is_root() { + // As root, drop_privileges falls back to "sandbox:sandbox". + // If that user exists, it succeeds; if not (e.g. CI), it + // must error rather than silently keep root. + let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); + assert_eq!(drop_privileges(&policy).is_ok(), has_sandbox); + } else { + assert!(drop_privileges(&policy).is_ok()); + } } #[test] @@ -531,7 +550,12 @@ mod tests { run_as_user: Some(String::new()), run_as_group: Some(String::new()), }); - assert!(drop_privileges(&policy).is_ok()); + if nix::unistd::geteuid().is_root() { + let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); + assert_eq!(drop_privileges(&policy).is_ok(), has_sandbox); + } else { + assert!(drop_privileges(&policy).is_ok()); + } } #[test] diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 6fe26b37..44234f66 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -1647,6 +1647,34 @@ async fn handle_forward_proxy( }; let policy_str = matched_policy.as_deref().unwrap_or("-"); + // 4b. Reject if the endpoint has L7 config — the forward proxy path does + // not perform per-request method/path inspection, so L7-configured + // endpoints must go through the CONNECT tunnel where inspection happens. + if query_l7_config(&opa_engine, &decision, &host_lc, port).is_some() { + info!( + dst_host = %host_lc, + dst_port = port, + method = %method, + path = %path, + binary = %binary_str, + policy = %policy_str, + action = "deny", + reason = "endpoint has L7 rules; use CONNECT", + "FORWARD", + ); + emit_denial_simple( + denial_tx, + &host_lc, + port, + &binary_str, + &decision, + "endpoint has L7 rules configured; forward proxy bypasses L7 inspection — use CONNECT", + "forward-l7-bypass", + ); + respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; + return Ok(()); + } + // 5. DNS resolution + SSRF defence (mirrors the CONNECT path logic). // - If allowed_ips is set: validate resolved IPs against the allowlist // (this is the SSRF override for private IP destinations). diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 422b6463..a2c6a58f 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -3134,6 +3134,14 @@ fn hmac_sha256(key: &[u8], data: &[u8]) -> String { // Provider CRUD // --------------------------------------------------------------------------- +/// Strip credential values from a provider before returning it in a gRPC +/// response. Internal server paths (inference routing, sandbox env injection) +/// read credentials from the store directly and are unaffected. +fn redact_provider_credentials(mut provider: Provider) -> Provider { + provider.credentials.clear(); + provider +} + async fn create_provider_record( store: &crate::persistence::Store, mut provider: Provider, @@ -3169,7 +3177,7 @@ async fn create_provider_record( .await .map_err(|e| Status::internal(format!("persist provider failed: {e}")))?; - Ok(provider) + Ok(redact_provider_credentials(provider)) } async fn get_provider_record( @@ -3185,6 +3193,7 @@ async fn get_provider_record( .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found")) + .map(redact_provider_credentials) } async fn list_provider_records( @@ -3201,7 +3210,7 @@ async fn list_provider_records( for record in records { let provider = Provider::decode(record.payload.as_slice()) .map_err(|e| Status::internal(format!("decode provider failed: {e}")))?; - providers.push(provider); + providers.push(redact_provider_credentials(provider)); } Ok(providers) @@ -3270,7 +3279,7 @@ async fn update_provider_record( .await .map_err(|e| Status::internal(format!("persist provider failed: {e}")))?; - Ok(updated) + Ok(redact_provider_credentials(updated)) } async fn delete_provider_record( @@ -3428,14 +3437,23 @@ mod tests { .await .unwrap(); assert_eq!(updated.id, provider_id); - // Updated credential has new value. + // Credentials are redacted in responses. + assert!( + updated.credentials.is_empty(), + "credentials must be redacted in gRPC responses" + ); + // Verify the store still has full credentials. + let stored: Provider = store + .get_message_by_name("gitlab-local") + .await + .unwrap() + .unwrap(); assert_eq!( - updated.credentials.get("API_TOKEN"), + stored.credentials.get("API_TOKEN"), Some(&"rotated-token".to_string()) ); - // Non-updated credential is preserved (not clobbered). assert_eq!( - updated.credentials.get("SECONDARY"), + stored.credentials.get("SECONDARY"), Some(&"secondary-token".to_string()) ); // Updated config has new value. @@ -3528,21 +3546,21 @@ mod tests { assert_eq!(updated.id, persisted.id); assert_eq!(updated.r#type, "nvidia"); - assert_eq!(updated.credentials.len(), 2); - assert_eq!( - updated.credentials.get("API_TOKEN"), - Some(&"token-123".to_string()) - ); - assert_eq!( - updated.credentials.get("SECONDARY"), - Some(&"secondary-token".to_string()) - ); + // Credentials are redacted in responses. + assert!(updated.credentials.is_empty()); assert_eq!(updated.config.len(), 2); assert_eq!( updated.config.get("endpoint"), Some(&"https://example.com".to_string()) ); assert_eq!(updated.config.get("region"), Some(&"us-west".to_string())); + // Verify the store still has full credentials. + let stored: Provider = store + .get_message_by_name("noop-test") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.credentials.len(), 2); } #[tokio::test] @@ -3568,18 +3586,26 @@ mod tests { .await .unwrap(); - assert_eq!(updated.credentials.len(), 1); - assert_eq!( - updated.credentials.get("API_TOKEN"), - Some(&"token-123".to_string()) - ); - assert!(updated.credentials.get("SECONDARY").is_none()); + // Credentials are redacted in responses. + assert!(updated.credentials.is_empty()); assert_eq!(updated.config.len(), 1); assert_eq!( updated.config.get("endpoint"), Some(&"https://example.com".to_string()) ); assert!(updated.config.get("region").is_none()); + // Verify the store has the correct credential state (SECONDARY deleted). + let stored: Provider = store + .get_message_by_name("delete-key-test") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.credentials.len(), 1); + assert_eq!( + stored.credentials.get("API_TOKEN"), + Some(&"token-123".to_string()) + ); + assert!(stored.credentials.get("SECONDARY").is_none()); } #[tokio::test] diff --git a/crates/openshell-server/src/sandbox/mod.rs b/crates/openshell-server/src/sandbox/mod.rs index 7b1272a8..bb943c12 100644 --- a/crates/openshell-server/src/sandbox/mod.rs +++ b/crates/openshell-server/src/sandbox/mod.rs @@ -969,13 +969,14 @@ fn sandbox_template_to_k8s( serde_json::Value::Array(vec![serde_json::Value::Object(container)]), ); - // Add TLS secret volume. + // Add TLS secret volume. Mode 0400 (owner-read) prevents the + // unprivileged sandbox user from reading the mTLS private key. if !client_tls_secret_name.is_empty() { spec.insert( "volumes".to_string(), serde_json::json!([{ "name": "openshell-client-tls", - "secret": { "secretName": client_tls_secret_name } + "secret": { "secretName": client_tls_secret_name, "defaultMode": 256 } }]), ); } @@ -1047,7 +1048,8 @@ fn inject_pod_template( ); } - // Inject TLS volume at the pod spec level. + // Inject TLS volume at the pod spec level. Mode 0400 (owner-read) + // prevents the unprivileged sandbox user from reading the mTLS private key. if !client_tls_secret_name.is_empty() { let volumes = spec .entry("volumes") @@ -1055,7 +1057,7 @@ fn inject_pod_template( if let Some(volumes_arr) = volumes.as_array_mut() { volumes_arr.push(serde_json::json!({ "name": "openshell-client-tls", - "secret": { "secretName": client_tls_secret_name } + "secret": { "secretName": client_tls_secret_name, "defaultMode": 256 } })); } } @@ -2071,4 +2073,37 @@ mod tests { .expect("hostAliases should exist in custom pod template"); assert_eq!(host_aliases[0]["ip"], "192.168.65.2"); } + + #[test] + fn tls_secret_volume_uses_restrictive_default_mode() { + let template = SandboxTemplate::default(); + let pod_template = sandbox_template_to_k8s( + &template, + false, + "openshell/sandbox:latest", + "", + "sandbox-id", + "sandbox-name", + "https://gateway.example.com", + "0.0.0.0:2222", + "secret", + 300, + &std::collections::HashMap::new(), + "my-tls-secret", + "", + ); + + let volumes = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist"); + let tls_vol = volumes + .iter() + .find(|v| v["name"] == "openshell-client-tls") + .expect("TLS volume should exist"); + assert_eq!( + tls_vol["secret"]["defaultMode"], + 256, // 0o400 + "TLS secret volume must use mode 0400 to prevent sandbox user from reading the private key" + ); + } } diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 899b6e46..2cdba29c 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -280,8 +280,8 @@ def test_update_provider_preserves_unset_credentials_and_config( got = stub.GetProvider(openshell_pb2.GetProviderRequest(name=name)) p = got.provider - assert p.credentials["KEY_A"] == "rotated-a" - assert p.credentials["KEY_B"] == "val-b", "KEY_B should be preserved" + # Credentials are redacted in gRPC responses (security hardening). + assert len(p.credentials) == 0, "credentials must be redacted in gRPC responses" assert p.config["BASE_URL"] == "https://example.com", ( "config should be preserved" ) @@ -320,7 +320,8 @@ def test_update_provider_empty_maps_preserves_all( got = stub.GetProvider(openshell_pb2.GetProviderRequest(name=name)) p = got.provider - assert p.credentials["TOKEN"] == "secret" + # Credentials are redacted in gRPC responses (security hardening). + assert len(p.credentials) == 0, "credentials must be redacted in gRPC responses" assert p.config["URL"] == "https://api.example.com" finally: _delete_provider(stub, name) @@ -358,9 +359,8 @@ def test_update_provider_merges_config_preserves_credentials( got = stub.GetProvider(openshell_pb2.GetProviderRequest(name=name)) p = got.provider - assert p.credentials["API_KEY"] == "original-key", ( - "credentials should be untouched" - ) + # Credentials are redacted in gRPC responses (security hardening). + assert len(p.credentials) == 0, "credentials must be redacted in gRPC responses" assert p.config["ENDPOINT"] == "https://new.example.com" finally: _delete_provider(stub, name) diff --git a/e2e/rust/tests/forward_proxy_l7_bypass.rs b/e2e/rust/tests/forward_proxy_l7_bypass.rs new file mode 100644 index 00000000..3e913607 --- /dev/null +++ b/e2e/rust/tests/forward_proxy_l7_bypass.rs @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Regression test: the forward proxy path must reject requests to endpoints +//! that have L7 rules configured. Before the fix, plain `http://` requests +//! (which use the forward proxy, not CONNECT) bypassed per-request method/path +//! enforcement entirely. + +#![cfg(feature = "e2e")] + +use std::io::Write; +use std::process::Command; +use std::time::Duration; + +use openshell_e2e::harness::port::find_free_port; +use openshell_e2e::harness::sandbox::SandboxGuard; +use tempfile::NamedTempFile; +use tokio::time::{interval, timeout}; + +const TEST_SERVER_IMAGE: &str = "python:3.13-alpine"; + +struct DockerServer { + port: u16, + container_id: String, +} + +impl DockerServer { + async fn start() -> Result { + let port = find_free_port(); + let script = r#"from http.server import BaseHTTPRequestHandler, HTTPServer + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok":true}') + def do_DELETE(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok":true}') + def log_message(self, format, *args): + pass + +HTTPServer(("0.0.0.0", 8000), Handler).serve_forever() +"#; + + let output = Command::new("docker") + .args([ + "run", + "--detach", + "--rm", + "-p", + &format!("{port}:8000"), + TEST_SERVER_IMAGE, + "python3", + "-c", + script, + ]) + .output() + .map_err(|e| format!("start docker test server: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(format!( + "docker run failed (exit {:?}):\n{stderr}", + output.status.code() + )); + } + + let server = Self { + port, + container_id: stdout, + }; + server.wait_until_ready().await?; + Ok(server) + } + + async fn wait_until_ready(&self) -> Result<(), String> { + let container_id = self.container_id.clone(); + timeout(Duration::from_secs(30), async move { + let mut tick = interval(Duration::from_millis(500)); + loop { + tick.tick().await; + let output = Command::new("docker") + .args([ + "exec", + &container_id, + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000', timeout=1).read()", + ]) + .output() + .ok(); + if output.is_some_and(|o| o.status.success()) { + return; + } + } + }) + .await + .map_err(|_| "docker test server did not become ready within 30s".to_string()) + } +} + +impl Drop for DockerServer { + fn drop(&mut self) { + let _ = Command::new("docker") + .args(["rm", "-f", &self.container_id]) + .output(); + } +} + +fn write_policy_with_l7_rules(port: u16) -> Result { + let mut file = NamedTempFile::new().map_err(|e| format!("create temp policy file: {e}"))?; + let policy = format!( + r#"version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + test_l7: + name: test_l7 + endpoints: + - host: host.openshell.internal + port: {port} + protocol: rest + allowed_ips: + - "172.0.0.0/8" + rules: + - allow: + method: GET + path: /allowed + binaries: + - path: /usr/bin/curl + - path: /usr/bin/python* + - path: /usr/local/bin/python* +"# + ); + file.write_all(policy.as_bytes()) + .map_err(|e| format!("write temp policy file: {e}"))?; + file.flush() + .map_err(|e| format!("flush temp policy file: {e}"))?; + Ok(file) +} + +/// The forward proxy path (plain http:// via HTTP_PROXY) must return 403 for +/// endpoints with L7 rules, forcing clients through the CONNECT tunnel where +/// per-request method/path inspection actually happens. +#[tokio::test] +async fn forward_proxy_rejects_l7_configured_endpoint() { + let server = DockerServer::start() + .await + .expect("start docker test server"); + let policy = write_policy_with_l7_rules(server.port).expect("write custom policy"); + let policy_path = policy + .path() + .to_str() + .expect("temp policy path should be utf-8") + .to_string(); + + // Python script that tries a plain http:// request (forward proxy path). + // HTTP_PROXY is set automatically by the sandbox, so urllib will use the + // forward proxy for http:// URLs (not CONNECT). + let script = format!( + r#" +import urllib.request, urllib.error, json, sys +url = "http://host.openshell.internal:{port}/allowed" +try: + resp = urllib.request.urlopen(url, timeout=15) + print(json.dumps({{"status": resp.status, "error": None}})) +except urllib.error.HTTPError as e: + print(json.dumps({{"status": e.code, "error": str(e)}})) +except Exception as e: + print(json.dumps({{"status": -1, "error": str(e)}})) +"#, + port = server.port, + ); + + let guard = SandboxGuard::create(&[ + "--policy", + &policy_path, + "--", + "python3", + "-c", + &script, + ]) + .await + .expect("sandbox create"); + + // The forward proxy should return 403 because the endpoint has L7 rules. + assert!( + guard.create_output.contains("\"status\": 403"), + "expected 403 from forward proxy for L7-configured endpoint, got:\n{}", + guard.create_output + ); +}