From fef3b5ed73f709f3d2a4460b83b9c693bcb68970 Mon Sep 17 00:00:00 2001 From: naspirato Date: Mon, 1 Dec 2025 16:52:19 +0000 Subject: [PATCH 1/3] Enhance OAuth redirect URL handling and validation - Introduced a new function to validate and normalize redirect URLs, ensuring only internal paths are allowed to prevent open redirect attacks. - Updated the GitHub login route to save validated redirect URLs from query parameters. - Enhanced the workflow trigger route to save relative paths for redirects when authentication is required. - Added comprehensive tests for OAuth redirect URL validation and preservation of query parameters. - Improved frontend to dynamically set the redirect URL for authentication links. --- backend/routes/auth.py | 85 ++++++++++-- backend/routes/workflow.py | 11 +- frontend/templates/index.html | 12 +- frontend/templates/result.html | 8 +- tests/test_app.py | 78 +++-------- tests/test_auth_redirect.py | 247 +++++++++++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 76 deletions(-) create mode 100644 tests/test_auth_redirect.py diff --git a/backend/routes/auth.py b/backend/routes/auth.py index ebf9e0d..3d3666a 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -3,6 +3,7 @@ """ import secrets import logging +from urllib.parse import urlparse from fastapi import APIRouter, Request, HTTPException from fastapi.responses import RedirectResponse @@ -12,23 +13,80 @@ router = APIRouter() +def validate_redirect_url(url: str, request: Request) -> str: + """ + Validate and normalize redirect URL for security. + Only allows internal paths (starting with /) to prevent open redirect attacks. + + Args: + url: URL to validate + request: Request object to get base URL + + Returns: + Normalized internal path (relative URL) + + Raises: + HTTPException: If URL is invalid or external + """ + if not url: + return "/" + + # Parse URL + parsed = urlparse(url) + + # If URL has scheme (http/https), check if it's internal + if parsed.scheme: + # Get base URL from request + base_url = str(request.base_url).rstrip('/') + request_host = request.url.hostname + + # Allow only same host + if parsed.netloc and parsed.netloc != request_host: + logger.warning(f"External redirect URL blocked: {url}") + return "/" + + # Extract path and query + path = parsed.path or "/" + query = parsed.query + if query: + path = f"{path}?{query}" + return path + + # If no scheme, treat as relative path + # Ensure it starts with / + if not url.startswith('/'): + url = '/' + url + + return url + + @router.get("/github") async def github_login(request: Request, redirect_after: str = None): - """Initiate GitHub OAuth login""" + """ + Initiate GitHub OAuth login + + Saves redirect URL for post-authentication redirect. + Only saves if explicitly provided via parameter or query string. + """ # Generate state for CSRF protection state = secrets.token_urlsafe(32) request.session["oauth_state"] = state - # Save redirect URL if provided - if redirect_after: - request.session["oauth_redirect_after"] = redirect_after - elif "oauth_redirect_after" not in request.session: - # Get redirect_after from query parameter if not in session + # Get redirect_after from parameter or query string + if not redirect_after: redirect_after = request.query_params.get("redirect_after") - if redirect_after: - request.session["oauth_redirect_after"] = redirect_after - logger.info(f"OAuth login initiated, state generated: {state[:10]}..., redirect_after: {request.session.get('oauth_redirect_after', 'not set')}") + # Validate and save redirect URL if provided + if redirect_after: + try: + validated_url = validate_redirect_url(redirect_after, request) + request.session["oauth_redirect_after"] = validated_url + logger.info(f"OAuth login initiated, state: {state[:10]}..., redirect_after: {validated_url}") + except Exception as e: + logger.warning(f"Invalid redirect URL provided: {redirect_after}, error: {e}") + # Continue without redirect_after, will redirect to / after auth + else: + logger.info(f"OAuth login initiated, state: {state[:10]}..., no redirect_after (will redirect to /)") # Redirect to GitHub OAuth oauth_url = get_oauth_url(state=state) @@ -85,8 +143,15 @@ async def github_callback(request: Request, code: str = None, state: str = None) request.session.pop("oauth_state", None) logger.info(f"Session updated for user: {user_info['login']}") - # Redirect to saved URL or main page + # Get and validate redirect URL redirect_url = request.session.pop("oauth_redirect_after", "/") + try: + # Validate redirect URL for security (prevent open redirect attacks) + redirect_url = validate_redirect_url(redirect_url, request) + except Exception as e: + logger.warning(f"Invalid redirect URL in session: {redirect_url}, error: {e}, redirecting to /") + redirect_url = "/" + logger.info(f"Redirecting after OAuth to: {redirect_url}") return RedirectResponse(url=redirect_url, status_code=303) except HTTPException: diff --git a/backend/routes/workflow.py b/backend/routes/workflow.py index e6edda5..3d0e7e2 100644 --- a/backend/routes/workflow.py +++ b/backend/routes/workflow.py @@ -41,10 +41,13 @@ async def _trigger_and_show_result( access_token = request.session.get("access_token") if not user or not access_token: - # Save current URL for redirect after OAuth - current_url = str(request.url) - request.session["oauth_redirect_after"] = current_url - logger.info(f"No session found, saving redirect URL: {current_url}") + # Save current URL (relative path with query) for redirect after OAuth + # Use relative path for security (prevents open redirect attacks) + redirect_path = request.url.path + if request.url.query: + redirect_path = f"{redirect_path}?{request.url.query}" + request.session["oauth_redirect_after"] = redirect_path + logger.info(f"No session found, saving redirect path: {redirect_path}") # Redirect to login oauth_url = get_oauth_url() diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 44a4057..885ef41 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -14,7 +14,7 @@

Authentication Required

Please sign in with GitHub to run workflows

- Sign in with GitHub + Sign in with GitHub
{% endif %} @@ -135,7 +135,7 @@

Run GitHub Action

{% endif %} {{ user.login }} - Выйти + Logout @@ -1000,6 +1000,14 @@

Run GitHub Action

// Инициализация при загрузке function initAll() { + // Обновляем ссылку на авторизацию, чтобы она передавала текущий URL с параметрами + const authLink = document.getElementById('auth-link'); + if (authLink && window.location.search) { + const currentUrl = window.location.href; + const redirectAfter = encodeURIComponent(currentUrl); + authLink.href = `/auth/github?redirect_after=${redirectAfter}`; + } + // Инициализируем скрытое поле return_url из URL параметров, если его нет const form = document.getElementById('workflowForm'); if (form) { diff --git a/frontend/templates/result.html b/frontend/templates/result.html index 8587d94..cba29ac 100644 --- a/frontend/templates/result.html +++ b/frontend/templates/result.html @@ -76,7 +76,7 @@

Success

-

Ищем запуск workflow...

+

Searching for workflow run...