Harden OAuth callback postMessage origin and payload encoding
This commit is contained in:
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
message_html = message.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
message_html = message.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user