from __future__ import annotations import hmac import ipaddress import re from collections.abc import Mapping from dataclasses import dataclass from urllib.parse import parse_qsl from app.config import Settings SECRET_PATTERNS = ( re.compile(r"(?i)(token|authorization|password|secret)=([^&\s]+)"), re.compile(r"https://[^/\s]+/rest/[0-9]+/[^/\s]+/"), ) def token_matches(received: str | None, current: str, previous: str | None = None) -> bool: candidate = (received or "").encode() current_match = hmac.compare_digest(candidate, current.encode()) previous_match = hmac.compare_digest(candidate, (previous or "").encode()) return current_match or (previous is not None and previous_match) def redact(value: object) -> str: text = str(value) for pattern in SECRET_PATTERNS: text = pattern.sub( lambda match: ( f"{match.group(1)}=[REDACTED]" if match.lastindex == 2 else "https://[REDACTED]/" ), text, ) if "@" in text or re.search(r"\+7[0-9]{10}", text): return "[PII_REDACTED]" return text[:512] @dataclass(frozen=True) class WebhookEvent: receiver_type: str entity_id: str event_type: str source_ip: str class WebhookValidationError(ValueError): pass def parse_bounded_form(body: bytes, *, max_fields: int) -> dict[str, str]: try: pairs = parse_qsl(body.decode("utf-8"), keep_blank_values=True, max_num_fields=max_fields) except (UnicodeDecodeError, ValueError) as exc: raise WebhookValidationError("malformed form") from exc if len(pairs) > max_fields: raise WebhookValidationError("too many form fields") data: dict[str, str] = {} for key, value in pairs: if len(key) > 128 or len(value) > 512: raise WebhookValidationError("form field too long") data[key] = value return data def validate_webhook( receiver: str, query: Mapping[str, str], form: Mapping[str, str], source_ip: str, settings: Settings, *, alert_entity_type_id: int | None, ) -> WebhookEvent: try: ip = ipaddress.ip_address(source_ip) except ValueError as exc: raise WebhookValidationError("invalid source address") from exc if not any(ip in network for network in settings.allowed_networks): raise PermissionError("source_ip") configured = ( settings.contact_receiver_token if receiver == "contact" else settings.alert_receiver_token ) previous = ( settings.contact_receiver_previous_token if receiver == "contact" else settings.alert_receiver_previous_token ) if not configured or not token_matches( query.get("token"), configured.get_secret_value(), previous.get_secret_value() if previous else None, ): raise PermissionError("token") if form.get("auth[domain]", "").lower() != str(settings.portal_host).lower(): raise WebhookValidationError("portal mismatch") if form.get("auth[member_id]") != settings.portal_member_id: raise WebhookValidationError("member mismatch") if form.get("document_id[0]") != "crm": raise WebhookValidationError("invalid document module") document_type = form.get("document_id[1]") document_id = form.get("document_id[2]", "") if receiver == "contact": match = re.fullmatch(r"CONTACT_([1-9][0-9]*)", document_id) if document_type != "CCrmDocumentContact" or not match: raise WebhookValidationError("invalid contact document") else: match = re.fullmatch(r"DYNAMIC_([1-9][0-9]*)_([1-9][0-9]*)", document_id) if not match or not document_type or "Dynamic" not in document_type: raise WebhookValidationError("invalid alert document") if alert_entity_type_id is None or int(match.group(1)) != alert_entity_type_id: raise WebhookValidationError("alert entity type mismatch") entity_id = match.group(match.lastindex or 1) if query.get("ID") != entity_id: raise WebhookValidationError("query/document ID mismatch") return WebhookEvent(receiver, entity_id, f"{receiver}.changed", source_ip)