fix: add OAUTH_REDIRECT_ALLOWLIST for client registration and waive the open default (SEC-05)
This commit is contained in:
1 parent
79aa4f7fc0
commit
8be859243d
7 files changed
+283
-11
No files matched your search
@@ -59,7 +59,7 @@ from pydantic import AnyHttpUrl, ValidationError
|
||||
|
||||
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
|
||||
from aegis_gitea_mcp.auth import MCP_SCOPE
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.config import Settings, redirect_matches_allowlist
|
||||
from aegis_gitea_mcp.oauth.store import (
|
||||
ClientRecord,
|
||||
OAuthStore,
|
||||
@@ -87,6 +87,7 @@ CONSENT_PATH = "/oauth/consent"
|
||||
|
||||
_LOOPBACK_REDIRECT_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
||||
_CLIENT_AUTH_METHODS = frozenset({"none", "client_secret_post", "client_secret_basic"})
|
||||
_NOT_ALLOWED = "redirect URI is not on this server's allowlist (OAUTH_REDIRECT_ALLOWLIST)"
|
||||
_REDIRECT_RULE = (
|
||||
"redirect URIs must use https://, or http:// on localhost, 127.0.0.1 or [::1], "
|
||||
"without credentials or a fragment"
|
||||
@@ -96,8 +97,12 @@ V = TypeVar("V")
|
||||
Clock = Callable[[], float]
|
||||
|
||||
|
||||
def redirect_uri_problem(uri: str) -> str | None:
|
||||
"""Return why ``uri`` is not an acceptable redirect URI, or None when it is."""
|
||||
def redirect_uri_problem(uri: str, allowlist: list[str] | None = None) -> str | None:
|
||||
"""Return why ``uri`` is not an acceptable redirect URI, or None when it is.
|
||||
|
||||
With a non-empty ``allowlist`` an https URI must also match it; http loopback
|
||||
URIs (native clients on the user's own machine) stay allowed.
|
||||
"""
|
||||
try:
|
||||
parts = urlsplit(uri)
|
||||
parts.port # noqa: B018 (accessing .port validates it)
|
||||
@@ -108,10 +113,10 @@ def redirect_uri_problem(uri: str) -> str | None:
|
||||
host = (parts.hostname or "").lower()
|
||||
if not host:
|
||||
return _REDIRECT_RULE
|
||||
if parts.scheme == "https":
|
||||
return None
|
||||
if parts.scheme == "http" and host in _LOOPBACK_REDIRECT_HOSTS:
|
||||
return None
|
||||
if parts.scheme == "https":
|
||||
return None if redirect_matches_allowlist(uri, allowlist or []) else _NOT_ALLOWED
|
||||
return _REDIRECT_RULE
|
||||
|
||||
|
||||
@@ -306,7 +311,8 @@ class GiteaOAuthProvider(
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Validate and persist a dynamically registered client.
|
||||
|
||||
Redirect URIs must be https, or http on a loopback host. When
|
||||
Redirect URIs must be https, or http on a loopback host, and match
|
||||
``OAUTH_REDIRECT_ALLOWLIST`` when that is set. When
|
||||
:data:`MAX_CLIENTS` is reached, the least recently used client without
|
||||
any session is evicted; when every client has a session, registration
|
||||
is refused.
|
||||
@@ -320,7 +326,7 @@ class GiteaOAuthProvider(
|
||||
"invalid_redirect_uri", f"between 1 and {MAX_REDIRECT_URIS} redirect URIs required"
|
||||
)
|
||||
for uri in uris:
|
||||
problem = redirect_uri_problem(uri)
|
||||
problem = redirect_uri_problem(uri, self._settings.oauth_redirect_allowlist)
|
||||
if problem is not None:
|
||||
raise RegistrationError("invalid_redirect_uri", problem)
|
||||
if client_info.token_endpoint_auth_method not in _CLIENT_AUTH_METHODS:
|
||||
@@ -368,6 +374,12 @@ class GiteaOAuthProvider(
|
||||
"""
|
||||
if params.resource is not None and not same_resource(params.resource, self.resource):
|
||||
raise AuthorizeError("invalid_target", "unknown resource")
|
||||
# A client registered before the allowlist was set must not slip through it.
|
||||
if (
|
||||
redirect_uri_problem(str(params.redirect_uri), self._settings.oauth_redirect_allowlist)
|
||||
is not None
|
||||
):
|
||||
raise AuthorizeError("invalid_request", "redirect URI is not allowed")
|
||||
request_id = secrets.token_urlsafe(32)
|
||||
pending = PendingAuthorization(
|
||||
client_id=client.client_id,
|
||||
|
||||
Reference in new issue
Block a user