fix: address PR review — XSS escape, asyncio→anyio, URL rewrite dedup

- Escape HTML in _render_error to prevent XSS from exception messages
- Replace asyncio.create_task/sleep with anyio task group and sleep,
  tying poll task lifetime to the app lifespan for proper cleanup
- Extract rewrite_url_origin() utility to fix duplicated URL rewriting
  logic and replace urlparse._replace with stable urlunparse API
- Add warning log for insecure HTTP redirect URIs
- Add unit tests for validation, XSS escaping, route handlers, and
  URL rewriting (16 new tests in test_provision_routes.py)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-30 08:51:41 +02:00
co-authored by Claude Opus 4.6
parent c21776948d
commit 777a09c806
5 changed files with 264 additions and 33 deletions
+21 -7
View File
@@ -19,6 +19,26 @@ from nextcloud_mcp_server.http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
def rewrite_url_origin(url: str, target_host: str) -> str:
"""Rewrite a URL's scheme+host+port to match target_host.
Preserves the path, params, query, and fragment from the original URL.
Useful for rewriting internal Docker hostnames to public-facing URLs.
"""
parsed_url = urlparse(url)
parsed_host = urlparse(target_host)
return urlunparse(
(
parsed_host.scheme,
parsed_host.netloc,
parsed_url.path,
parsed_url.params,
parsed_url.query,
parsed_url.fragment,
)
)
class LoginFlowInitResponse(BaseModel):
"""Response from initiating Login Flow v2."""
@@ -120,13 +140,7 @@ class LoginFlowV2Client:
(e.g. http://app:80). This replaces the scheme+host+port while
preserving the path and query.
"""
parsed_url = urlparse(url)
parsed_host = urlparse(self.nextcloud_host)
rewritten = parsed_url._replace(
scheme=parsed_host.scheme,
netloc=parsed_host.netloc,
)
result = urlunparse(rewritten)
result = rewrite_url_origin(url, self.nextcloud_host)
if result != url:
logger.debug(f"Rewrote Login Flow v2 URL: {url}{result}")
return result
+22 -13
View File
@@ -13,17 +13,18 @@ Flow:
5. User returns to Astrolabe settings (via redirect_uri or navigation)
"""
import asyncio
import html
import logging
import os
import secrets
import time
from urllib.parse import urlparse
import anyio
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client, rewrite_url_origin
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.config import get_nextcloud_ssl_verify, get_settings
@@ -87,7 +88,7 @@ async def _poll_and_store(provision_id: str) -> None:
logger.warning(
f"Login Flow v2 poll error for provision {provision_id}: {e}"
)
await asyncio.sleep(2)
await anyio.sleep(2)
continue
if result.status == "completed":
@@ -121,7 +122,7 @@ async def _poll_and_store(provision_id: str) -> None:
)
return
await asyncio.sleep(2)
await anyio.sleep(2)
# Timed out
session["status"] = "expired"
@@ -151,6 +152,9 @@ async def provision_page(request: Request) -> RedirectResponse | HTMLResponse:
status_code=400,
)
if urlparse(redirect_uri).scheme == "http":
logger.warning(f"Provision redirect_uri uses insecure HTTP: {redirect_uri}")
# Check if user already has an app password — skip straight to redirect
if user_id:
storage = await get_shared_storage()
@@ -196,8 +200,18 @@ async def provision_page(request: Request) -> RedirectResponse | HTMLResponse:
"expires_at": time.time() + _SESSION_TTL,
}
# Start background polling task
asyncio.create_task(_poll_and_store(provision_id))
# Start background polling task (uses task group from app lifespan)
poll_tg = getattr(request.app.state, "poll_task_group", None)
if poll_tg is None:
logger.error("No poll task group available; cannot start background polling")
_provision_sessions.pop(provision_id, None)
return HTMLResponse(
content=_render_error(
"Server configuration error: background polling unavailable."
),
status_code=500,
)
poll_tg.start_soon(_poll_and_store, provision_id)
logger.info(
f"Login Flow v2 web provision initiated (provision_id={provision_id}, "
@@ -210,12 +224,7 @@ async def provision_page(request: Request) -> RedirectResponse | HTMLResponse:
login_url = init_response.login_url
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "")
if public_issuer and nextcloud_host:
# Extract just scheme+host from NEXTCLOUD_HOST for matching
# (NC may omit default ports, e.g. http://app:80 → http://app)
parsed = urlparse(nextcloud_host)
internal_origin = f"{parsed.scheme}://{parsed.hostname}"
if internal_origin in login_url:
login_url = login_url.replace(internal_origin, public_issuer.rstrip("/"))
login_url = rewrite_url_origin(login_url, public_issuer.rstrip("/"))
return RedirectResponse(login_url)
@@ -282,7 +291,7 @@ def _render_error(message: str) -> str:
<body>
<div class="card">
<h1 class="error">Provisioning Error</h1>
<p>{message}</p>
<p>{html.escape(message)}</p>
</div>
</body>
</html>"""