Files
han-app/codebase/services/bitrix-sync/app/security.py
T

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)