Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core-api/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 44 additions & 12 deletions core-api/api/routers/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
# ============================================================================
Expand All @@ -42,10 +60,10 @@
# ============================================================================

@router.post("/gmail", response_model=WebhookProcessResponse)
async def gmail_webhook(request: Request):
async def gmail_webhook(request: Request, token: Optional[str] = Query(None)):

Check failure on line 63 in core-api/api/routers/webhooks.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=10xapp_core-oss&issues=AZ1brf5wS1usNdOdgz-g&open=AZ1brf5wS1usNdOdgz-g&pullRequest=44

Check failure on line 63 in core-api/api/routers/webhooks.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=10xapp_core-oss&issues=AZ1brf5wS1usNdOdgz-h&open=AZ1brf5wS1usNdOdgz-h&pullRequest=44
Comment thread
joshuajerin marked this conversation as resolved.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Receive Gmail push notifications from Google Cloud Pub/Sub.

Gmail notifications come via Pub/Sub in this format:
{
"message": {
Expand All @@ -55,20 +73,27 @@
},
"subscription": "..."
}

The decoded data contains:
{
"emailAddress": "user@example.com",
"historyId": "12345"
}


The push URL must include ?token=<GOOGLE_WEBHOOK_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")

Check failure on line 90 in core-api/api/routers/webhooks.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Document this HTTPException with status code 401 in the "responses" parameter.

See more on https://sonarcloud.io/project/issues?id=10xapp_core-oss&issues=AZ1brf5wS1usNdOdgz-i&open=AZ1brf5wS1usNdOdgz-i&pullRequest=44

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
Expand Down Expand Up @@ -149,24 +174,31 @@
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),

Check failure on line 177 in core-api/api/routers/webhooks.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=10xapp_core-oss&issues=AZ1brf5wS1usNdOdgz-j&open=AZ1brf5wS1usNdOdgz-j&pullRequest=44
x_goog_channel_token: Optional[str] = Header(None),

Check failure on line 178 in core-api/api/routers/webhooks.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=10xapp_core-oss&issues=AZ1brf5wS1usNdOdgz-k&open=AZ1brf5wS1usNdOdgz-k&pullRequest=44
):
"""
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")

Check failure on line 198 in core-api/api/routers/webhooks.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Document this HTTPException with status code 401 in the "responses" parameter.

See more on https://sonarcloud.io/project/issues?id=10xapp_core-oss&issues=AZ1brf5wS1usNdOdgz-l&open=AZ1brf5wS1usNdOdgz-l&pullRequest=44

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:
Expand Down
28 changes: 24 additions & 4 deletions core-api/api/services/agents/sandbox_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"""
import asyncio
import logging
import re
import posixpath
import shlex
Comment thread
coderabbitai[bot] marked this conversation as resolved.
from typing import List, Dict, Any

from e2b import Sandbox
Expand All @@ -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
Expand Down Expand Up @@ -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"])
Expand All @@ -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"])
Expand Down
16 changes: 11 additions & 5 deletions core-api/api/services/syncs/watch_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions core-api/setup_pubsub_subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions core-api/tests/unit/test_watch_manager_permanent_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion core-web/src/components/ai-app-builder/BuilderPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%",
Expand Down
4 changes: 2 additions & 2 deletions core-web/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
}
]
}
Expand Down
Loading