Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,24 @@ 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 <token>` or the standard
`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
sandbox pod IP; supervisors connect outbound and bridge only the explicit target
Expand Down
66 changes: 49 additions & 17 deletions crates/openshell-server/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,22 @@ async fn render_metrics(State(handle): State<PrometheusHandle>) -> impl IntoResp
}

/// Create the HTTP router served on the multiplexed gateway port.
pub fn http_router(state: Arc<crate::ServerState>) -> Router {
pub fn http_router(
state: Arc<crate::ServerState>,
peer_identity: Option<crate::auth::identity::Identity>,
) -> 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.
Expand All @@ -194,6 +203,12 @@ pub fn http_router(state: Arc<crate::ServerState>) -> Router {
pub fn service_http_router(state: Arc<crate::ServerState>) -> Router {
Router::new()
.fallback(sandbox_service_routing_only)
.layer(axum::Extension(
crate::service_routing::ServiceRequestAuthContext {
peer_identity: None,
trusted_local: true,
},
))
.with_state(state)
}

Expand All @@ -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,
Expand All @@ -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(),
Expand All @@ -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);
Expand All @@ -267,9 +282,9 @@ struct Origin {
port: u16,
}

fn request_origin(req: &Request) -> Option<Origin> {
fn request_origin(req: &Request, scheme: &str) -> Option<Origin> {
let host = crate::service_routing::request_host(req)?;
parse_origin_authority("http", host)
parse_origin_authority(scheme, host)
}

fn parse_origin(value: &str) -> Option<Origin> {
Expand Down Expand Up @@ -330,6 +345,7 @@ fn normalize_host(host: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use tower::ServiceExt;

fn service_request(headers: &[(&str, &str)]) -> Request {
let mut builder = Request::builder()
Expand All @@ -345,50 +361,50 @@ 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]
fn plaintext_service_browser_context_requires_matching_origin() {
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]
Expand All @@ -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]
Expand All @@ -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\""
);
}
}

Expand Down
16 changes: 12 additions & 4 deletions crates/openshell-server/src/multiplex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<AuthenticatorChain> {
pub fn build_authenticator_chain(state: &ServerState) -> Option<AuthenticatorChain> {
let mut authenticators: Vec<Arc<dyn crate::auth::authenticator::Authenticator>> = Vec::new();
if let Some(driver) = state.compute_driver_authenticator.clone() {
authenticators.push(driver);
Expand Down Expand Up @@ -941,7 +949,7 @@ impl<S> AuthGrpcRouter<S> {
}
}

fn unauthenticated_dev_user_principal() -> Principal {
pub fn unauthenticated_dev_user_principal() -> Principal {
Principal::User(UserPrincipal {
identity: Identity {
subject: "unauthenticated-local-dev".to_string(),
Expand Down
Loading
Loading