@@ -130,6 +130,22 @@ def _normalize_bearer(
130130 return lambda : token
131131
132132
133+ def _is_loopback_host (hostname : str | None ) -> bool :
134+ if hostname is None :
135+ return False
136+ if hostname .lower () == "localhost" :
137+ return True
138+ with contextlib .suppress (ValueError ):
139+ return ipaddress .ip_address (hostname ).is_loopback
140+ return False
141+
142+
143+ def _is_local_grpc_endpoint (endpoint : str ) -> bool :
144+ if endpoint .startswith ("unix:" ):
145+ return True
146+ return _is_loopback_host (urlparse (f"//{ endpoint } " ).hostname )
147+
148+
133149def _validate_oauth_url (name : str , raw : str ) -> str :
134150 """Validate an OAuth endpoint without reflecting attacker-controlled URLs."""
135151 parsed = urlparse (raw )
@@ -139,11 +155,7 @@ def _validate_oauth_url(name: str, raw: str) -> str:
139155 raise SandboxError (f"OAuth { name } URL must not contain a fragment" )
140156 if parsed .scheme == "https" :
141157 return raw
142- loopback = parsed .hostname .lower () == "localhost"
143- if not loopback :
144- with contextlib .suppress (ValueError ):
145- loopback = ipaddress .ip_address (parsed .hostname ).is_loopback
146- if parsed .scheme == "http" and loopback :
158+ if parsed .scheme == "http" and _is_loopback_host (parsed .hostname ):
147159 return raw
148160 raise SandboxError (
149161 f"OAuth { name } URL must use HTTPS (HTTP is allowed only for loopback hosts)"
@@ -510,7 +522,8 @@ def __init__(
510522 the gateway uses mTLS for transport identity and OIDC
511523 for user identity.
512524 client_credentials: renewable OAuth client-credentials provider.
513- Mutually exclusive with `bearer_token`.
525+ Mutually exclusive with `bearer_token`. A non-loopback endpoint
526+ requires `tls` so the acquired bearer is never sent in cleartext.
514527 timeout: default per-call timeout in seconds.
515528 cluster_name: optional friendly name for error messages.
516529 _bearer_close: internal — wired by `from_active_cluster`
@@ -524,6 +537,14 @@ def __init__(
524537 raise SandboxError (
525538 "bearer_token and client_credentials are mutually exclusive"
526539 )
540+ if (
541+ client_credentials is not None
542+ and tls is None
543+ and not _is_local_grpc_endpoint (endpoint )
544+ ):
545+ raise SandboxError (
546+ "OAuth client credentials require TLS for non-loopback gateway endpoints"
547+ )
527548 self ._endpoint = endpoint
528549 self ._timeout = timeout
529550 self ._cluster_name = cluster_name
@@ -592,7 +613,7 @@ def from_active_cluster(
592613 client_credentials: renewable OAuth client-credentials provider.
593614 Omitted issuer, client ID, audience, and scopes are filled from
594615 the registered gateway metadata. This provider does not read or
595- write `oidc_token.json`.
616+ write `oidc_token.json`. Remote plaintext gateways are rejected.
596617 """
597618 cluster_name = cluster or _resolve_active_cluster ()
598619 gateway_dir = _xdg_config_home () / "openshell" / "gateways" / cluster_name
@@ -638,7 +659,6 @@ def from_active_cluster(
638659 f"gateway '{ cluster_name } ' is not configured for OIDC"
639660 )
640661 client_credentials ._apply_gateway_metadata (metadata , insecure = insecure )
641- bearer_token = client_credentials
642662 elif metadata .get ("auth_mode" ) == "oidc" :
643663 bearer_token , bearer_close = _make_cluster_bearer_provider (
644664 gateway_dir ,
@@ -652,6 +672,7 @@ def from_active_cluster(
652672 endpoint ,
653673 tls = tls ,
654674 bearer_token = bearer_token ,
675+ client_credentials = client_credentials ,
655676 timeout = timeout ,
656677 cluster_name = cluster_name ,
657678 _bearer_close = bearer_close ,
0 commit comments