Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user