fix(security): address critical security issues from PR #401 code review
Implemented 6 critical security fixes identified during PR #401 review: 1. Token Rotation Race Condition (Issue 1) - Added in-progress marker pattern to prevent concurrent refresh - Prevents token invalidation when multiple requests refresh simultaneously - File: token_broker.py:324, 343-390 2. Hardcoded Localhost URL (Issue 2) - Added getNextcloudBaseUrl() with fallback chain - Supports overwrite.cli.url, trusted_domains, and localhost fallback - File: IdpTokenRefresher.php:38-61, 116 3. Error Information Leakage (Issue 3) - Replaced 13 instances of str(e) with sanitized errors - Prevents exposure of stack traces, paths, and tokens - File: management.py:368, 444, 492, 510, 546, 571, 625, 643, 695, 750, 919, 956, 1121 4. Input Validation Gaps (Issue 4) - Added validation helpers: _parse_int_param, _parse_float_param, _validate_query_string - Applied bounds checking to get_chunk_context and unified_search - File: management.py:119-164, 807-835, 1197-1212 5. PHP Refresh Token Validation (Issue 5) - Added explicit refresh_token presence check - Prevents silent token rotation failures - File: IdpTokenRefresher.php:122-132 6. Cookie Security Configuration (Issue 6) - Added _should_use_secure_cookies() with auto-detection - Supports explicit COOKIE_SECURE env var or auto-detect from NEXTCLOUD_HOST - Files: browser_oauth_routes.py:27-44, 470; env.sample:54-57 Testing: - Unit tests: 195 passed - Integration tests: 102 passed, 4 skipped - OAuth tests: 9 passed - All linting and type checks passed Follow-up work tracked in issues #408-#417 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
fe54733a39
commit
daabd90359
@@ -24,6 +24,26 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _should_use_secure_cookies() -> bool:
|
||||
"""Determine if cookies should have secure flag.
|
||||
|
||||
Checks COOKIE_SECURE env var first, then auto-detects from NEXTCLOUD_HOST.
|
||||
|
||||
Returns:
|
||||
True if cookies should be secure (HTTPS), False otherwise
|
||||
"""
|
||||
# Explicit configuration takes precedence
|
||||
explicit = os.getenv("COOKIE_SECURE", "").lower()
|
||||
if explicit == "true":
|
||||
return True
|
||||
if explicit == "false":
|
||||
return False
|
||||
|
||||
# Auto-detect from NEXTCLOUD_HOST protocol
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "")
|
||||
return nextcloud_host.startswith("https://")
|
||||
|
||||
|
||||
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"""Browser OAuth login endpoint - redirects to IdP for authentication.
|
||||
|
||||
@@ -447,7 +467,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
value=user_id,
|
||||
max_age=86400 * 30, # 30 days
|
||||
httponly=True,
|
||||
secure=False, # Set to True in production with HTTPS
|
||||
secure=_should_use_secure_cookies(),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
@@ -321,6 +321,8 @@ class TokenBrokerService:
|
||||
"""
|
||||
# Check cache first (background tokens can be cached)
|
||||
cache_key = f"{user_id}:background:{','.join(sorted(required_scopes))}"
|
||||
refresh_in_progress_key = f"{user_id}:refresh_in_progress"
|
||||
|
||||
cached_token = await self.cache.get(cache_key)
|
||||
if cached_token:
|
||||
return cached_token
|
||||
@@ -337,13 +339,28 @@ class TokenBrokerService:
|
||||
)
|
||||
return cached_token
|
||||
|
||||
# Get stored refresh token
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.info(f"No refresh token found for user {user_id}")
|
||||
return None
|
||||
# Check if another thread is currently refreshing
|
||||
if await self.cache.get(refresh_in_progress_key):
|
||||
logger.debug(f"Refresh in progress for user {user_id}, waiting briefly")
|
||||
await anyio.sleep(0.1) # Brief wait for in-progress refresh
|
||||
# Check cache one more time after wait
|
||||
cached_token = await self.cache.get(cache_key)
|
||||
if cached_token:
|
||||
logger.debug(
|
||||
f"Token refreshed by another thread for user {user_id}"
|
||||
)
|
||||
return cached_token
|
||||
|
||||
# Mark refresh as in-progress
|
||||
await self.cache.set(refresh_in_progress_key, "true", expires_in=5)
|
||||
|
||||
try:
|
||||
# Get stored refresh token
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.info(f"No refresh token found for user {user_id}")
|
||||
return None
|
||||
|
||||
# storage.get_refresh_token() returns already-decrypted token
|
||||
refresh_token = refresh_data["refresh_token"]
|
||||
|
||||
@@ -370,6 +387,10 @@ class TokenBrokerService:
|
||||
await self.cache.invalidate(cache_key)
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Always clear the in-progress marker
|
||||
await self.cache.invalidate(refresh_in_progress_key)
|
||||
|
||||
async def _refresh_access_token(
|
||||
self, refresh_token: str, user_id: str | None = None
|
||||
) -> Tuple[str, int]:
|
||||
|
||||
Reference in New Issue
Block a user