diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py
index 68fd08bd..c32ba904 100644
--- a/nextcloud_mcp_server/app.py
+++ b/nextcloud_mcp_server/app.py
@@ -1354,13 +1354,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
storage = await get_shared_storage()
count = await storage.delete_expired_login_flow_sessions()
if count:
- logger.info(f"Cleaned up {count} expired login flow sessions")
+ logger.info("Cleaned up %s expired login flow sessions", count)
+ # Browser session rows are otherwise only cleaned up lazily
+ # when a user revisits — PR #758 finding 6.
+ await storage.cleanup_expired_browser_sessions()
# Also clean up expired AS proxy codes/sessions
_cleanup_expired_proxy_codes()
# Clean up expired web provision sessions
_cleanup_expired_provision_sessions()
except Exception as e:
- logger.warning(f"Login flow cleanup error: {e}")
+ logger.warning("Login flow cleanup error: %s", e)
await anyio.sleep(3600) # Every hour
@asynccontextmanager
@@ -2242,8 +2245,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
name="oauth_login_callback",
)
)
+ # POST-only: defends against passive CSRF (e.g. )
+ # — see PR #758 finding 5.
routes.append(
- Route("/oauth/logout", oauth_logout, methods=["GET"], name="oauth_logout")
+ Route("/oauth/logout", oauth_logout, methods=["POST"], name="oauth_logout")
)
logger.info(
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback (legacy), /oauth/logout"
diff --git a/nextcloud_mcp_server/auth/browser_oauth_routes.py b/nextcloud_mcp_server/auth/browser_oauth_routes.py
index 577c3024..cc957242 100644
--- a/nextcloud_mcp_server/auth/browser_oauth_routes.py
+++ b/nextcloud_mcp_server/auth/browser_oauth_routes.py
@@ -32,6 +32,48 @@ from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
+def _origin_matches_self(request: Request, oauth_ctx: dict) -> bool:
+ """Return True when Origin/Referer is missing or matches our own host.
+
+ Used to gate POST /oauth/logout against cross-origin form submissions
+ (PR #758 finding 5). Per OWASP CSRF cheat sheet, the policy is:
+ - If neither Origin nor Referer is set, allow (same-origin POST in
+ privacy-conscious browsers may strip both).
+ - Otherwise, the netloc of the first present header must equal the
+ netloc of the configured ``mcp_server_url``.
+ """
+ cfg = oauth_ctx.get("config") or oauth_ctx
+ mcp_server_url = cfg.get("mcp_server_url")
+ if not mcp_server_url:
+ # Mis-configured deployment — fail open rather than break logout.
+ return True
+
+ expected = parse_url(mcp_server_url).netloc.lower()
+ raw = request.headers.get("origin") or request.headers.get("referer")
+ if not raw:
+ return True
+ return parse_url(raw).netloc.lower() == expected
+
+
+def _safe_next_url(raw: str | None, default: str) -> str:
+ """Return a path-only redirect target, falling back to *default*.
+
+ Blocks open-redirect abuse via the ``?next=`` query parameter on
+ ``/oauth/login`` and ``/oauth/logout`` (and the round-tripped
+ ``client_redirect_uri`` stored on the oauth_session). A safe target:
+ - starts with a single ``/`` (so it's a path on this server)
+ - does NOT start with ``//`` (which would be protocol-relative)
+ - has no whitespace or control characters that could trick browsers
+
+ Anything else returns *default*.
+ """
+ if not raw or not raw.startswith("/") or raw.startswith("//"):
+ return default
+ if any(c.isspace() or ord(c) < 0x20 for c in raw):
+ return default
+ return raw
+
+
def _should_use_secure_cookies() -> bool:
"""Determine if cookies should have secure flag.
@@ -73,14 +115,17 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
- # Debug: Log oauth_config contents
- logger.info(f"oauth_login called - oauth_config keys: {oauth_config.keys()}")
- logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
- logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
+ # Demoted to DEBUG (PR #758 nit a) — these previously leaked the
+ # full set of config keys + the client_id at INFO on every login.
+ logger.debug("oauth_login called - oauth_config keys: %s", oauth_config.keys())
+ logger.debug("oauth_login called - client_id: %s", oauth_config.get("client_id"))
+ logger.debug("oauth_login called - oauth_client: %s", oauth_client is not None)
- # Get redirect URL from query params (default to /app)
- next_url = request.query_params.get("next", "/app")
- logger.info(f"oauth_login - next_url: {next_url}")
+ # Get redirect URL from query params (default to /app). Validated at
+ # write-time so we never store an attacker-controlled absolute URL on
+ # the oauth_session row (issue #758 finding 3).
+ next_url = _safe_next_url(request.query_params.get("next"), "/app")
+ logger.debug("oauth_login - next_url: %s", next_url)
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
@@ -142,7 +187,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
}
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
- logger.info(f"Redirecting to external IdP login: {auth_url.split('?')[0]}")
+ logger.debug("Redirecting to external IdP login: %s", auth_url.split("?")[0])
else:
# Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url")
@@ -199,11 +244,10 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
}
- # Debug: Log full parameters
- logger.info(f"Building Nextcloud OIDC auth URL with params: {idp_params}")
+ logger.debug("Building Nextcloud OIDC auth URL with params: %s", idp_params)
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
- logger.info(f"Redirecting to Nextcloud OIDC login: {auth_url}")
+ logger.debug("Redirecting to Nextcloud OIDC login: %s", auth_url)
return RedirectResponse(auth_url, status_code=302)
@@ -228,8 +272,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
- logger.error(f"OAuth login error: {error} - {error_description}")
+ logger.error("OAuth login error: %s - %s", error, error_description)
login_url = str(request.url_for("oauth_login"))
+ # html_escape: error / error_description come from attacker-controlled
+ # query parameters and would otherwise reflect into the failure page.
return HTMLResponse(
f"""
@@ -237,9 +283,9 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
Error: {error}
-{error_description}
- +Error: {html_escape(error)}
+{html_escape(error_description)}
+