From 11d83e6f86880ec30ad73994c6fba6d58b26605c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Mon, 11 May 2026 15:47:59 +0530 Subject: [PATCH] Harden OAuth callback postMessage origin and payload encoding --- backend/api/wix_routes.py | 105 +++++++++----- backend/env_template.txt | 4 + backend/routers/wordpress_oauth.py | 218 +++++++++++++---------------- 3 files changed, 173 insertions(+), 154 deletions(-) diff --git a/backend/api/wix_routes.py b/backend/api/wix_routes.py index 08279ac9..d33e188b 100644 --- a/backend/api/wix_routes.py +++ b/backend/api/wix_routes.py @@ -14,9 +14,69 @@ from services.wix_service import WixService from services.integrations.wix_oauth import WixOAuthService from middleware.auth_middleware import get_current_user import os +import json +from urllib.parse import urlparse router = APIRouter(prefix="/api/wix", tags=["Wix Integration"]) + +def _sanitize_error_message(error: Exception) -> str: + return " ".join(str(error).split())[:500] + + +def _normalize_origin(url: Optional[str]) -> Optional[str]: + if not url: + return None + parsed = urlparse(url.strip()) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + return f"{parsed.scheme}://{parsed.netloc}" + + +def _trusted_frontend_origin() -> Optional[str]: + origins_env = os.getenv("OAUTH_CALLBACK_ALLOWED_ORIGINS", "") + configured_origins = [ + _normalize_origin(origin) + for origin in origins_env.split(",") + if origin.strip() + ] + configured_origins = [origin for origin in configured_origins if origin] + if configured_origins: + return configured_origins[0] + return _normalize_origin(os.getenv("FRONTEND_URL")) + + +def _build_oauth_callback_html(payload: Dict[str, Any], title: str, heading: str, message: str) -> str: + trusted_origin = _trusted_frontend_origin() + payload_json = json.dumps(payload) + target_origin_json = json.dumps(trusted_origin or "") + heading_html = heading.replace("&", "&").replace("<", "<").replace(">", ">") + message_html = message.replace("&", "&").replace("<", "<").replace(">", ">") + return f""" + + + {title} + +

{heading_html}

+

{message_html}

+ + + + """ + # Initialize Wix service wix_service = WixService() @@ -193,45 +253,24 @@ async def handle_oauth_callback_get(code: str, state: Optional[str] = None, requ "permissions": permissions } - html = f""" - - - Wix Connected - - - - - """ + html = _build_oauth_callback_html( + payload=payload, + title="Wix Connected", + heading="Connection Successful", + message="Your Wix account was connected. You can close this window." + ) return HTMLResponse(content=html, headers={ "Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Embedder-Policy": "unsafe-none" }) except Exception as e: logger.error(f"Wix OAuth GET callback failed: {e}") - html = f""" - - - Wix Connection Failed - - - - - """ + html = _build_oauth_callback_html( + payload={"type": "WIX_OAUTH_ERROR", "success": False, "error": _sanitize_error_message(e)}, + title="Wix Connection Failed", + heading="Connection Failed", + message="There was an issue connecting your Wix account. You can close this window and try again." + ) return HTMLResponse(content=html, headers={ "Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Embedder-Policy": "unsafe-none" diff --git a/backend/env_template.txt b/backend/env_template.txt index 6ca037cf..4566cc6d 100644 --- a/backend/env_template.txt +++ b/backend/env_template.txt @@ -16,6 +16,10 @@ EXA_API_KEY=your_exa_api_key_here # Frontend URL for OAuth callbacks FRONTEND_URL=https://alwrity-ai.vercel.app +# Optional comma-separated allowlist of trusted frontend origins used for OAuth callback postMessage targetOrigin. +# If unset, FRONTEND_URL origin is used. +# Example: OAUTH_CALLBACK_ALLOWED_ORIGINS=https://alwrity-ai.vercel.app,http://localhost:3000 +OAUTH_CALLBACK_ALLOWED_ORIGINS= # OAuth Redirect URIs (Using environment variable for flexibility) GSC_REDIRECT_URI=${FRONTEND_URL}/gsc/callback diff --git a/backend/routers/wordpress_oauth.py b/backend/routers/wordpress_oauth.py index abc10f8c..0204dfc5 100644 --- a/backend/routers/wordpress_oauth.py +++ b/backend/routers/wordpress_oauth.py @@ -8,6 +8,9 @@ from fastapi.responses import RedirectResponse, HTMLResponse, JSONResponse from typing import Dict, Any, Optional from pydantic import BaseModel from loguru import logger +import json +import os +from urllib.parse import urlparse from services.integrations.wordpress_oauth import WordPressOAuthService from middleware.auth_middleware import get_current_user @@ -17,6 +20,65 @@ router = APIRouter(prefix="/wp", tags=["WordPress OAuth"]) # Initialize OAuth service oauth_service = WordPressOAuthService() + +def _sanitize_string(value: Any, max_len: int = 500) -> str: + if value is None: + return "" + return " ".join(str(value).split())[:max_len] + + +def _normalize_origin(url: Optional[str]) -> Optional[str]: + if not url: + return None + parsed = urlparse(url.strip()) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + return f"{parsed.scheme}://{parsed.netloc}" + + +def _trusted_frontend_origin() -> Optional[str]: + origins_env = os.getenv("OAUTH_CALLBACK_ALLOWED_ORIGINS", "") + configured_origins = [ + _normalize_origin(origin) + for origin in origins_env.split(",") + if origin.strip() + ] + configured_origins = [origin for origin in configured_origins if origin] + if configured_origins: + return configured_origins[0] + return _normalize_origin(os.getenv("FRONTEND_URL")) + + +def _oauth_callback_html(payload: Dict[str, Any], title: str, heading: str, message: str) -> str: + payload_json = json.dumps(payload) + target_origin = json.dumps(_trusted_frontend_origin() or "") + heading_html = heading.replace("&", "&").replace("<", "<").replace(">", ">") + message_html = message.replace("&", "&").replace("<", "<").replace(">", ">") + return f""" + + + {title} + +

