mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(oidc): allow_private_network opt-in for self-hosted IdPs
The SSRF guard on OIDC endpoint URLs hard-refused any hostname
resolving to a non-public address, which made it impossible to use a
self-hosted IdP (Keycloak, Authentik, Dex) on an internal network —
even though the login-flow issuer is operator-configured, i.e. trusted
input.
Add [oidc] allow_private_network in config.toml (or
TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK), default off. When set, the
issuer and its discovered endpoints may resolve to private-range,
unique-local, CGNAT, and loopback addresses. Link-local, multicast,
unspecified, and reserved ranges stay refused regardless — cloud
metadata services live on link-local and no legitimate IdP does. The
HTTPS requirement and same-origin endpoint checks are unchanged.
The private-address refusal now raises OAuthSSRFPrivateAddressError,
and the OIDC wrapper appends the remediation hint to the error message
so the failure is self-service. mcp_oauth call sites — where endpoint
URLs come from untrusted remote-server metadata — do not get the knob
and keep the strict public-address rule.
(cherry picked from commit 9c74673fd4)
This commit is contained in:
@@ -41,6 +41,7 @@ are set.
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
|
||||
| `TURNSTONE_OIDC_REDIRECT_BASE` | Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
|
||||
| `TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS` | No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See [Cross-host endpoints](#cross-host-endpoints). |
|
||||
| `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK` | No | `false` | Allow the issuer (and its discovered endpoints) to resolve to private/internal addresses — needed for a self-hosted IdP on an internal network. See [Self-hosted and internal IdPs](#self-hosted-and-internal-idps). |
|
||||
|
||||
All four required fields — issuer, client ID, client secret, and
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
|
||||
@@ -99,6 +100,40 @@ The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts —
|
||||
this knob only relaxes the same-origin check, not the security gates.
|
||||
Each entry is a hostname (no scheme, no path).
|
||||
|
||||
### Self-hosted and internal IdPs
|
||||
|
||||
By default Turnstone refuses an issuer whose hostname resolves to a
|
||||
private or internal address:
|
||||
|
||||
```
|
||||
OIDCError: endpoint URL resolves to non-public address (10.0.0.5): https://auth.example.site
|
||||
```
|
||||
|
||||
This is SSRF hardening, not a licensing or product restriction: the OIDC
|
||||
flow makes server-side HTTP requests (discovery, JWKS, token exchange),
|
||||
and refusing non-public destinations keeps a mistyped or maliciously
|
||||
steered issuer from aiming those fetches at internal services. For a
|
||||
self-hosted IdP (Keycloak, Authentik, Dex, …) on a private network,
|
||||
opt in explicitly in `config.toml`:
|
||||
|
||||
```toml
|
||||
[oidc]
|
||||
allow_private_network = true
|
||||
```
|
||||
|
||||
or via `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true` (the env var wins
|
||||
when both are set).
|
||||
|
||||
The opt-in admits private-range (RFC 1918), unique-local, CGNAT
|
||||
(100.64/10 — tailnets), and loopback addresses. Link-local, multicast,
|
||||
and reserved ranges stay refused even with the opt-in — cloud metadata
|
||||
services (169.254.169.254) live there, and no legitimate IdP does. The
|
||||
HTTPS requirement and the same-origin endpoint checks are unaffected.
|
||||
|
||||
This knob only affects the login-flow IdP configured here. OAuth
|
||||
endpoints advertised by remote MCP servers are untrusted input and are
|
||||
always held to the strict public-address rule.
|
||||
|
||||
### config.toml alternative
|
||||
|
||||
```toml
|
||||
@@ -111,6 +146,8 @@ provider_name = "Google"
|
||||
role_claim = "groups"
|
||||
password_enabled = true
|
||||
redirect_base = "https://app.example.com"
|
||||
# Self-hosted IdP on an internal network (see "Self-hosted and internal IdPs")
|
||||
allow_private_network = false
|
||||
|
||||
[oidc.role_map]
|
||||
admin = "builtin-admin"
|
||||
|
||||
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from turnstone.core.oauth_ssrf import (
|
||||
OAuthSSRFError,
|
||||
OAuthSSRFPrivateAddressError,
|
||||
effective_port,
|
||||
is_localhost,
|
||||
validate_discovered_endpoint,
|
||||
@@ -86,6 +87,54 @@ class TestValidateUrlNoSSRF:
|
||||
):
|
||||
validate_url_no_ssrf("https://corp.example.com", allow_http=False)
|
||||
|
||||
def test_private_address_raises_distinct_subclass(self) -> None:
|
||||
# Callers with an operator opt-in (OIDC) catch the subclass to
|
||||
# append the remediation hint; plain OAuthSSRFError catches still work.
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=self._PRIVATE_ADDR),
|
||||
pytest.raises(OAuthSSRFPrivateAddressError),
|
||||
):
|
||||
validate_url_no_ssrf("https://corp.example.com", allow_http=False)
|
||||
|
||||
def test_allow_private_accepts_rfc1918(self) -> None:
|
||||
with patch("socket.getaddrinfo", return_value=self._PRIVATE_ADDR):
|
||||
parsed = validate_url_no_ssrf(
|
||||
"https://auth.corp.example.com", allow_http=False, allow_private=True
|
||||
)
|
||||
assert parsed.hostname == "auth.corp.example.com"
|
||||
|
||||
def test_allow_private_accepts_cgnat(self) -> None:
|
||||
# 100.64/10 (RFC 6598, shared address space) — e.g. a tailnet-hosted IdP.
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("100.64.0.7", 0))]):
|
||||
validate_url_no_ssrf("https://idp.tail.example", allow_http=False, allow_private=True)
|
||||
|
||||
def test_allow_private_accepts_loopback_hostname(self) -> None:
|
||||
# A non-localhost hostname resolving to loopback (IdP behind a
|
||||
# local reverse proxy) is operator-trusted under the opt-in.
|
||||
with patch("socket.getaddrinfo", return_value=self._LOOPBACK_ADDR):
|
||||
validate_url_no_ssrf("https://auth.internal", allow_http=False, allow_private=True)
|
||||
|
||||
def test_allow_private_still_rejects_link_local(self) -> None:
|
||||
# Cloud metadata services live on link-local; no legitimate IdP does.
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("169.254.169.254", 0))]),
|
||||
pytest.raises(OAuthSSRFError, match="refused even with private"),
|
||||
):
|
||||
validate_url_no_ssrf("https://md.example.com", allow_http=False, allow_private=True)
|
||||
|
||||
def test_allow_private_still_rejects_unspecified(self) -> None:
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("0.0.0.0", 0))]),
|
||||
pytest.raises(OAuthSSRFError, match="refused even with private"),
|
||||
):
|
||||
validate_url_no_ssrf("https://zero.example.com", allow_http=False, allow_private=True)
|
||||
|
||||
def test_allow_private_does_not_relax_https(self) -> None:
|
||||
with pytest.raises(OAuthSSRFError, match="must use HTTPS"):
|
||||
validate_url_no_ssrf(
|
||||
"http://auth.corp.example.com", allow_http=False, allow_private=True
|
||||
)
|
||||
|
||||
def test_rejects_unresolvable(self) -> None:
|
||||
import socket
|
||||
|
||||
@@ -122,6 +171,19 @@ class TestValidateDiscoveredEndpoint:
|
||||
trusted_endpoint_hosts=frozenset(),
|
||||
)
|
||||
|
||||
def test_allow_private_passes_through(self) -> None:
|
||||
# Same-origin endpoint on a private-resolving issuer host is accepted
|
||||
# when the operator opted in.
|
||||
issuer = urllib.parse.urlparse("https://auth.corp.example.com")
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
|
||||
validate_discovered_endpoint(
|
||||
"https://auth.corp.example.com/token",
|
||||
issuer,
|
||||
allow_http=False,
|
||||
trusted_endpoint_hosts=frozenset(),
|
||||
allow_private=True,
|
||||
)
|
||||
|
||||
def test_trusted_endpoint_host_passes(self) -> None:
|
||||
issuer = urllib.parse.urlparse("https://idp.example.com")
|
||||
with patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR):
|
||||
|
||||
@@ -90,6 +90,42 @@ class TestLoadOIDCConfig:
|
||||
assert cfg.scopes == "openid"
|
||||
assert cfg.provider_name == "Okta"
|
||||
|
||||
def test_load_oidc_config_allow_private_network_env(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.internal.example")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", "true")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.allow_private_network is True
|
||||
|
||||
def test_load_oidc_config_allow_private_network_toml(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.internal.example")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", raising=False)
|
||||
|
||||
with patch(
|
||||
"turnstone.core.config.load_config",
|
||||
return_value={"allow_private_network": True},
|
||||
):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.allow_private_network is True
|
||||
|
||||
def test_load_oidc_config_allow_private_network_default_off(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", raising=False)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.allow_private_network is False
|
||||
|
||||
def test_load_oidc_config_disabled_when_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_ISSUER", raising=False)
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
|
||||
@@ -345,6 +381,27 @@ class TestValidateIssuerURL:
|
||||
):
|
||||
validate_issuer_url("https://idp.example.com")
|
||||
|
||||
def test_private_address_hint_mentions_opt_in(self):
|
||||
"""The rejection message points the operator at allow_private_network."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]),
|
||||
pytest.raises(OIDCError, match="allow_private_network"),
|
||||
):
|
||||
validate_issuer_url("https://auth.internal.example")
|
||||
|
||||
def test_allow_private_accepts_private_issuer(self):
|
||||
"""The opt-in accepts an issuer resolving to RFC 1918 space."""
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
|
||||
validate_issuer_url("https://auth.internal.example", allow_private=True)
|
||||
|
||||
def test_allow_private_still_rejects_link_local(self):
|
||||
"""Link-local (cloud metadata) is refused even with the opt-in."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("169.254.169.254", 0))]),
|
||||
pytest.raises(OIDCError, match="refused even with private"),
|
||||
):
|
||||
validate_issuer_url("https://md.internal.example", allow_private=True)
|
||||
|
||||
def test_rejects_http_non_localhost(self):
|
||||
"""HTTP is rejected for non-localhost hosts."""
|
||||
with pytest.raises(OIDCError, match="must use HTTPS"):
|
||||
@@ -2166,6 +2223,58 @@ class TestDiscoverOIDC:
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_private_issuer_rejected_by_default(self):
|
||||
"""Without the opt-in, a private-resolving issuer disables OIDC."""
|
||||
config = _make_config(
|
||||
issuer="https://auth.internal.example",
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
async def _run():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
|
||||
result = await discover_oidc(config)
|
||||
assert result.enabled is False
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_private_issuer_with_opt_in(self):
|
||||
"""allow_private_network=True lets a private-resolving IdP discover."""
|
||||
config = _make_config(
|
||||
issuer="https://auth.internal.example",
|
||||
allow_private_network=True,
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
discovery_doc = {
|
||||
"authorization_endpoint": "https://auth.internal.example/authorize",
|
||||
"token_endpoint": "https://auth.internal.example/token",
|
||||
"userinfo_endpoint": "https://auth.internal.example/userinfo",
|
||||
"jwks_uri": "https://auth.internal.example/jwks",
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = discovery_doc
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(lambda url: _async_return(mock_response))
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
result = await discover_oidc(config)
|
||||
|
||||
assert result.enabled is True
|
||||
assert result.token_endpoint == "https://auth.internal.example/token"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_failure(self):
|
||||
"""Mock httpx error -> enabled=False returned."""
|
||||
config = _make_config(
|
||||
|
||||
@@ -58,6 +58,17 @@ class OAuthSSRFError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class OAuthSSRFPrivateAddressError(OAuthSSRFError):
|
||||
"""A hostname resolved to a non-public address, specifically.
|
||||
|
||||
A distinct subclass so callers with an operator-facing opt-in
|
||||
(``[oidc] allow_private_network``) can catch this case and append the
|
||||
remediation hint, while callers with no such opt-in (``mcp_oauth``,
|
||||
where endpoint URLs come from untrusted remote-server metadata) keep
|
||||
catching :class:`OAuthSSRFError` and stay strict.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -92,13 +103,23 @@ def effective_port(parsed: urllib.parse.ParseResult) -> int | None:
|
||||
return {"http": 80, "https": 443}.get(parsed.scheme)
|
||||
|
||||
|
||||
def validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
|
||||
def validate_url_no_ssrf(
|
||||
url: str, *, allow_http: bool, allow_private: bool = False
|
||||
) -> urllib.parse.ParseResult:
|
||||
"""Run the scheme/userinfo/SSRF checks shared by issuer and discovered URLs.
|
||||
|
||||
Returns the parsed URL on success. Raises :class:`OAuthSSRFError` on
|
||||
failure. The ``allow_http`` flag is the only knob: when ``True``,
|
||||
``http://`` is accepted *if* the hostname is also a localhost form;
|
||||
when ``False``, only ``https://`` is accepted.
|
||||
failure. Two knobs: ``allow_http=True`` accepts ``http://`` *if* the
|
||||
hostname is also a localhost form (when ``False``, only ``https://``
|
||||
is accepted); ``allow_private=True`` accepts hostnames resolving to
|
||||
private-range addresses (RFC 1918, ULA, CGNAT, loopback) for
|
||||
operator-trusted URLs — a self-hosted IdP on an internal network.
|
||||
Even with ``allow_private``, link-local, multicast, unspecified, and
|
||||
reserved addresses stay refused: cloud metadata services
|
||||
(169.254.169.254) are the canonical SSRF target, and no legitimate
|
||||
IdP lives in those ranges. Non-public rejections raise the
|
||||
:class:`OAuthSSRFPrivateAddressError` subclass so callers that *have*
|
||||
an opt-in can point the operator at it.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
@@ -127,8 +148,19 @@ def validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseRes
|
||||
raise OAuthSSRFError(
|
||||
f"endpoint hostname resolved to invalid IP {sockaddr[0]!r}: {hostname}"
|
||||
) from exc
|
||||
if not addr.is_global and not is_localhost(hostname):
|
||||
raise OAuthSSRFError(f"endpoint URL resolves to non-public address ({addr}): {url}")
|
||||
if addr.is_global or is_localhost(hostname):
|
||||
continue
|
||||
if allow_private:
|
||||
if addr.is_link_local or addr.is_multicast or addr.is_unspecified or addr.is_reserved:
|
||||
raise OAuthSSRFError(
|
||||
f"endpoint URL resolves to a link-local/multicast/reserved "
|
||||
f"address ({addr}), refused even with private addresses "
|
||||
f"allowed: {url}"
|
||||
)
|
||||
continue
|
||||
raise OAuthSSRFPrivateAddressError(
|
||||
f"endpoint URL resolves to non-public address ({addr}): {url}"
|
||||
)
|
||||
|
||||
return parsed
|
||||
|
||||
@@ -139,6 +171,7 @@ def validate_discovered_endpoint(
|
||||
*,
|
||||
allow_http: bool,
|
||||
trusted_endpoint_hosts: frozenset[str],
|
||||
allow_private: bool = False,
|
||||
) -> None:
|
||||
"""Validate an endpoint URL pulled from an OIDC/OAuth discovery document.
|
||||
|
||||
@@ -146,11 +179,12 @@ def validate_discovered_endpoint(
|
||||
constraint: the endpoint host must equal the issuer host, be in the
|
||||
well-known trust map, or be in the operator-supplied
|
||||
``trusted_endpoint_hosts``. Effective port (with scheme defaults
|
||||
applied) and scheme must match the issuer.
|
||||
applied) and scheme must match the issuer. ``allow_private`` forwards
|
||||
to :func:`validate_url_no_ssrf`.
|
||||
|
||||
Raises :class:`OAuthSSRFError` on validation failure.
|
||||
"""
|
||||
parsed = validate_url_no_ssrf(url, allow_http=allow_http)
|
||||
parsed = validate_url_no_ssrf(url, allow_http=allow_http, allow_private=allow_private)
|
||||
|
||||
issuer_hostname = (issuer_parsed.hostname or "").lower()
|
||||
endpoint_hostname = (parsed.hostname or "").lower()
|
||||
@@ -182,7 +216,9 @@ def validate_discovered_endpoint(
|
||||
)
|
||||
|
||||
|
||||
async def validate_url_no_ssrf_async(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
|
||||
async def validate_url_no_ssrf_async(
|
||||
url: str, *, allow_http: bool, allow_private: bool = False
|
||||
) -> urllib.parse.ParseResult:
|
||||
"""Async variant of :func:`validate_url_no_ssrf` for hot-path callers.
|
||||
|
||||
The synchronous variant calls ``socket.getaddrinfo``, which blocks
|
||||
@@ -191,7 +227,9 @@ async def validate_url_no_ssrf_async(url: str, *, allow_http: bool) -> urllib.pa
|
||||
:func:`asyncio.to_thread` to keep the loop responsive. This wrapper
|
||||
centralises that wrapping so callers don't repeat the idiom.
|
||||
"""
|
||||
return await asyncio.to_thread(validate_url_no_ssrf, url, allow_http=allow_http)
|
||||
return await asyncio.to_thread(
|
||||
validate_url_no_ssrf, url, allow_http=allow_http, allow_private=allow_private
|
||||
)
|
||||
|
||||
|
||||
async def validate_discovered_endpoint_async(
|
||||
@@ -200,6 +238,7 @@ async def validate_discovered_endpoint_async(
|
||||
*,
|
||||
allow_http: bool,
|
||||
trusted_endpoint_hosts: frozenset[str],
|
||||
allow_private: bool = False,
|
||||
) -> None:
|
||||
"""Async variant of :func:`validate_discovered_endpoint`."""
|
||||
await asyncio.to_thread(
|
||||
@@ -208,12 +247,14 @@ async def validate_discovered_endpoint_async(
|
||||
issuer_parsed,
|
||||
allow_http=allow_http,
|
||||
trusted_endpoint_hosts=trusted_endpoint_hosts,
|
||||
allow_private=allow_private,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KNOWN_TRUSTED_OAUTH_ENDPOINT_HOSTS",
|
||||
"OAuthSSRFError",
|
||||
"OAuthSSRFPrivateAddressError",
|
||||
"effective_port",
|
||||
"is_localhost",
|
||||
"sanitize_log_text",
|
||||
|
||||
+47
-9
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.oauth_ssrf import (
|
||||
OAuthSSRFError,
|
||||
OAuthSSRFPrivateAddressError,
|
||||
is_localhost,
|
||||
)
|
||||
from turnstone.core.oauth_ssrf import (
|
||||
@@ -105,7 +106,7 @@ class OIDCConfig:
|
||||
Startup-config fields (set by :func:`load_oidc_config`):
|
||||
``enabled``, ``issuer``, ``client_id``, ``client_secret``, ``scopes``,
|
||||
``provider_name``, ``role_claim``, ``role_map``, ``password_enabled``,
|
||||
``redirect_base``, ``trusted_endpoint_hosts``.
|
||||
``redirect_base``, ``trusted_endpoint_hosts``, ``allow_private_network``.
|
||||
|
||||
Discovery-derived fields (set by :func:`discover_oidc`; empty before
|
||||
discovery completes):
|
||||
@@ -124,6 +125,10 @@ class OIDCConfig:
|
||||
password_enabled: bool = True
|
||||
redirect_base: str = ""
|
||||
trusted_endpoint_hosts: tuple[str, ...] = ()
|
||||
# Opt-in for self-hosted IdPs on internal networks: permit the issuer
|
||||
# (and its same-origin discovered endpoints) to resolve to private
|
||||
# addresses. Link-local/multicast/reserved stay refused regardless.
|
||||
allow_private_network: bool = False
|
||||
# Discovered from .well-known/openid-configuration
|
||||
authorization_endpoint: str = ""
|
||||
token_endpoint: str = ""
|
||||
@@ -189,6 +194,9 @@ def load_oidc_config() -> OIDCConfig:
|
||||
password_enabled = _env_or_cfg_bool(
|
||||
"TURNSTONE_OIDC_PASSWORD_ENABLED", cfg, "password_enabled", True
|
||||
)
|
||||
allow_private_network = _env_or_cfg_bool(
|
||||
"TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", cfg, "allow_private_network", False
|
||||
)
|
||||
|
||||
# Role map: env var is "admin:builtin-admin,eng:builtin-operator"
|
||||
role_map_raw = os.environ.get("TURNSTONE_OIDC_ROLE_MAP", "").strip()
|
||||
@@ -259,7 +267,12 @@ def load_oidc_config() -> OIDCConfig:
|
||||
enabled = bool(issuer and client_id and client_secret)
|
||||
|
||||
if enabled:
|
||||
log.info("OIDC enabled: issuer=%s provider=%s", issuer, provider_name)
|
||||
log.info(
|
||||
"OIDC enabled: issuer=%s provider=%s%s",
|
||||
issuer,
|
||||
provider_name,
|
||||
" (private-network IdP allowed)" if allow_private_network else "",
|
||||
)
|
||||
else:
|
||||
log.debug("OIDC not configured (issuer/client_id/client_secret incomplete)")
|
||||
|
||||
@@ -275,6 +288,7 @@ def load_oidc_config() -> OIDCConfig:
|
||||
password_enabled=password_enabled,
|
||||
redirect_base=redirect_base,
|
||||
trusted_endpoint_hosts=trusted_endpoint_hosts,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
|
||||
|
||||
@@ -287,25 +301,43 @@ def load_oidc_config() -> OIDCConfig:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
|
||||
"""OIDC-flavoured wrapper around :func:`oauth_ssrf.validate_url_no_ssrf`."""
|
||||
def _validate_url_no_ssrf(
|
||||
url: str, *, allow_http: bool, allow_private: bool = False
|
||||
) -> urllib.parse.ParseResult:
|
||||
"""OIDC-flavoured wrapper around :func:`oauth_ssrf.validate_url_no_ssrf`.
|
||||
|
||||
The private-address rejection gets the remediation hint appended: the
|
||||
login-flow issuer is operator-configured, so pointing the operator at
|
||||
the ``allow_private_network`` opt-in is safe here (unlike ``mcp_oauth``,
|
||||
where the URLs come from untrusted remote-server metadata and no such
|
||||
opt-in exists).
|
||||
"""
|
||||
try:
|
||||
return _ssrf_validate_url_no_ssrf(url, allow_http=allow_http)
|
||||
return _ssrf_validate_url_no_ssrf(url, allow_http=allow_http, allow_private=allow_private)
|
||||
except OAuthSSRFPrivateAddressError as exc:
|
||||
raise OIDCError(
|
||||
f"{exc} — to allow a self-hosted IdP on a private network, set "
|
||||
"allow_private_network = true in the [oidc] section of config.toml "
|
||||
"(or TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true)"
|
||||
) from exc
|
||||
except OAuthSSRFError as exc:
|
||||
raise OIDCError(str(exc)) from exc
|
||||
|
||||
|
||||
def validate_issuer_url(url: str) -> None:
|
||||
def validate_issuer_url(url: str, *, allow_private: bool = False) -> None:
|
||||
"""Validate an OIDC issuer URL to prevent SSRF.
|
||||
|
||||
Rejects:
|
||||
- Non-HTTPS URLs (except localhost for development)
|
||||
- URLs with embedded credentials (userinfo)
|
||||
- Hostnames that resolve to private/internal/loopback IP addresses
|
||||
- Hostnames that resolve to private/internal/loopback IP addresses,
|
||||
unless ``allow_private`` is set (the ``allow_private_network``
|
||||
opt-in for self-hosted IdPs; link-local/multicast/reserved
|
||||
addresses stay refused regardless)
|
||||
|
||||
Raises :class:`OIDCError` on validation failure.
|
||||
"""
|
||||
_validate_url_no_ssrf(url, allow_http=True)
|
||||
_validate_url_no_ssrf(url, allow_http=True, allow_private=allow_private)
|
||||
|
||||
|
||||
def validate_discovered_endpoint(
|
||||
@@ -314,6 +346,7 @@ def validate_discovered_endpoint(
|
||||
*,
|
||||
allow_http: bool,
|
||||
trusted_endpoint_hosts: frozenset[str],
|
||||
allow_private: bool = False,
|
||||
) -> None:
|
||||
"""Validate an endpoint pulled from an IdP discovery document.
|
||||
|
||||
@@ -340,6 +373,7 @@ def validate_discovered_endpoint(
|
||||
issuer_parsed,
|
||||
allow_http=allow_http,
|
||||
trusted_endpoint_hosts=trusted_endpoint_hosts,
|
||||
allow_private=allow_private,
|
||||
)
|
||||
except OAuthSSRFError as exc:
|
||||
raise OIDCError(str(exc)) from exc
|
||||
@@ -367,7 +401,9 @@ async def discover_oidc(
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
|
||||
try:
|
||||
issuer_parsed = _validate_url_no_ssrf(config.issuer, allow_http=True)
|
||||
issuer_parsed = _validate_url_no_ssrf(
|
||||
config.issuer, allow_http=True, allow_private=config.allow_private_network
|
||||
)
|
||||
except OIDCError as exc:
|
||||
log.warning("OIDC issuer URL rejected: %s", exc)
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
@@ -422,6 +458,7 @@ async def discover_oidc(
|
||||
issuer_parsed,
|
||||
allow_http=allow_http,
|
||||
trusted_endpoint_hosts=trusted_hosts,
|
||||
allow_private=config.allow_private_network,
|
||||
)
|
||||
except OIDCError as exc:
|
||||
log.warning("OIDC discovered %s rejected (url=%s): %s", name, endpoint_url, exc)
|
||||
@@ -434,6 +471,7 @@ async def discover_oidc(
|
||||
issuer_parsed,
|
||||
allow_http=allow_http,
|
||||
trusted_endpoint_hosts=trusted_hosts,
|
||||
allow_private=config.allow_private_network,
|
||||
)
|
||||
except OIDCError as exc:
|
||||
log.warning(
|
||||
|
||||
Reference in New Issue
Block a user