From c7f47ebed1e976fbaf6a71902da78ca44f2fd418 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 20 Sep 2026 23:29:17 -0700 Subject: [PATCH] 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 3e9102ad9..355b8e1f7 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 c2803f899..ba5985073 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 { + 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::DedicatedHeader, - Some(token), - ) - } else if let Some(token) = cookie { - ( - 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 rejects_conflicting_or_malformed_service_credentials() { + 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_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 6c66dbbda..46149766f 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