fix(auth): address PR #758 review — XSS, CSRF, open redirect, JWKS cache
Addresses all 9 findings from the review on PR #758: Blocking: - _revoke_refresh_token_at_idp now reads config from oauth_ctx["config"] (the production-shaped nested dict). Previously read flat keys, causing IdP revocation to silently no-op in production. Test fixtures rebuilt to the realistic nested shape so the bug can't regress unnoticed. - HTML error responses in oauth_login_callback now wrap IdP-controlled error_body, str(e), and the attacker-controlled error/error_description query params in html_escape. New test_browser_oauth_xss.py pins this. Important: - New _safe_next_url helper validates the ?next= query param at write time (oauth_login), in oauth_logout, and on read from the session row in oauth_login_callback. Blocks https://, // (protocol-relative), and CRLF/whitespace injection. - verify_id_token now caches discovery + JWKS (5-min TTL) using the same pattern as oauth_routes._get_cached_discovery. New caching regression test pins to one fetch per URL across multiple calls. - /oauth/logout is now POST-only at the route layer (defeats passive CSRF via <img src>). oauth_logout also validates Origin/Referer against the configured mcp_server_url. Logout UI in user_info.html converted from <a href> to <form method="post">. - New storage.cleanup_expired_browser_sessions() called from the hourly cleanup loop in app.py — previously these rows accumulated for users who never explicitly logged out. Nits: - Demoted INFO logs that leaked oauth_config.keys() / client_id / token-storage state to DEBUG. Operator-relevant outcome lines (login successful, refresh token stored, logged out) stay INFO. - verify_id_token algorithms widened to RS256, PS256, ES256 — covers Azure AD (PS256) and Cognito/some Keycloak realms (ES256). Symmetric and "none" remain off the allowlist. - Migrated all Optional[X] usages in auth/storage.py to X | None per CLAUDE.md. Breaking change: GET /oauth/logout now returns 405. The in-tree logout UI was migrated to a POST form; any external bookmark or curl-based caller that relied on GET will need to switch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
15dbb26349
commit
931ee602eb
@@ -46,12 +46,20 @@ async def storage():
|
||||
yield s
|
||||
|
||||
|
||||
def _build_request(*, cookie: str | None, oauth_context: dict | None):
|
||||
def _build_request(
|
||||
*,
|
||||
cookie: str | None,
|
||||
oauth_context: dict | None,
|
||||
headers: dict | None = None,
|
||||
):
|
||||
"""Build a minimal Starlette-style request stub for oauth_logout."""
|
||||
request = MagicMock()
|
||||
request.query_params = {}
|
||||
request.cookies = {"mcp_session": cookie} if cookie else {}
|
||||
request.app.state.oauth_context = oauth_context
|
||||
# Headers default to empty so the CSRF check sees neither Origin nor
|
||||
# Referer (allowed by policy — see _origin_matches_self).
|
||||
request.headers = headers or {}
|
||||
return request
|
||||
|
||||
|
||||
@@ -69,7 +77,7 @@ async def test_logout_deletes_refresh_token_and_session(storage):
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-1",
|
||||
oauth_context={"storage": storage, "discovery_url": None},
|
||||
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -93,7 +101,10 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
|
||||
revoke = AsyncMock()
|
||||
request = _build_request(
|
||||
cookie="sid-2",
|
||||
oauth_context={"storage": storage, "discovery_url": "http://idp/.well-known"},
|
||||
oauth_context={
|
||||
"storage": storage,
|
||||
"config": {"discovery_url": "http://idp/.well-known"},
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -111,7 +122,8 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
|
||||
async def test_logout_no_session_cookie_returns_302(storage):
|
||||
"""Without a cookie, logout still 302s and doesn't touch storage."""
|
||||
request = _build_request(
|
||||
cookie=None, oauth_context={"storage": storage, "discovery_url": None}
|
||||
cookie=None,
|
||||
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||
)
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 302
|
||||
@@ -128,12 +140,78 @@ async def test_logout_swallows_storage_errors(storage):
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-3",
|
||||
oauth_context={"storage": broken_storage, "discovery_url": None},
|
||||
oauth_context={
|
||||
"storage": broken_storage,
|
||||
"config": {"discovery_url": None},
|
||||
},
|
||||
)
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 302 # logout still succeeds
|
||||
|
||||
|
||||
async def test_logout_blocks_cross_origin_post(storage):
|
||||
"""POST from a foreign Origin must be rejected with 403 (PR #758 finding 5)."""
|
||||
await storage.create_browser_session(session_id="sid-X", user_id="alice")
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-X",
|
||||
oauth_context={
|
||||
"storage": storage,
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"discovery_url": None,
|
||||
},
|
||||
},
|
||||
headers={"origin": "https://evil.example.com"},
|
||||
)
|
||||
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 403
|
||||
# Session row must NOT have been deleted.
|
||||
assert await storage.get_browser_session_user("sid-X") == "alice"
|
||||
|
||||
|
||||
async def test_logout_allows_same_origin_post(storage):
|
||||
"""POST with matching Origin proceeds normally."""
|
||||
await storage.create_browser_session(session_id="sid-Y", user_id="alice")
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-Y",
|
||||
oauth_context={
|
||||
"storage": storage,
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"discovery_url": None,
|
||||
},
|
||||
},
|
||||
headers={"origin": "https://mcp.example.com"},
|
||||
)
|
||||
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 302
|
||||
assert await storage.get_browser_session_user("sid-Y") is None
|
||||
|
||||
|
||||
async def test_logout_allows_referer_when_origin_missing(storage):
|
||||
"""Some browsers strip Origin on POST; Referer is the fallback signal."""
|
||||
await storage.create_browser_session(session_id="sid-Z", user_id="alice")
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-Z",
|
||||
oauth_context={
|
||||
"storage": storage,
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"discovery_url": None,
|
||||
},
|
||||
},
|
||||
headers={"referer": "https://mcp.example.com/app"},
|
||||
)
|
||||
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
async def test_logout_handles_session_with_no_refresh_token(storage):
|
||||
"""Cookie + session row exist but refresh token already gone — logout is idempotent."""
|
||||
await storage.create_browser_session(session_id="sid-4", user_id="dave")
|
||||
@@ -141,7 +219,7 @@ async def test_logout_handles_session_with_no_refresh_token(storage):
|
||||
revoke = AsyncMock()
|
||||
request = _build_request(
|
||||
cookie="sid-4",
|
||||
oauth_context={"storage": storage, "discovery_url": None},
|
||||
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||
)
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
|
||||
@@ -197,9 +275,11 @@ async def test_revoke_helper_posts_to_revocation_endpoint():
|
||||
):
|
||||
await _revoke_refresh_token_at_idp(
|
||||
{
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": "test-client",
|
||||
"client_secret": "test-secret",
|
||||
"config": {
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": "test-client",
|
||||
"client_secret": "test-secret",
|
||||
}
|
||||
},
|
||||
"rt-secret",
|
||||
)
|
||||
@@ -232,9 +312,11 @@ async def test_revoke_helper_skips_when_no_revocation_endpoint():
|
||||
# Returns None and does not raise
|
||||
result = await _revoke_refresh_token_at_idp(
|
||||
{
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": "x",
|
||||
"client_secret": "y",
|
||||
"config": {
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": "x",
|
||||
"client_secret": "y",
|
||||
}
|
||||
},
|
||||
"rt",
|
||||
)
|
||||
@@ -259,9 +341,11 @@ async def test_revoke_helper_silent_on_idp_error():
|
||||
):
|
||||
result = await _revoke_refresh_token_at_idp(
|
||||
{
|
||||
"discovery_url": "http://x/.well-known",
|
||||
"client_id": "x",
|
||||
"client_secret": "y",
|
||||
"config": {
|
||||
"discovery_url": "http://x/.well-known",
|
||||
"client_id": "x",
|
||||
"client_secret": "y",
|
||||
}
|
||||
},
|
||||
"rt",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user