fix(auth): address PR #758 round-3 review
- Flow 2 (oauth_authorize_nextcloud) now generates a nonce, stores it on the oauth_session row, forwards it to the IdP, and verifies it via expected_nonce in oauth_callback_nextcloud — closes the last replay- protection gap (round-3 finding 1). - _origin_matches_self fails closed when mcp_server_url is missing instead of allowing the logout, and the diagnostic log is promoted from warning to error so the misconfiguration is monitorable (round-3 finding 2). New regression test pins the new behaviour. - The five user_id-accepting helpers in oauth_tools.py (get_provisioning_status, provision_nextcloud_access, revoke_nextcloud_access, check_provisioning_status, check_logged_in) are renamed with leading underscores to make the trust boundary structural rather than documentary (round-3 finding 3). - create_browser_session and delete_browser_session now emit audit_log rows so session establishment / teardown match the pattern used by the rest of the security-relevant storage operations (round-3 nit 5). delete_browser_session selects user_id before delete so the audit row is attributable. - oauth_login_callback no longer reflects raw IdP-error text or exception strings into the HTML failure page; users see a generic "internal error occurred" message + a correlation ID, with the detail logged server-side keyed by the same ID (round-3 nit 6). The XSS regression test is updated to pin the stricter contract. 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
c33d52ea91
commit
9d0e7dcebe
@@ -70,8 +70,14 @@ async def test_callback_escapes_error_query_params(storage):
|
||||
assert "<script>alert(1)</script>" in body
|
||||
|
||||
|
||||
async def test_callback_escapes_idp_http_error_body(storage):
|
||||
"""IdP-returned HTTPError body must be HTML-escaped before reflection."""
|
||||
async def test_callback_does_not_reflect_idp_http_error_body(storage):
|
||||
"""IdP-returned HTTPError body must not appear in the user-visible HTML.
|
||||
|
||||
Updated for PR #758 round-3 nit 6: the callback now logs the IdP
|
||||
response server-side and shows the user only a generic message + a
|
||||
correlation ID, eliminating reflection of attacker-controllable text
|
||||
into the error page entirely.
|
||||
"""
|
||||
discovery = {"token_endpoint": "http://idp.example/token"}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
@@ -135,5 +141,10 @@ async def test_callback_escapes_idp_http_error_body(storage):
|
||||
|
||||
body = response.body.decode()
|
||||
assert response.status_code == 500
|
||||
# Strict: neither the raw payload nor an HTML-escaped form of the
|
||||
# IdP body should appear — the page must show only the generic
|
||||
# message + correlation ID.
|
||||
assert XSS_PAYLOAD not in body
|
||||
assert "<script>alert(1)</script>" in body
|
||||
assert "<script>alert(1)</script>" not in body
|
||||
assert "An internal error occurred" in body
|
||||
assert "Correlation ID" in body
|
||||
|
||||
@@ -92,7 +92,13 @@ async def test_logout_deletes_refresh_token_and_session(storage):
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-1",
|
||||
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||
oauth_context={
|
||||
"storage": storage,
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"discovery_url": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
@@ -118,7 +124,10 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
|
||||
cookie="sid-2",
|
||||
oauth_context={
|
||||
"storage": storage,
|
||||
"config": {"discovery_url": "http://idp/.well-known"},
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"discovery_url": "http://idp/.well-known",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -138,7 +147,13 @@ 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, "config": {"discovery_url": None}},
|
||||
oauth_context={
|
||||
"storage": storage,
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"discovery_url": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 302
|
||||
@@ -157,7 +172,10 @@ async def test_logout_swallows_storage_errors(storage):
|
||||
cookie="sid-3",
|
||||
oauth_context={
|
||||
"storage": broken_storage,
|
||||
"config": {"discovery_url": None},
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"discovery_url": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
response = await oauth_logout(request)
|
||||
@@ -295,6 +313,26 @@ async def test_logout_allows_referer_when_origin_missing(storage):
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
async def test_logout_blocked_when_mcp_server_url_missing(storage):
|
||||
"""Fail-closed CSRF (PR #758 round-3 finding 2): missing ``mcp_server_url``
|
||||
in oauth_ctx must reject the logout, not allow it.
|
||||
|
||||
A future code path that leaves ``mcp_server_url`` unset would
|
||||
otherwise silently disable CSRF protection. Blocking is recoverable.
|
||||
"""
|
||||
await storage.create_browser_session(session_id="sid-MM", user_id="alice")
|
||||
|
||||
request = _build_request(
|
||||
cookie="sid-MM",
|
||||
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||
)
|
||||
|
||||
response = await oauth_logout(request)
|
||||
assert response.status_code == 403
|
||||
# Session must NOT have been deleted.
|
||||
assert await storage.get_browser_session_user("sid-MM") == "alice"
|
||||
|
||||
|
||||
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")
|
||||
@@ -302,7 +340,13 @@ async def test_logout_handles_session_with_no_refresh_token(storage):
|
||||
revoke = AsyncMock()
|
||||
request = _build_request(
|
||||
cookie="sid-4",
|
||||
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||
oauth_context={
|
||||
"storage": storage,
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"discovery_url": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
|
||||
|
||||
Reference in New Issue
Block a user