Files
remnawave-bedolaga-telegram…/app/webserver/remnawave_webhook.py
Fringg dc1e96bbe9 fix: security and architecture fixes for webhook handlers
- Add html.escape() to all untrusted webhook data in admin and device
  notifications (prevents HTML/Telegram injection)
- Add public send_webhook_notification() and is_enabled property to
  AdminNotificationService (eliminates private method access)
- Add dedicated NotificationType enum values for device and not_connected
  events (fixes incorrect semantic mapping)
- Extend user resolution to handle nested user objects and userUuid for
  device-scope events
- Replace manual __anext__() DB session with AsyncSessionLocal context
  manager; skip DB session for admin-only events
- Replace deprecated datetime.utcnow() with datetime.now(UTC)
- Use db.flush() instead of db.commit() in handlers (router commits)
- Wrap _notify_user in try/except to prevent notification failures from
  rolling back successful DB mutations
2026-02-10 05:55:48 +03:00

150 lines
5.7 KiB
Python

"""
FastAPI router for receiving incoming webhooks from RemnaWave backend.
Handles HMAC-SHA256 signature verification, payload parsing, and
event dispatch to RemnaWaveWebhookService.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from aiogram import Bot
from fastapi import APIRouter, Request, status
from fastapi.responses import JSONResponse
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
logger = logging.getLogger(__name__)
# Max accepted webhook payload size (64 KB) to prevent memory exhaustion DoS
_MAX_BODY_SIZE = 64 * 1024
def _verify_signature(raw_body: bytes, received_signature: str, secret: str) -> bool:
"""Verify HMAC-SHA256 signature from RemnaWave backend."""
expected = hmac.new(secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_signature)
def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
router = APIRouter()
webhook_service = RemnaWaveWebhookService(bot)
webhook_path = settings.REMNAWAVE_WEBHOOK_PATH
@router.get(webhook_path)
async def remnawave_webhook_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'remnawave_webhook',
'enabled': settings.is_remnawave_webhook_enabled(),
}
)
@router.post(webhook_path)
async def remnawave_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse(
{'status': 'error', 'reason': 'empty_body'},
status_code=status.HTTP_400_BAD_REQUEST,
)
if len(raw_body) > _MAX_BODY_SIZE:
logger.warning('RemnaWave webhook: payload too large (%d bytes)', len(raw_body))
return JSONResponse(
{'status': 'error', 'reason': 'payload_too_large'},
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
)
# Verify HMAC-SHA256 signature (always required)
secret = settings.REMNAWAVE_WEBHOOK_SECRET
if not secret:
logger.error('RemnaWave webhook: secret not configured, rejecting request')
return JSONResponse(
{'status': 'error', 'reason': 'webhook_not_configured'},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
signature = request.headers.get('X-Remnawave-Signature') or ''
if not signature:
logger.warning('RemnaWave webhook: missing signature header')
return JSONResponse(
{'status': 'error', 'reason': 'missing_signature'},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if not _verify_signature(raw_body, signature, secret):
logger.warning('RemnaWave webhook: invalid signature')
return JSONResponse(
{'status': 'error', 'reason': 'invalid_signature'},
status_code=status.HTTP_401_UNAUTHORIZED,
)
# Parse JSON payload
try:
payload = json.loads(raw_body.decode('utf-8'))
except json.JSONDecodeError:
return JSONResponse(
{'status': 'error', 'reason': 'invalid_json'},
status_code=status.HTTP_400_BAD_REQUEST,
)
# Extract and validate event info
scope = payload.get('scope', '')
event = payload.get('event', '')
data = payload.get('data')
if not scope or not event:
logger.warning('RemnaWave webhook: missing scope or event')
return JSONResponse(
{'status': 'error', 'reason': 'missing_scope_or_event'},
status_code=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(data, dict):
data = {}
# RemnaWave sends event as full qualified name (e.g. "user.modified"),
# so we use event directly instead of concatenating scope + event.
event_name = event
logger.info('RemnaWave webhook received: scope=%s, event=%s', scope, event_name)
# Process event — return 200 to prevent retries for application-level errors.
# Only return non-200 for infrastructure failures (DB unavailable).
# Admin events (node/service/crm) don't need a DB session.
if webhook_service.is_admin_event(event_name):
try:
processed = await webhook_service.process_event(None, event_name, data)
return JSONResponse({'status': 'ok', 'processed': processed})
except Exception:
logger.exception('RemnaWave webhook processing error for event %s', event_name)
return JSONResponse({'status': 'ok', 'processed': False})
# User events require a DB session
try:
async with AsyncSessionLocal() as db:
try:
processed = await webhook_service.process_event(db, event_name, data)
await db.commit()
return JSONResponse({'status': 'ok', 'processed': processed})
except Exception:
await db.rollback()
logger.exception('RemnaWave webhook processing error for event %s', event_name)
return JSONResponse({'status': 'ok', 'processed': False})
except Exception:
logger.error('RemnaWave webhook: failed to get database session')
return JSONResponse(
{'status': 'error', 'reason': 'database_unavailable'},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return router