fix(services): prevent gateway bearer forwarding

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-09-20 23:29:17 -07:00
parent 917c884477
commit c7f47ebed1
3 changed files with 77 additions and 36 deletions
+13 -11
View File
@@ -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 <token>` 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
+63 -24
View File
@@ -222,23 +222,14 @@ async fn authorize_service_request(
context: ServiceRequestAuthContext,
workspace: &str,
) -> Result<ServiceRequestAuthorization, ServiceRouteError> {
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<Option<(ServiceCredentialSource, String)>, 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,
+1 -1
View File
@@ -62,7 +62,7 @@ curl \
Standard `Authorization: Bearer <token>` 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