Harden OAuth callback postMessage origin and payload encoding

This commit is contained in:
ي
2026-05-11 15:47:59 +05:30
committed by ajaysi
parent 8834a05cf5
commit 11d83e6f86
3 changed files with 173 additions and 154 deletions

View File

@@ -14,9 +14,69 @@ from services.wix_service import WixService
from services.integrations.wix_oauth import WixOAuthService from services.integrations.wix_oauth import WixOAuthService
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
import os import os
import json
from urllib.parse import urlparse
router = APIRouter(prefix="/api/wix", tags=["Wix Integration"]) 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
message_html = message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return f"""
<!DOCTYPE html>
<html>
<head><title>{title}</title></head>
<body>
<h1>{heading_html}</h1>
<p>{message_html}</p>
<script>
(function() {{
var payload = {payload_json};
var targetOrigin = {target_origin_json};
var destination = window.opener || window.parent;
if (destination && targetOrigin) {{
try {{
destination.postMessage(payload, targetOrigin);
window.close();
return;
}} catch (_e) {{}}
}}
}})();
</script>
</body>
</html>
"""
# Initialize Wix service # Initialize Wix service
wix_service = WixService() wix_service = WixService()
@@ -193,45 +253,24 @@ async def handle_oauth_callback_get(code: str, state: Optional[str] = None, requ
"permissions": permissions "permissions": permissions
} }
html = f""" html = _build_oauth_callback_html(
<!DOCTYPE html> payload=payload,
<html> title="Wix Connected",
<head><title>Wix Connected</title></head> heading="Connection Successful",
<body> message="Your Wix account was connected. You can close this window."
<script> )
(function() {{
try {{
var payload = {payload};
(window.opener || window.parent).postMessage(payload, '*');
}} catch (e) {{}}
window.close();
}})();
</script>
</body>
</html>
"""
return HTMLResponse(content=html, headers={ return HTMLResponse(content=html, headers={
"Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Opener-Policy": "unsafe-none",
"Cross-Origin-Embedder-Policy": "unsafe-none" "Cross-Origin-Embedder-Policy": "unsafe-none"
}) })
except Exception as e: except Exception as e:
logger.error(f"Wix OAuth GET callback failed: {e}") logger.error(f"Wix OAuth GET callback failed: {e}")
html = f""" html = _build_oauth_callback_html(
<!DOCTYPE html> payload={"type": "WIX_OAUTH_ERROR", "success": False, "error": _sanitize_error_message(e)},
<html> title="Wix Connection Failed",
<head><title>Wix Connection Failed</title></head> heading="Connection Failed",
<body> message="There was an issue connecting your Wix account. You can close this window and try again."
<script> )
(function() {{
try {{
(window.opener || window.parent).postMessage({{ type: 'WIX_OAUTH_ERROR', success: false, error: '{str(e)}' }}, '*');
}} catch (e) {{}}
window.close();
}})();
</script>
</body>
</html>
"""
return HTMLResponse(content=html, headers={ return HTMLResponse(content=html, headers={
"Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Opener-Policy": "unsafe-none",
"Cross-Origin-Embedder-Policy": "unsafe-none" "Cross-Origin-Embedder-Policy": "unsafe-none"

View File

@@ -16,6 +16,10 @@ EXA_API_KEY=your_exa_api_key_here
# Frontend URL for OAuth callbacks # Frontend URL for OAuth callbacks
FRONTEND_URL=https://alwrity-ai.vercel.app 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) # OAuth Redirect URIs (Using environment variable for flexibility)
GSC_REDIRECT_URI=${FRONTEND_URL}/gsc/callback GSC_REDIRECT_URI=${FRONTEND_URL}/gsc/callback

View File

@@ -8,6 +8,9 @@ from fastapi.responses import RedirectResponse, HTMLResponse, JSONResponse
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from pydantic import BaseModel from pydantic import BaseModel
from loguru import logger from loguru import logger
import json
import os
from urllib.parse import urlparse
from services.integrations.wordpress_oauth import WordPressOAuthService from services.integrations.wordpress_oauth import WordPressOAuthService
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
@@ -17,6 +20,65 @@ router = APIRouter(prefix="/wp", tags=["WordPress OAuth"])
# Initialize OAuth service # Initialize OAuth service
oauth_service = WordPressOAuthService() 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
message_html = message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return f"""
<!DOCTYPE html>
<html>
<head><title>{title}</title></head>
<body>
<h1>{heading_html}</h1>
<p>{message_html}</p>
<script>
(function() {{
var payload = {payload_json};
var targetOrigin = {target_origin};
var destination = window.opener || window.parent;
if (destination && targetOrigin) {{
try {{
destination.postMessage(payload, targetOrigin);
window.close();
return;
}} catch (_e) {{}}
}}
}})();
</script>
</body>
</html>
"""
# Pydantic Models # Pydantic Models
class WordPressOAuthResponse(BaseModel): class WordPressOAuthResponse(BaseModel):
auth_url: str auth_url: str
@@ -78,30 +140,12 @@ async def handle_wordpress_callback(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
content={"success": False, "error": error} content={"success": False, "error": error}
) )
html_content = f""" html_content = _oauth_callback_html(
<!DOCTYPE html> payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": _sanitize_string(error)},
<html> title="WordPress.com Connection Failed",
<head> heading="Connection Failed",
<title>WordPress.com Connection Failed</title> message="There was an error connecting to WordPress.com. You can close this window and try again."
<script> )
// Send error message to parent window
window.onload = function() {{
(window.opener || window.parent).postMessage({{
type: 'WPCOM_OAUTH_ERROR',
success: false,
error: '{error}'
}}, '*');
window.close();
}};
</script>
</head>
<body>
<h1>Connection Failed</h1>
<p>There was an error connecting to WordPress.com.</p>
<p>You can close this window and try again.</p>
</body>
</html>
"""
return HTMLResponse(content=html_content, headers={ return HTMLResponse(content=html_content, headers={
"Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Opener-Policy": "unsafe-none",
"Cross-Origin-Embedder-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, status_code=status.HTTP_400_BAD_REQUEST,
content={"success": False, "error": "Missing parameters"} content={"success": False, "error": "Missing parameters"}
) )
html_content = """ html_content = _oauth_callback_html(
<!DOCTYPE html> payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Missing parameters"},
<html> title="WordPress.com Connection Failed",
<head> heading="Connection Failed",
<title>WordPress.com Connection Failed</title> message="Missing required parameters. You can close this window and try again."
<script> )
// Send error message to opener/parent window
window.onload = function() {{
(window.opener || window.parent).postMessage({{
type: 'WPCOM_OAUTH_ERROR',
success: false,
error: 'Missing parameters'
}}, '*');
window.close();
}};
</script>
</head>
<body>
<h1>Connection Failed</h1>
<p>Missing required parameters.</p>
<p>You can close this window and try again.</p>
</body>
</html>
"""
return HTMLResponse(content=html_content, headers={ return HTMLResponse(content=html_content, headers={
"Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Opener-Policy": "unsafe-none",
"Cross-Origin-Embedder-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, status_code=status.HTTP_400_BAD_REQUEST,
content={"success": False, "error": "Token exchange failed"} content={"success": False, "error": "Token exchange failed"}
) )
html_content = """ html_content = _oauth_callback_html(
<!DOCTYPE html> payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Token exchange failed"},
<html> title="WordPress.com Connection Failed",
<head> heading="Connection Failed",
<title>WordPress.com Connection Failed</title> message="Failed to exchange authorization code for access token. You can close this window and try again."
<script> )
// Send error message to opener/parent window
window.onload = function() {{
(window.opener || window.parent).postMessage({{
type: 'WPCOM_OAUTH_ERROR',
success: false,
error: 'Token exchange failed'
}}, '*');
window.close();
}};
</script>
</head>
<body>
<h1>Connection Failed</h1>
<p>Failed to exchange authorization code for access token.</p>
<p>You can close this window and try again.</p>
</body>
</html>
"""
return HTMLResponse(content=html_content) return HTMLResponse(content=html_content)
# Return success page with postMessage script # Return success page with postMessage script
@@ -193,31 +201,17 @@ async def handle_wordpress_callback(
} }
) )
html_content = f""" html_content = _oauth_callback_html(
<!DOCTYPE html> payload={
<html> "type": "WPCOM_OAUTH_SUCCESS",
<head> "success": True,
<title>WordPress.com Connection Successful</title> "blogUrl": _sanitize_string(blog_url, 300),
<script> "blogId": _sanitize_string(blog_id, 128)
// Send success message to opener/parent window },
window.onload = function() {{ title="WordPress.com Connection Successful",
(window.opener || window.parent).postMessage({{ heading="Connection Successful",
type: 'WPCOM_OAUTH_SUCCESS', message="Your WordPress.com site has been connected successfully. You can close this window now."
success: true, )
blogUrl: '{blog_url}',
blogId: '{blog_id}'
}}, '*');
window.close();
}};
</script>
</head>
<body>
<h1>Connection Successful!</h1>
<p>Your WordPress.com site has been connected successfully.</p>
<p>You can close this window now.</p>
</body>
</html>
"""
return HTMLResponse(content=html_content, headers={ return HTMLResponse(content=html_content, headers={
"Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Opener-Policy": "unsafe-none",
@@ -226,30 +220,12 @@ async def handle_wordpress_callback(
except Exception as e: except Exception as e:
logger.error(f"Error handling WordPress OAuth callback: {e}") logger.error(f"Error handling WordPress OAuth callback: {e}")
html_content = """ html_content = _oauth_callback_html(
<!DOCTYPE html> payload={"type": "WPCOM_OAUTH_ERROR", "success": False, "error": "Callback error"},
<html> title="WordPress.com Connection Failed",
<head> heading="Connection Failed",
<title>WordPress.com Connection Failed</title> message="An unexpected error occurred during connection. You can close this window and try again."
<script> )
// Send error message to opener/parent window
window.onload = function() {{
(window.opener || window.parent).postMessage({{
type: 'WPCOM_OAUTH_ERROR',
success: false,
error: 'Callback error'
}}, '*');
window.close();
}};
</script>
</head>
<body>
<h1>Connection Failed</h1>
<p>An unexpected error occurred during connection.</p>
<p>You can close this window and try again.</p>
</body>
</html>
"""
return HTMLResponse(content=html_content, headers={ return HTMLResponse(content=html_content, headers={
"Cross-Origin-Opener-Policy": "unsafe-none", "Cross-Origin-Opener-Policy": "unsafe-none",
"Cross-Origin-Embedder-Policy": "unsafe-none" "Cross-Origin-Embedder-Policy": "unsafe-none"