121 lines
4.1 KiB
Python
121 lines
4.1 KiB
Python
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)
|