From 917c884477d8a05c01546d397ad6db3b616d2c80 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sat, 19 Sep 2026 22:03:41 -0700 Subject: [PATCH 1/2] feat(services): authenticate exposed service requests Signed-off-by: Drew Newberry --- architecture/gateway.md | 16 + crates/openshell-server/src/http.rs | 66 ++- crates/openshell-server/src/multiplex.rs | 16 +- .../openshell-server/src/service_routing.rs | 421 +++++++++++++++++- docs/sandboxes/manage-gateways.mdx | 14 +- docs/security/best-practices.mdx | 4 +- 6 files changed, 503 insertions(+), 34 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index e77ed5c5cd..3e9102ad93 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -887,6 +887,22 @@ DNS SANs configured on the gateway server certificate, with loopback gateways print `http://` URLs when loopback plaintext service HTTP is enabled; non-loopback TLS gateways continue to print `https://` URLs. +The loopback plaintext path trusts the local machine. Every other service +request authenticates through the gateway and requires user access to the +workspace encoded in the service hostname. Clients can use +`OpenShell-Service-Authorization: Bearer ` or the standard +`Authorization` bearer header. A successful header-authenticated HTTPS request +also establishes an exact-host `__Host-OpenShell-Service-Authorization` +session cookie for browser and WebSocket requests. The gateway removes its +dedicated header, cookie, and trusted-proxy assertions before relaying the +request. When the dedicated header or cookie authenticates the gateway request, +an application-specific `Authorization` header remains available to the +sandbox service. When `Authorization` itself authenticates the gateway request, +the gateway consumes it. Sandbox responses cannot set the reserved gateway +session cookie. Service routing has no unauthenticated public exposure mode; +only the gateway-wide explicit unauthenticated-development override can relax +the remote user-auth boundary. + For `target.tcp`, the gateway only accepts loopback destinations such as `localhost`, `127.0.0.0/8`, or `::1`. The gateway never needs to know or dial a sandbox pod IP; supervisors connect outbound and bridge only the explicit target diff --git a/crates/openshell-server/src/http.rs b/crates/openshell-server/src/http.rs index 63b59edf04..ad8b89eeaa 100644 --- a/crates/openshell-server/src/http.rs +++ b/crates/openshell-server/src/http.rs @@ -178,13 +178,22 @@ async fn render_metrics(State(handle): State) -> impl IntoResp } /// Create the HTTP router served on the multiplexed gateway port. -pub fn http_router(state: Arc) -> Router { +pub fn http_router( + state: Arc, + peer_identity: Option, +) -> Router { crate::ws_tunnel::router(state.clone()) .merge(crate::auth::router(state.clone())) .layer(middleware::from_fn_with_state( state, sandbox_service_routing_first, )) + .layer(axum::Extension( + crate::service_routing::ServiceRequestAuthContext { + peer_identity, + trusted_local: false, + }, + )) } /// Create the plaintext loopback-only router for browser service endpoints. @@ -194,6 +203,12 @@ pub fn http_router(state: Arc) -> Router { pub fn service_http_router(state: Arc) -> Router { Router::new() .fallback(sandbox_service_routing_only) + .layer(axum::Extension( + crate::service_routing::ServiceRequestAuthContext { + peer_identity: None, + trusted_local: true, + }, + )) .with_state(state) } @@ -217,7 +232,7 @@ async fn sandbox_service_routing_only( if !crate::service_routing::is_sandbox_service_request(&req, &state.config.service_routing) { return StatusCode::NOT_FOUND.into_response(); } - if !browser_context_allows_plaintext_service_request(&req) { + if !browser_context_allows_service_request(&req, "http") { crate::service_routing::emit_cross_origin_service_http_rejection(&state, &req); return crate::service_routing::service_error_response( StatusCode::FORBIDDEN, @@ -229,7 +244,7 @@ async fn sandbox_service_routing_only( .into_response() } -fn browser_context_allows_plaintext_service_request(req: &Request) -> bool { +pub fn browser_context_allows_service_request(req: &Request, scheme: &str) -> bool { if let Some(fetch_site) = header_str(req.headers(), "sec-fetch-site") && !matches!( fetch_site.to_ascii_lowercase().as_str(), @@ -240,14 +255,14 @@ fn browser_context_allows_plaintext_service_request(req: &Request) -> bool { } if let Some(origin) = header_str(req.headers(), header::ORIGIN.as_str()) { - let Some(request_origin) = request_origin(req) else { + let Some(request_origin) = request_origin(req, scheme) else { return false; }; return parse_origin(origin).is_some_and(|origin| origin == request_origin); } if let Some(referer) = header_str(req.headers(), header::REFERER.as_str()) { - let Some(request_origin) = request_origin(req) else { + let Some(request_origin) = request_origin(req, scheme) else { return false; }; return parse_origin(referer).is_some_and(|origin| origin == request_origin); @@ -267,9 +282,9 @@ struct Origin { port: u16, } -fn request_origin(req: &Request) -> Option { +fn request_origin(req: &Request, scheme: &str) -> Option { let host = crate::service_routing::request_host(req)?; - parse_origin_authority("http", host) + parse_origin_authority(scheme, host) } fn parse_origin(value: &str) -> Option { @@ -330,6 +345,7 @@ fn normalize_host(host: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use tower::ServiceExt; fn service_request(headers: &[(&str, &str)]) -> Request { let mut builder = Request::builder() @@ -345,35 +361,35 @@ mod tests { fn plaintext_service_browser_context_allows_direct_tools() { let req = service_request(&[]); - assert!(browser_context_allows_plaintext_service_request(&req)); + assert!(browser_context_allows_service_request(&req, "http")); } #[test] fn plaintext_service_browser_context_allows_same_origin_fetch_metadata() { let req = service_request(&[("sec-fetch-site", "same-origin")]); - assert!(browser_context_allows_plaintext_service_request(&req)); + assert!(browser_context_allows_service_request(&req, "http")); } #[test] fn plaintext_service_browser_context_allows_direct_navigation_fetch_metadata() { let req = service_request(&[("sec-fetch-site", "none")]); - assert!(browser_context_allows_plaintext_service_request(&req)); + assert!(browser_context_allows_service_request(&req, "http")); } #[test] fn plaintext_service_browser_context_rejects_cross_site_fetch_metadata() { let req = service_request(&[("sec-fetch-site", "cross-site")]); - assert!(!browser_context_allows_plaintext_service_request(&req)); + assert!(!browser_context_allows_service_request(&req, "http")); } #[test] fn plaintext_service_browser_context_rejects_same_site_sibling_requests() { let req = service_request(&[("sec-fetch-site", "same-site")]); - assert!(!browser_context_allows_plaintext_service_request(&req)); + assert!(!browser_context_allows_service_request(&req, "http")); } #[test] @@ -381,14 +397,14 @@ mod tests { let req = service_request(&[("origin", "http://sandbox--web.dev.openshell.localhost:8080")]); - assert!(browser_context_allows_plaintext_service_request(&req)); + assert!(browser_context_allows_service_request(&req, "http")); let req = service_request(&[( "origin", "http://sandbox--other.dev.openshell.localhost:8080", )]); - assert!(!browser_context_allows_plaintext_service_request(&req)); + assert!(!browser_context_allows_service_request(&req, "http")); } #[test] @@ -398,14 +414,14 @@ mod tests { "http://sandbox--web.dev.openshell.localhost:8080/page", )]); - assert!(browser_context_allows_plaintext_service_request(&req)); + assert!(browser_context_allows_service_request(&req, "http")); let req = service_request(&[( "referer", "http://sandbox--other.dev.openshell.localhost:8080/page", )]); - assert!(!browser_context_allows_plaintext_service_request(&req)); + assert!(!browser_context_allows_service_request(&req, "http")); } #[test] @@ -415,7 +431,23 @@ mod tests { "https://sandbox--web.dev.openshell.localhost:8080", )]); - assert!(!browser_context_allows_plaintext_service_request(&req)); + assert!(!browser_context_allows_service_request(&req, "http")); + assert!(browser_context_allows_service_request(&req, "https")); + } + + #[tokio::test] + async fn multiplexed_service_route_requires_remote_authentication() { + let state = crate::grpc::test_support::test_server_state().await; + let response = http_router(state, None) + .oneshot(service_request(&[])) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers()[header::WWW_AUTHENTICATE], + "Bearer realm=\"openshell-service\"" + ); } } diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 2df0ea5403..475044908c 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -252,14 +252,22 @@ impl MultiplexService { .config .mtls_auth .enabled - .then_some(peer_identity) + .then_some(peer_identity.clone()) .flatten(), self.state.config.mtls_auth.enabled, self.state.config.auth.allow_unauthenticated_users, ); let grpc_service = GrpcRateLimitService::new(grpc_service, self.state.grpc_rate_limiter.clone()); - let http_service = http_router(self.state.clone()); + let http_service = http_router( + self.state.clone(), + self.state + .config + .mtls_auth + .enabled + .then_some(peer_identity) + .flatten(), + ); let grpc_service = request_id_middleware!(grpc_service); let http_service = request_id_middleware!(http_service); @@ -863,7 +871,7 @@ where /// When neither OIDC nor sandbox credentials are configured (a barebones /// dev gateway), the chain is left as `None` so the router short-circuits /// to pass-through unless mTLS or local unauthenticated users are enabled. -fn build_authenticator_chain(state: &ServerState) -> Option { +pub fn build_authenticator_chain(state: &ServerState) -> Option { let mut authenticators: Vec> = Vec::new(); if let Some(driver) = state.compute_driver_authenticator.clone() { authenticators.push(driver); @@ -941,7 +949,7 @@ impl AuthGrpcRouter { } } -fn unauthenticated_dev_user_principal() -> Principal { +pub fn unauthenticated_dev_user_principal() -> Principal { Principal::User(UserPrincipal { identity: Identity { subject: "unauthenticated-local-dev".to_string(), diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index e2b67e7311..c2803f899e 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -24,6 +24,10 @@ use tokio::io::AsyncWriteExt; use tracing::{info, warn}; use crate::ServerState; +use crate::auth::authz::AuthzPolicy; +use crate::auth::identity::Identity; +use crate::auth::principal::{Principal, UserPrincipal}; +use crate::auth::workspace_authz::{MinWorkspaceRole, authorize_workspace}; use crate::persistence::{ObjectType, Store}; const ENDPOINT_OBJECT_TYPE: &str = "service_endpoint"; @@ -31,6 +35,31 @@ const ROUTING_RULE_NAME: &str = "sandbox_service_routing"; const ROUTING_RULE_TYPE: &str = "gateway"; const RELAY_RULE_NAME: &str = "sandbox_service_relay"; const RELAY_TARGET_HOST: &str = "127.0.0.1"; +const SERVICE_AUTHORIZATION_HEADER: &str = "openshell-service-authorization"; +const SERVICE_AUTHORIZATION_COOKIE: &str = "__Host-OpenShell-Service-Authorization"; +const SERVICE_AUTHORIZATION_PATH: &str = "/openshell.v1.OpenShell/GetService"; + +#[derive(Clone, Debug)] +pub struct ServiceRequestAuthContext { + pub peer_identity: Option, + pub trusted_local: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ServiceCredentialSource { + DedicatedHeader, + Cookie, + AuthorizationHeader, + Mtls, + TrustedLocal, + DevelopmentOverride, +} + +#[derive(Clone, Debug)] +struct ServiceRequestAuthorization { + source: ServiceCredentialSource, + session_token: Option, +} impl ObjectType for ServiceEndpoint { fn object_type() -> &'static str { @@ -128,7 +157,7 @@ pub fn is_sandbox_service_request(req: &Request, config: &ServiceRoutingCo pub async fn proxy_sandbox_service_request( state: Arc, - req: Request, + mut req: Request, ) -> impl IntoResponse { let Some(host) = request_host(&req) else { return StatusCode::NOT_FOUND.into_response(); @@ -139,12 +168,188 @@ pub async fn proxy_sandbox_service_request( return StatusCode::NOT_FOUND.into_response(); }; + let auth_context = req + .extensions() + .get::() + .cloned() + .unwrap_or(ServiceRequestAuthContext { + peer_identity: None, + trusted_local: false, + }); + let authorization = + match authorize_service_request(&state, req.headers().clone(), auth_context, &workspace) + .await + { + Ok(authorization) => authorization, + Err(err) => { + emit_service_http_failure(&state, &req, &sandbox_name, &service_name, None, &err); + return err.into_response(); + } + }; + req.extensions_mut().insert(authorization.clone()); + let secure_service = endpoint_scheme(&state.config) == "https"; + if authorization.source == ServiceCredentialSource::Cookie + && !crate::http::browser_context_allows_service_request( + &req, + if secure_service { "https" } else { "http" }, + ) + { + let err = ServiceRouteError::cross_origin(); + emit_service_http_failure(&state, &req, &sandbox_name, &service_name, None, &err); + return err.into_response(); + } + match proxy_to_endpoint(state, req, &workspace, sandbox_name, service_name).await { - Ok(response) => response.into_response(), + Ok(mut response) => { + sanitize_upstream_set_cookie_headers(response.headers_mut()); + if secure_service + && let Some(token) = authorization.session_token + && let Ok(value) = HeaderValue::from_str(&format!( + "{SERVICE_AUTHORIZATION_COOKIE}={token}; Path=/; Secure; HttpOnly; SameSite=Lax" + )) + { + response.headers_mut().append(header::SET_COOKIE, value); + } + response.into_response() + } Err(err) => err.into_response(), } } +async fn authorize_service_request( + state: &ServerState, + headers: HeaderMap, + context: ServiceRequestAuthContext, + workspace: &str, +) -> Result { + let dedicated = bearer_token(&headers, SERVICE_AUTHORIZATION_HEADER)?; + let cookie = service_authorization_cookie(&headers)?; + if dedicated.is_some() && cookie.is_some() { + return Err(ServiceRouteError::invalid_authentication()); + } + + let (principal, source, session_token) = if let Some(token) = dedicated { + ( + authenticate_service_token(state, &token).await?, + ServiceCredentialSource::DedicatedHeader, + Some(token), + ) + } else if let Some(token) = cookie { + ( + authenticate_service_token(state, &token).await?, + ServiceCredentialSource::Cookie, + None, + ) + } else if context.trusted_local { + ( + crate::multiplex::unauthenticated_dev_user_principal(), + ServiceCredentialSource::TrustedLocal, + None, + ) + } else if state.config.mtls_auth.enabled + && let Some(identity) = context.peer_identity + { + ( + Principal::User(UserPrincipal { identity }), + ServiceCredentialSource::Mtls, + None, + ) + } else if let Some(token) = bearer_token(&headers, header::AUTHORIZATION.as_str())? { + ( + authenticate_service_token(state, &token).await?, + ServiceCredentialSource::AuthorizationHeader, + Some(token), + ) + } else if state.config.auth.allow_unauthenticated_users { + ( + crate::multiplex::unauthenticated_dev_user_principal(), + ServiceCredentialSource::DevelopmentOverride, + None, + ) + } else { + return Err(ServiceRouteError::authentication_required()); + }; + + let Principal::User(user) = &principal else { + return Err(ServiceRouteError::authentication_required()); + }; + if let Some(oidc) = &state.config.oidc { + AuthzPolicy { + admin_role: oidc.admin_role.clone(), + user_role: oidc.user_role.clone(), + scopes_enabled: !oidc.scopes_claim.is_empty(), + } + .check(&user.identity, SERVICE_AUTHORIZATION_PATH) + .map_err(ServiceRouteError::from_auth_status)?; + } + authorize_workspace( + state.store.as_ref(), + &state.admin_role, + &principal, + workspace, + MinWorkspaceRole::User, + ) + .await + .map_err(ServiceRouteError::from_auth_status)?; + + Ok(ServiceRequestAuthorization { + source, + session_token, + }) +} + +async fn authenticate_service_token( + state: &ServerState, + token: &str, +) -> Result { + let Some(chain) = crate::multiplex::build_authenticator_chain(state) else { + return Err(ServiceRouteError::authentication_required()); + }; + let mut headers = HeaderMap::new(); + let value = HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|_| ServiceRouteError::invalid_authentication())?; + headers.insert(header::AUTHORIZATION, value); + chain + .authenticate(&headers, SERVICE_AUTHORIZATION_PATH) + .await + .map_err(ServiceRouteError::from_auth_status)? + .ok_or_else(ServiceRouteError::authentication_required) +} + +fn bearer_token(headers: &HeaderMap, name: &str) -> Result, ServiceRouteError> { + let Some(value) = headers.get(name) else { + return Ok(None); + }; + let value = value + .to_str() + .map_err(|_| ServiceRouteError::invalid_authentication())?; + let token = value + .strip_prefix("Bearer ") + .filter(|token| !token.is_empty()) + .ok_or_else(ServiceRouteError::invalid_authentication)?; + Ok(Some(token.to_string())) +} + +fn service_authorization_cookie(headers: &HeaderMap) -> Result, ServiceRouteError> { + let mut token = None; + for value in headers.get_all(header::COOKIE) { + let value = value + .to_str() + .map_err(|_| ServiceRouteError::invalid_authentication())?; + for cookie in value.split(';') { + let Some((name, value)) = cookie.trim().split_once('=') else { + continue; + }; + if name == SERVICE_AUTHORIZATION_COOKIE + && (value.is_empty() || token.replace(value.to_string()).is_some()) + { + return Err(ServiceRouteError::invalid_authentication()); + } + } + } + Ok(token) +} + #[derive(Debug, Clone)] struct ServiceRouteError { status: StatusCode, @@ -201,6 +406,43 @@ impl ServiceRouteError { ) } + const fn authentication_required() -> Self { + Self::new( + StatusCode::UNAUTHORIZED, + "Service authentication required", + "service authentication required", + ) + } + + const fn invalid_authentication() -> Self { + Self::new( + StatusCode::BAD_REQUEST, + "Invalid service authentication", + "invalid service authentication", + ) + } + + const fn cross_origin() -> Self { + Self::new( + StatusCode::FORBIDDEN, + "Cross-origin service request rejected", + "cross-origin service request rejected", + ) + } + + fn from_auth_status(status: tonic::Status) -> Self { + match status.code() { + tonic::Code::Unauthenticated => Self::authentication_required(), + tonic::Code::PermissionDenied => Self::new( + StatusCode::FORBIDDEN, + "Service access denied", + "service access denied", + ), + tonic::Code::InvalidArgument => Self::invalid_authentication(), + _ => Self::internal_error(), + } + } + const fn internal_error() -> Self { Self::new( StatusCode::INTERNAL_SERVER_ERROR, @@ -212,7 +454,14 @@ impl ServiceRouteError { impl IntoResponse for ServiceRouteError { fn into_response(self) -> AxumResponse { - service_error_response(self.status, self.message) + let mut response = service_error_response(self.status, self.message); + if self.status == StatusCode::UNAUTHORIZED { + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + HeaderValue::from_static("Bearer realm=\"openshell-service\""), + ); + } + response } } @@ -460,6 +709,12 @@ fn build_upstream_request( .method(parts.method) .uri(uri) .version(http::Version::HTTP_11); + let strip_authorization = parts + .extensions + .get::() + .is_some_and(|authorization| { + authorization.source == ServiceCredentialSource::AuthorizationHeader + }); let headers = builder .headers_mut() @@ -467,7 +722,7 @@ fn build_upstream_request( for (name, value) in &parts.headers { if (is_hop_by_hop_header(name) && !(preserve_upgrade_headers && is_websocket_hop_by_hop_header(name))) - || is_gateway_auth_header(name) + || is_gateway_auth_header(name, strip_authorization) { continue; } @@ -547,15 +802,15 @@ fn is_websocket_hop_by_hop_header(name: &header::HeaderName) -> bool { matches!(name.as_str(), "connection" | "upgrade") } -fn is_gateway_auth_header(name: &header::HeaderName) -> bool { +fn is_gateway_auth_header(name: &header::HeaderName, strip_authorization: bool) -> bool { matches!( name.as_str(), - "authorization" + SERVICE_AUTHORIZATION_HEADER | "cf-access-jwt-assertion" | "x-forwarded-client-cert" | "x-ssl-client-cert" | "x-client-cert" - ) + ) || (strip_authorization && name == header::AUTHORIZATION) } fn sanitize_cookie_header(value: &HeaderValue) -> Option { @@ -577,7 +832,29 @@ fn sanitize_cookie_header(value: &HeaderValue) -> Option { } fn is_gateway_auth_cookie(name: &str) -> bool { - name.eq_ignore_ascii_case("CF_Authorization") || name.eq_ignore_ascii_case("cf-authorization") + name.eq_ignore_ascii_case("CF_Authorization") + || name.eq_ignore_ascii_case("cf-authorization") + || name == SERVICE_AUTHORIZATION_COOKIE +} + +fn sanitize_upstream_set_cookie_headers(headers: &mut HeaderMap) { + let retained = headers + .get_all(header::SET_COOKIE) + .iter() + .filter(|value| { + value + .to_str() + .ok() + .and_then(|value| value.split(';').next()) + .and_then(|cookie| cookie.trim().split_once('=')) + .is_none_or(|(name, _)| !is_gateway_auth_cookie(name.trim())) + }) + .cloned() + .collect::>(); + headers.remove(header::SET_COOKIE); + for value in retained { + headers.append(header::SET_COOKIE, value); + } } pub fn emit_service_endpoint_config_event(endpoint: &ServiceEndpoint, url: &str, created: bool) { @@ -1062,6 +1339,13 @@ mod tests { response.headers()[header::CONTENT_TYPE], "text/plain; charset=utf-8" ); + + let response = ServiceRouteError::authentication_required().into_response(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers()[header::WWW_AUTHENTICATE], + "Bearer realm=\"openshell-service\"" + ); } #[test] @@ -1137,23 +1421,40 @@ mod tests { #[test] fn strips_gateway_auth_headers_from_upstream_request() { - let request = Request::builder() + let mut request = Request::builder() .uri("https://my-sandbox--web.dev.openshell.localhost/path") .header(header::AUTHORIZATION, "Bearer gateway-token") + .header( + SERVICE_AUTHORIZATION_HEADER, + "Bearer dedicated-gateway-token", + ) .header("cf-access-jwt-assertion", "edge-token") .header("x-forwarded-client-cert", "cert") .header( header::COOKIE, - "theme=dark; CF_Authorization=edge-cookie; app=session", + format!( + "theme=dark; CF_Authorization=edge-cookie; {SERVICE_AUTHORIZATION_COOKIE}=service-cookie; app=session" + ), ) .header("x-app-header", "kept") .body(Body::empty()) .unwrap(); + request + .extensions_mut() + .insert(ServiceRequestAuthorization { + source: ServiceCredentialSource::AuthorizationHeader, + session_token: None, + }); let upstream = build_upstream_request(request, 8080, false).unwrap(); assert_eq!(upstream.uri(), "/path"); assert!(!upstream.headers().contains_key(header::AUTHORIZATION)); + assert!( + !upstream + .headers() + .contains_key(SERVICE_AUTHORIZATION_HEADER) + ); assert!(!upstream.headers().contains_key("cf-access-jwt-assertion")); assert!(!upstream.headers().contains_key("x-forwarded-client-cert")); assert_eq!( @@ -1163,6 +1464,106 @@ mod tests { assert_eq!(upstream.headers()["x-app-header"], "kept"); } + #[test] + fn dedicated_gateway_auth_preserves_application_authorization() { + let mut request = Request::builder() + .uri("https://my-sandbox--web.dev.openshell.localhost/path") + .header(header::AUTHORIZATION, "Bearer application-token") + .header(SERVICE_AUTHORIZATION_HEADER, "Bearer gateway-token") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ServiceRequestAuthorization { + source: ServiceCredentialSource::DedicatedHeader, + session_token: None, + }); + + let upstream = build_upstream_request(request, 8080, false).unwrap(); + + assert_eq!( + upstream.headers()[header::AUTHORIZATION], + "Bearer application-token" + ); + assert!( + !upstream + .headers() + .contains_key(SERVICE_AUTHORIZATION_HEADER) + ); + } + + #[test] + fn rejects_conflicting_or_malformed_service_credentials() { + let mut headers = HeaderMap::new(); + headers.insert( + SERVICE_AUTHORIZATION_HEADER, + HeaderValue::from_static("Basic wrong"), + ); + assert!(bearer_token(&headers, SERVICE_AUTHORIZATION_HEADER).is_err()); + + headers.insert( + header::COOKIE, + HeaderValue::from_static( + "__Host-OpenShell-Service-Authorization=one; __Host-OpenShell-Service-Authorization=two", + ), + ); + assert!(service_authorization_cookie(&headers).is_err()); + } + + #[tokio::test] + async fn remote_service_requires_authentication_but_loopback_is_trusted() { + let state = crate::grpc::test_support::test_server_state().await; + let remote = authorize_service_request( + &state, + HeaderMap::new(), + ServiceRequestAuthContext { + peer_identity: None, + trusted_local: false, + }, + "default", + ) + .await + .unwrap_err(); + assert_eq!(remote.status, StatusCode::UNAUTHORIZED); + + let local = authorize_service_request( + &state, + HeaderMap::new(), + ServiceRequestAuthContext { + peer_identity: None, + trusted_local: true, + }, + "default", + ) + .await + .unwrap(); + assert_eq!(local.source, ServiceCredentialSource::TrustedLocal); + } + + #[test] + fn strips_reserved_gateway_set_cookies_from_upstream_response() { + let mut headers = HeaderMap::new(); + headers.append( + header::SET_COOKIE, + HeaderValue::from_static("app=session; Path=/"), + ); + headers.append( + header::SET_COOKIE, + HeaderValue::from_static( + "__Host-OpenShell-Service-Authorization=forged; Path=/; Secure", + ), + ); + + sanitize_upstream_set_cookie_headers(&mut headers); + + let values = headers + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(); + assert_eq!(values, vec!["app=session; Path=/"]); + } + #[test] fn detects_websocket_upgrade_request() { let request = Request::builder() diff --git a/docs/sandboxes/manage-gateways.mdx b/docs/sandboxes/manage-gateways.mdx index 731fd52c12..6c66dbbdae 100644 --- a/docs/sandboxes/manage-gateways.mdx +++ b/docs/sandboxes/manage-gateways.mdx @@ -50,7 +50,19 @@ Disable the local browser path with `--enable-loopback-service-http=false` or `O Custom HTTPS service domains use the gateway server SAN configuration. Add a wildcard DNS SAN such as `*.apps.example.com` to the gateway certificate and pass the same SAN to the gateway with `--server-san` or `OPENSHELL_SERVER_SAN`. -For remote or non-loopback gateways, browser service URLs remain HTTPS and require normal gateway authentication. +For remote or non-loopback gateways, service URLs remain HTTPS and require an authenticated user with access to the service's workspace. Service exposure has no public or anonymous visibility mode. + +Non-browser clients can authenticate with a dedicated header: + +```shell +curl \ + --header "OpenShell-Service-Authorization: Bearer $OPENSHELL_TOKEN" \ + https://default--sandbox--web.apps.example.com/ +``` + +Standard `Authorization: Bearer ` also authenticates clients that cannot select a custom header. The gateway consumes that header before proxying. When the dedicated header or the OpenShell browser cookie authenticates the request, the gateway preserves an application-specific `Authorization` header for the sandbox service. + +After a successful header-authenticated HTTPS request, the gateway sets an exact-host, secure, HTTP-only `__Host-OpenShell-Service-Authorization` session cookie. Browsers include it automatically on later HTTP and WebSocket requests. The gateway strips its header and cookie before proxying and prevents sandbox responses from replacing the reserved cookie. ## Register an Existing Gateway diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index a011677c23..6b35c04e0a 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -263,9 +263,9 @@ Gateway transport uses TLS, with client certificate checks available where the d | Aspect | Detail | |---|---| -| Default | Local TLS bundles enable mTLS user authentication for single-user local gateways. Helm deployments generate mTLS certificates for transport, while sandbox supervisors authenticate API calls with gateway-minted sandbox JWTs. TLS-enabled loopback gateways also accept plaintext HTTP for sandbox service hostnames by default. | +| Default | Local TLS bundles enable mTLS user authentication for single-user local gateways. Helm deployments generate mTLS certificates for transport, while sandbox supervisors authenticate API calls with gateway-minted sandbox JWTs. TLS-enabled loopback gateways also accept plaintext HTTP for sandbox service hostnames by default. Non-loopback exposed services require gateway authentication and workspace access. | | What you can change | Configure OIDC or a trusted access proxy for multi-user gateways, set `OPENSHELL_ENABLE_MTLS_AUTH=true` for local single-user gateways, enable `server.auth.allowUnauthenticatedUsers=true` only for trusted local Kubernetes development or a fully trusted proxy, disable TLS only for trusted reverse-proxy setups, or disable loopback service HTTP with `--enable-loopback-service-http=false`. | -| Risk if relaxed | Disabling TLS removes transport-level protection entirely. Allowing unauthenticated users removes the gateway user-auth boundary and must not be exposed to shared or public networks. Treating transport certificates as shared user identity in Kubernetes would collapse user and sandbox trust boundaries. Loopback service HTTP is local-only and rejects cross-origin browser requests, but any local process can still reach exposed service URLs directly. | +| Risk if relaxed | Disabling TLS removes transport-level protection entirely. Allowing unauthenticated users removes both the control-plane and exposed-service user-auth boundaries and must not be exposed to shared or public networks. Treating transport certificates as shared user identity in Kubernetes would collapse user and sandbox trust boundaries. Loopback service HTTP is local-only and rejects cross-origin browser requests, but any local process can still reach exposed service URLs directly. | | Recommendation | Use local mTLS user authentication only for single-user Docker, Podman, and VM gateways. Use OIDC or a trusted access proxy for Kubernetes and shared deployments. | ### SSH Tunnel Authentication From c7f47ebed1e976fbaf6a71902da78ca44f2fd418 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 20 Sep 2026 23:29:17 -0700 Subject: [PATCH 2/2] fix(services): prevent gateway bearer forwarding Signed-off-by: Drew Newberry --- architecture/gateway.md | 24 ++--- .../openshell-server/src/service_routing.rs | 87 ++++++++++++++----- docs/sandboxes/manage-gateways.mdx | 2 +- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 3e9102ad93..355b8e1f72 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -891,17 +891,19 @@ The loopback plaintext path trusts the local machine. Every other service request authenticates through the gateway and requires user access to the workspace encoded in the service hostname. Clients can use `OpenShell-Service-Authorization: Bearer ` or the standard -`Authorization` bearer header. A successful header-authenticated HTTPS request -also establishes an exact-host `__Host-OpenShell-Service-Authorization` -session cookie for browser and WebSocket requests. The gateway removes its -dedicated header, cookie, and trusted-proxy assertions before relaying the -request. When the dedicated header or cookie authenticates the gateway request, -an application-specific `Authorization` header remains available to the -sandbox service. When `Authorization` itself authenticates the gateway request, -the gateway consumes it. Sandbox responses cannot set the reserved gateway -session cookie. Service routing has no unauthenticated public exposure mode; -only the gateway-wide explicit unauthenticated-development override can relax -the remote user-auth boundary. +`Authorization` bearer header. A successful HTTPS request authenticated by the +dedicated header also establishes an exact-host +`__Host-OpenShell-Service-Authorization` session cookie for browser and +WebSocket requests. The dedicated header replaces an existing service cookie; +standard `Authorization` authentication never mints one. The gateway removes +its dedicated header, cookie, and trusted-proxy assertions before relaying the +request. When the dedicated header or cookie authenticates the gateway +request, an application-specific `Authorization` header remains available to +the sandbox service. When `Authorization` itself authenticates the gateway +request, the gateway consumes it. Sandbox responses cannot set the reserved +gateway session cookie. Service routing has no unauthenticated public exposure +mode; only the gateway-wide explicit unauthenticated-development override can +relax the remote user-auth boundary. For `target.tcp`, the gateway only accepts loopback destinations such as `localhost`, `127.0.0.0/8`, or `::1`. The gateway never needs to know or dial a diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index c2803f899e..ba59850737 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -222,23 +222,14 @@ async fn authorize_service_request( context: ServiceRequestAuthContext, workspace: &str, ) -> Result { - let dedicated = bearer_token(&headers, SERVICE_AUTHORIZATION_HEADER)?; - let cookie = service_authorization_cookie(&headers)?; - if dedicated.is_some() && cookie.is_some() { - return Err(ServiceRouteError::invalid_authentication()); - } - - let (principal, source, session_token) = if let Some(token) = dedicated { - ( - authenticate_service_token(state, &token).await?, - ServiceCredentialSource::DedicatedHeader, - Some(token), - ) - } else if let Some(token) = cookie { + let credential = service_request_credential(&headers)?; + let (principal, source, session_token) = if let Some((source, token)) = credential { + let session_token = + (source == ServiceCredentialSource::DedicatedHeader).then(|| token.clone()); ( authenticate_service_token(state, &token).await?, - ServiceCredentialSource::Cookie, - None, + source, + session_token, ) } else if context.trusted_local { ( @@ -254,12 +245,6 @@ async fn authorize_service_request( ServiceCredentialSource::Mtls, None, ) - } else if let Some(token) = bearer_token(&headers, header::AUTHORIZATION.as_str())? { - ( - authenticate_service_token(state, &token).await?, - ServiceCredentialSource::AuthorizationHeader, - Some(token), - ) } else if state.config.auth.allow_unauthenticated_users { ( crate::multiplex::unauthenticated_dev_user_principal(), @@ -298,6 +283,21 @@ async fn authorize_service_request( }) } +fn service_request_credential( + headers: &HeaderMap, +) -> Result, ServiceRouteError> { + if let Some(token) = bearer_token(headers, SERVICE_AUTHORIZATION_HEADER)? { + return Ok(Some((ServiceCredentialSource::DedicatedHeader, token))); + } + if let Some(token) = bearer_token(headers, header::AUTHORIZATION.as_str())? { + return Ok(Some((ServiceCredentialSource::AuthorizationHeader, token))); + } + if let Some(token) = service_authorization_cookie(headers)? { + return Ok(Some((ServiceCredentialSource::Cookie, token))); + } + Ok(None) +} + async fn authenticate_service_token( state: &ServerState, token: &str, @@ -1470,13 +1470,22 @@ mod tests { .uri("https://my-sandbox--web.dev.openshell.localhost/path") .header(header::AUTHORIZATION, "Bearer application-token") .header(SERVICE_AUTHORIZATION_HEADER, "Bearer gateway-token") + .header( + header::COOKIE, + format!("{SERVICE_AUTHORIZATION_COOKIE}=old-gateway-token"), + ) .body(Body::empty()) .unwrap(); + let (source, token) = service_request_credential(request.headers()) + .unwrap() + .unwrap(); + assert_eq!(source, ServiceCredentialSource::DedicatedHeader); + assert_eq!(token, "gateway-token"); request .extensions_mut() .insert(ServiceRequestAuthorization { - source: ServiceCredentialSource::DedicatedHeader, - session_token: None, + source, + session_token: Some(token), }); let upstream = build_upstream_request(request, 8080, false).unwrap(); @@ -1490,10 +1499,40 @@ mod tests { .headers() .contains_key(SERVICE_AUTHORIZATION_HEADER) ); + assert!(!upstream.headers().contains_key(header::COOKIE)); + } + + #[test] + fn standard_gateway_authorization_precedes_cookie_and_is_stripped() { + let mut request = Request::builder() + .uri("https://my-sandbox--web.dev.openshell.localhost/path") + .header(header::AUTHORIZATION, "Bearer gateway-token") + .header( + header::COOKIE, + format!("{SERVICE_AUTHORIZATION_COOKIE}=old-gateway-token"), + ) + .body(Body::empty()) + .unwrap(); + let (source, token) = service_request_credential(request.headers()) + .unwrap() + .unwrap(); + assert_eq!(source, ServiceCredentialSource::AuthorizationHeader); + assert_eq!(token, "gateway-token"); + request + .extensions_mut() + .insert(ServiceRequestAuthorization { + source, + session_token: None, + }); + + let upstream = build_upstream_request(request, 8080, false).unwrap(); + + assert!(!upstream.headers().contains_key(header::AUTHORIZATION)); + assert!(!upstream.headers().contains_key(header::COOKIE)); } #[test] - fn rejects_conflicting_or_malformed_service_credentials() { + fn rejects_malformed_service_credentials() { let mut headers = HeaderMap::new(); headers.insert( SERVICE_AUTHORIZATION_HEADER, diff --git a/docs/sandboxes/manage-gateways.mdx b/docs/sandboxes/manage-gateways.mdx index 6c66dbbdae..46149766f0 100644 --- a/docs/sandboxes/manage-gateways.mdx +++ b/docs/sandboxes/manage-gateways.mdx @@ -62,7 +62,7 @@ curl \ Standard `Authorization: Bearer ` also authenticates clients that cannot select a custom header. The gateway consumes that header before proxying. When the dedicated header or the OpenShell browser cookie authenticates the request, the gateway preserves an application-specific `Authorization` header for the sandbox service. -After a successful header-authenticated HTTPS request, the gateway sets an exact-host, secure, HTTP-only `__Host-OpenShell-Service-Authorization` session cookie. Browsers include it automatically on later HTTP and WebSocket requests. The gateway strips its header and cookie before proxying and prevents sandbox responses from replacing the reserved cookie. +After a successful HTTPS request authenticated by the dedicated header, the gateway sets an exact-host, secure, HTTP-only `__Host-OpenShell-Service-Authorization` session cookie. The dedicated header takes precedence over an existing cookie so clients can rotate the session safely. Standard `Authorization` authentication never creates a service cookie. Browsers include the cookie automatically on later HTTP and WebSocket requests. The gateway strips its header and cookie before proxying and prevents sandbox responses from replacing the reserved cookie. ## Register an Existing Gateway