diff --git a/core-api/api/config.py b/core-api/api/config.py index d404620..1fa2dbf 100644 --- a/core-api/api/config.py +++ b/core-api/api/config.py @@ -184,6 +184,9 @@ def r2_public_base_url(self) -> str: e2b_api_key: str = "" e2b_default_template: str = "base" # Default sandbox template ID + # Google webhook verification token (set on Pub/Sub push URL and Calendar watch) + google_webhook_token: str = "" + # Agent dispatch webhook agent_webhook_secret: str = "" # Shared secret for Supabase webhook validation diff --git a/core-api/api/routers/webhooks.py b/core-api/api/routers/webhooks.py index 3f8f49a..c164177 100644 --- a/core-api/api/routers/webhooks.py +++ b/core-api/api/routers/webhooks.py @@ -2,15 +2,17 @@ Webhooks router - Receives push notifications from external services Thin layer that handles HTTP concerns and delegates to services. """ -from fastapi import APIRouter, Request, Header, Query, Response +from fastapi import APIRouter, HTTPException, Request, Header, Query, Response from fastapi.responses import PlainTextResponse from typing import Optional import asyncio +import hmac import logging import json import base64 from datetime import datetime, timezone +from api.config import settings from api.services.webhooks import ( process_gmail_notification, process_calendar_notification @@ -24,6 +26,22 @@ router = APIRouter(prefix="/api/webhooks", tags=["webhooks"]) +def _verify_google_webhook_token(token: Optional[str]) -> bool: + """Verify the token query parameter matches our configured secret. + + Google Pub/Sub sends a ?token= param on push URLs and Calendar + sends the token set during watch creation in X-Goog-Channel-Token. + We verify it matches GOOGLE_WEBHOOK_TOKEN to prevent forged requests. + """ + expected = settings.google_webhook_token + if not expected: + logger.warning("GOOGLE_WEBHOOK_TOKEN not configured — rejecting Google webhook") + return False + if not token: + return False + return hmac.compare_digest(token, expected) + + # ============================================================================ # Response Models # ============================================================================ @@ -42,10 +60,10 @@ class Config: # ============================================================================ @router.post("/gmail", response_model=WebhookProcessResponse) -async def gmail_webhook(request: Request): +async def gmail_webhook(request: Request, token: Optional[str] = Query(None)): """ Receive Gmail push notifications from Google Cloud Pub/Sub. - + Gmail notifications come via Pub/Sub in this format: { "message": { @@ -55,20 +73,27 @@ async def gmail_webhook(request: Request): }, "subscription": "..." } - + The decoded data contains: { "emailAddress": "user@example.com", "historyId": "12345" } - + + The push URL must include ?token= for verification. + Returns 200 immediately to acknowledge receipt (required by Pub/Sub). """ + # Verify BEFORE the try/except that returns 200 on all errors + if not _verify_google_webhook_token(token): + logger.warning("Gmail webhook rejected: invalid or missing token") + raise HTTPException(status_code=401, detail="Unauthorized") + try: # Parse Pub/Sub message format body = await request.json() - - logger.info("📬 Gmail webhook received") + + logger.info("Gmail webhook received") logger.debug(f"Body keys: {body.keys() if body else 'empty'}") # Validate Pub/Sub message format @@ -149,24 +174,31 @@ async def calendar_webhook( x_goog_channel_id: Optional[str] = Header(None), x_goog_resource_id: Optional[str] = Header(None), x_goog_resource_state: Optional[str] = Header(None), - x_goog_message_number: Optional[str] = Header(None) + x_goog_message_number: Optional[str] = Header(None), + x_goog_channel_token: Optional[str] = Header(None), ): """ Receive Google Calendar push notifications. - + Google sends notifications when calendar events change. We use sync tokens to fetch only what changed. - + Headers from Google: - X-Goog-Channel-ID: The UUID of the notification channel - X-Goog-Resource-ID: Opaque ID for the watched resource - X-Goog-Resource-State: "sync" (initial) or "exists" (change notification) - X-Goog-Message-Number: Sequential message number - + - X-Goog-Channel-Token: Verification token set during watch creation + Returns 200 immediately to acknowledge receipt (required by Google). """ + # Verify BEFORE the try/except that returns 200 on all errors + if not _verify_google_webhook_token(x_goog_channel_token): + logger.warning(f"Calendar webhook rejected: invalid or missing token (channel={x_goog_channel_id})") + raise HTTPException(status_code=401, detail="Unauthorized") + try: - logger.info(f"📅 Calendar webhook received: channel={x_goog_channel_id}, state={x_goog_resource_state}") + logger.info(f"Calendar webhook received: channel={x_goog_channel_id}, state={x_goog_resource_state}") # Try to enqueue via QStash for async processing try: diff --git a/core-api/api/services/agents/sandbox_files.py b/core-api/api/services/agents/sandbox_files.py index 1c12699..402b53f 100644 --- a/core-api/api/services/agents/sandbox_files.py +++ b/core-api/api/services/agents/sandbox_files.py @@ -3,7 +3,8 @@ """ import asyncio import logging -import re +import posixpath +import shlex from typing import List, Dict, Any from e2b import Sandbox @@ -13,6 +14,26 @@ logger = logging.getLogger(__name__) +# Allowed root for sandbox file operations +_SANDBOX_ROOT = "/home/user" + + +def _sanitize_sandbox_path(path: str) -> str: + """Sanitize a file path for use in sandbox shell commands. + + - Resolves .. and . segments + - Ensures the resolved path stays under _SANDBOX_ROOT + - Shell-quotes the result to prevent argument injection + """ + # Normalize and resolve relative segments (posixpath for Linux sandbox) + resolved = posixpath.normpath(path) + + # Ensure it stays within the sandbox root + if not resolved.startswith(_SANDBOX_ROOT + "/") and resolved != _SANDBOX_ROOT: + raise ValueError(f"Path must be under {_SANDBOX_ROOT}") + + return shlex.quote(resolved) + def _get_e2b_api_key() -> str: key = settings.e2b_api_key @@ -64,8 +85,7 @@ async def list_sandbox_files(agent_id: str, path: str, user_jwt: str) -> List[Di if agent.get("sandbox_status") not in ("running", "idle"): raise ValueError(f"Sandbox is not running (status: {agent.get('sandbox_status')})") - # Sanitize path - safe_path = re.sub(r'[;&|`$]', '', path) + safe_path = _sanitize_sandbox_path(path) def _run(): sandbox = _connect_sandbox(agent["sandbox_id"]) @@ -92,7 +112,7 @@ async def read_sandbox_file(agent_id: str, path: str, user_jwt: str) -> str: if agent.get("sandbox_status") not in ("running", "idle"): raise ValueError(f"Sandbox is not running (status: {agent.get('sandbox_status')})") - safe_path = re.sub(r'[;&|`$]', '', path) + safe_path = _sanitize_sandbox_path(path) def _run(): sandbox = _connect_sandbox(agent["sandbox_id"]) diff --git a/core-api/api/services/syncs/watch_manager.py b/core-api/api/services/syncs/watch_manager.py index 94e310e..551d7d5 100644 --- a/core-api/api/services/syncs/watch_manager.py +++ b/core-api/api/services/syncs/watch_manager.py @@ -259,10 +259,14 @@ def start_calendar_watch( 'id': channel_id, 'type': 'web_hook', 'address': webhook_url, - 'expiration': expiration_ms + 'expiration': expiration_ms, } - - logger.info(f"🔔 Starting Calendar watch for user {user_id} with channel {channel_id}") + # Include verification token so we can authenticate incoming notifications + if not settings.google_webhook_token: + raise ValueError("GOOGLE_WEBHOOK_TOKEN is required for Calendar webhook watches") + request_body['token'] = settings.google_webhook_token + + logger.info(f"Starting Calendar watch for user {user_id} with channel {channel_id}") # Start the watch watch_response = service.events().watch( @@ -794,10 +798,12 @@ def start_calendar_watch_service_role( 'id': channel_id, 'type': 'web_hook', 'address': webhook_url, - 'expiration': expiration_ms + 'expiration': expiration_ms, } + if settings.google_webhook_token: + request_body['token'] = settings.google_webhook_token - logger.info(f"🔔 Starting Calendar watch for user {user_id[:8]}...") + logger.info(f"Starting Calendar watch for user {user_id[:8]}...") # Start the watch watch_response = calendar_service.events().watch( diff --git a/core-api/setup_pubsub_subscription.py b/core-api/setup_pubsub_subscription.py index 72ecc67..733da1e 100644 --- a/core-api/setup_pubsub_subscription.py +++ b/core-api/setup_pubsub_subscription.py @@ -39,8 +39,12 @@ def setup_pubsub_push_subscription(): subscription_name = f"{topic_name}-push-subscription" full_subscription = f"projects/{project_id}/subscriptions/{subscription_name}" - # Push endpoint - push_endpoint = f"{webhook_url}/api/webhooks/gmail" + # Push endpoint — include verification token + webhook_token = os.getenv("GOOGLE_WEBHOOK_TOKEN", "").strip() + if not webhook_token: + print("GOOGLE_WEBHOOK_TOKEN is required for Gmail push endpoint") + sys.exit(1) + push_endpoint = f"{webhook_url}/api/webhooks/gmail?token={webhook_token}" print("=" * 80) print("🔧 Setting up Google Cloud Pub/Sub Push Subscription") diff --git a/core-api/tests/unit/test_watch_manager_permanent_failures.py b/core-api/tests/unit/test_watch_manager_permanent_failures.py index 27299d0..439f8a5 100644 --- a/core-api/tests/unit/test_watch_manager_permanent_failures.py +++ b/core-api/tests/unit/test_watch_manager_permanent_failures.py @@ -83,6 +83,7 @@ def test_start_calendar_watch_service_role_permanent_failure_logs_warning_not_er with patch("api.services.syncs.watch_manager.settings", SimpleNamespace( webhook_base_url="https://core-api.test", + google_webhook_token="test-token", )): with patch("api.services.syncs.watch_manager.logger") as logger_mock: result = start_calendar_watch_service_role( @@ -147,6 +148,7 @@ def test_start_calendar_watch_service_role_transient_failure_still_logs_error(): with patch("api.services.syncs.watch_manager.settings", SimpleNamespace( webhook_base_url="https://core-api.test", + google_webhook_token="test-token", )): with patch("api.services.syncs.watch_manager.logger") as logger_mock: result = start_calendar_watch_service_role( diff --git a/core-web/src/components/ai-app-builder/BuilderPreview.tsx b/core-web/src/components/ai-app-builder/BuilderPreview.tsx index 17d7568..166c5a0 100644 --- a/core-web/src/components/ai-app-builder/BuilderPreview.tsx +++ b/core-web/src/components/ai-app-builder/BuilderPreview.tsx @@ -163,7 +163,7 @@ export default function BuilderPreview() { ref={handleIframeLoad} src={previewUrl || "about:blank"} title="App Preview" - allow="geolocation; camera; microphone" + sandbox="allow-scripts allow-same-origin allow-forms allow-popups" style={{ width: "100%", height: "100%", diff --git a/core-web/vercel.json b/core-web/vercel.json index c62c45d..1da7868 100644 --- a/core-web/vercel.json +++ b/core-web/vercel.json @@ -4,8 +4,8 @@ "source": "/(.*)", "headers": [ { - "key": "Content-Security-Policy-Report-Only", - "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.ingest.sentry.io https://*.ingest.us.sentry.io; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.posthog.com; frame-src https://snack-web-player.s3.us-west-1.amazonaws.com https://*.expo.dev; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" } ] }