{heading_html}

+

{message_html}

+ + + + """ + # Pydantic Models class WordPressOAuthResponse(BaseModel): auth_url: str @@ -78,30 +140,12 @@ async def handle_wordpress_callback( status_code=status.HTTP_400_BAD_REQUEST, content={"success": False, "error": error} ) - html_content = f""" - - - - WordPress.com Connection Failed - - - -

Connection Failed

-

There was an error connecting to WordPress.com.

-

You can close this window and try again.

- - - """ + html_content = _oauth_callback_html( + payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": _sanitize_string(error)}, + title="WordPress.com Connection Failed", + heading="Connection Failed", + message="There was an error connecting to WordPress.com. You can close this window and try again." + ) return HTMLResponse(content=html_content, headers={ "Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Embedder-Policy": "unsafe-none" @@ -114,30 +158,12 @@ async def handle_wordpress_callback( status_code=status.HTTP_400_BAD_REQUEST, content={"success": False, "error": "Missing parameters"} ) - html_content = """ - - - - WordPress.com Connection Failed - - - -

Connection Failed

-

Missing required parameters.

-

You can close this window and try again.

- - - """ + html_content = _oauth_callback_html( + payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Missing parameters"}, + title="WordPress.com Connection Failed", + heading="Connection Failed", + message="Missing required parameters. You can close this window and try again." + ) return HTMLResponse(content=html_content, headers={ "Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Embedder-Policy": "unsafe-none" @@ -153,30 +179,12 @@ async def handle_wordpress_callback( status_code=status.HTTP_400_BAD_REQUEST, content={"success": False, "error": "Token exchange failed"} ) - html_content = """ - - - - WordPress.com Connection Failed - - - -

Connection Failed

-

Failed to exchange authorization code for access token.

-

You can close this window and try again.

- - - """ + html_content = _oauth_callback_html( + payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Token exchange failed"}, + title="WordPress.com Connection Failed", + heading="Connection Failed", + message="Failed to exchange authorization code for access token. You can close this window and try again." + ) return HTMLResponse(content=html_content) # Return success page with postMessage script @@ -193,31 +201,17 @@ async def handle_wordpress_callback( } ) - html_content = f""" - - - - WordPress.com Connection Successful - - - -

Connection Successful!

-

Your WordPress.com site has been connected successfully.

-

You can close this window now.

- - - """ + html_content = _oauth_callback_html( + payload={ + "type": "WPCOM_OAUTH_SUCCESS", + "success": True, + "blogUrl": _sanitize_string(blog_url, 300), + "blogId": _sanitize_string(blog_id, 128) + }, + title="WordPress.com Connection Successful", + heading="Connection Successful", + message="Your WordPress.com site has been connected successfully. You can close this window now." + ) return HTMLResponse(content=html_content, headers={ "Cross-Origin-Opener-Policy": "unsafe-none", @@ -226,30 +220,12 @@ async def handle_wordpress_callback( except Exception as e: logger.error(f"Error handling WordPress OAuth callback: {e}") - html_content = """ - - - - WordPress.com Connection Failed - - - -

Connection Failed

-

An unexpected error occurred during connection.

-

You can close this window and try again.

- - - """ + html_content = _oauth_callback_html( + payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Callback error"}, + title="WordPress.com Connection Failed", + heading="Connection Failed", + message="An unexpected error occurred during connection. You can close this window and try again." + ) return HTMLResponse(content=html_content, headers={ "Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Embedder-Policy": "unsafe-none"