mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
176 lines
8.1 KiB
Python
176 lines
8.1 KiB
Python
"""Проверка содержимого запроса: схема `request`, вложение, тексты.
|
|
|
|
Три правила, которые здесь удерживаются:
|
|
|
|
1. Схема закрытая. Неизвестное поле — отказ, а не игнорирование.
|
|
2. Тексты остаются текстами. Ни message, ни contact никогда не попадают в
|
|
разметку: доставка выводит их как plain text, а управляющие символы
|
|
вычищаются здесь, чтобы получатель не увидел «пустое» письмо с сюрпризом.
|
|
3. Вложение — ровно тот файл, о котором объявил отправитель: длина и sha256
|
|
сверяются с заявленными, содержимое разбирается и проверяется по allowlist
|
|
верхнего уровня (§7.1 ТЗ).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import unicodedata
|
|
from dataclasses import dataclass
|
|
|
|
from . import config
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
REQUEST_REQUIRED = frozenset({"schema_version", "message", "idempotency_key"})
|
|
REQUEST_OPTIONAL = frozenset({"contact", "versions", "attachment"})
|
|
REQUEST_ALLOWED = REQUEST_REQUIRED | REQUEST_OPTIONAL
|
|
|
|
VERSIONS_ALLOWED = frozenset({"card", "integration", "home_assistant", "model", "export_schema"})
|
|
ATTACHMENT_META_ALLOWED = frozenset({"size", "sha256"})
|
|
|
|
PACKAGE_ALLOWED_TOP_LEVEL = frozenset({
|
|
"format", "version", "versions", "runtime", "revisions",
|
|
"summary", "validation", "repairs", "plan_backup",
|
|
})
|
|
PACKAGE_FORMAT = "houseplan-support-package"
|
|
PACKAGE_VERSION = 1
|
|
|
|
IDEMPOTENCY_RE = re.compile(r"\A[A-Za-z0-9_.:-]{8,128}\Z")
|
|
SHA256_RE = re.compile(r"\A[0-9a-f]{64}\Z")
|
|
SAFE_VERSION_RE = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
|
|
FILENAME_RE = re.compile(r"\Ahouseplan-support-[0-9a-z-]{1,40}\.json\Z")
|
|
|
|
|
|
class ValidationError(ValueError):
|
|
"""Публичная причина отказа. Текст безопасно показывать наружу."""
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
def plain_text(value: str, limit: int, field: str) -> str:
|
|
"""Возвращает текст без управляющих символов и без сюрпризов раскладки.
|
|
|
|
Удаляются категории Cc (кроме перевода строки и табуляции) и Cf — в неё
|
|
входят маркеры двунаправленного письма, которыми в письме можно перевернуть
|
|
видимый порядок строк, не меняя байтов.
|
|
"""
|
|
if not isinstance(value, str):
|
|
raise ValidationError("support_rejected", f"{field} must be a string")
|
|
normalized = unicodedata.normalize("NFC", value)
|
|
cleaned = "".join(
|
|
ch for ch in normalized
|
|
if ch in "\n\t" or unicodedata.category(ch) not in {"Cc", "Cf"}
|
|
)
|
|
cleaned = cleaned.replace("\r\n", "\n").strip()
|
|
if len(cleaned) > limit:
|
|
raise ValidationError("support_rejected", f"{field} is too long")
|
|
return cleaned
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Request:
|
|
message: str
|
|
contact: str
|
|
versions: dict[str, str]
|
|
idempotency_key: str
|
|
attachment_size: int
|
|
attachment_sha256: str
|
|
|
|
|
|
def parse_request(raw: bytes) -> Request:
|
|
if len(raw) > 128 * 1024:
|
|
raise ValidationError("support_rejected", "request part is too large")
|
|
try:
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValidationError("support_rejected", "request part is not valid JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise ValidationError("support_rejected", "request part must be an object")
|
|
|
|
unknown = set(payload) - REQUEST_ALLOWED
|
|
if unknown:
|
|
raise ValidationError("support_rejected", f"unknown request fields: {sorted(unknown)}")
|
|
missing = REQUEST_REQUIRED - set(payload)
|
|
if missing:
|
|
raise ValidationError("support_rejected", f"missing request fields: {sorted(missing)}")
|
|
if payload["schema_version"] != SCHEMA_VERSION:
|
|
raise ValidationError("support_rejected", "unsupported schema_version")
|
|
|
|
message = plain_text(payload["message"], config.MAX_MESSAGE_CODEPOINTS, "message")
|
|
if not message:
|
|
raise ValidationError("support_invalid_message", "message is empty")
|
|
contact = plain_text(payload.get("contact", ""), config.MAX_CONTACT_CODEPOINTS, "contact")
|
|
|
|
key = payload["idempotency_key"]
|
|
if not isinstance(key, str) or not IDEMPOTENCY_RE.match(key):
|
|
raise ValidationError("support_rejected", "bad idempotency_key")
|
|
|
|
versions_raw = payload.get("versions", {})
|
|
if not isinstance(versions_raw, dict):
|
|
raise ValidationError("support_rejected", "versions must be an object")
|
|
unknown_versions = set(versions_raw) - VERSIONS_ALLOWED
|
|
if unknown_versions:
|
|
raise ValidationError("support_rejected", f"unknown versions: {sorted(unknown_versions)}")
|
|
versions: dict[str, str] = {}
|
|
for name, value in versions_raw.items():
|
|
text = str(value)
|
|
if not SAFE_VERSION_RE.match(text):
|
|
raise ValidationError("support_rejected", f"bad version value: {name}")
|
|
versions[name] = text
|
|
|
|
meta = payload.get("attachment", {})
|
|
if not isinstance(meta, dict):
|
|
raise ValidationError("support_rejected", "attachment meta must be an object")
|
|
unknown_meta = set(meta) - ATTACHMENT_META_ALLOWED
|
|
if unknown_meta:
|
|
raise ValidationError("support_rejected", f"unknown attachment meta: {sorted(unknown_meta)}")
|
|
size = meta.get("size", 0)
|
|
digest = meta.get("sha256", "")
|
|
if not isinstance(size, int) or isinstance(size, bool) or size < 0:
|
|
raise ValidationError("support_rejected", "attachment size must be a non-negative integer")
|
|
if size > config.MAX_ATTACHMENT_BYTES:
|
|
raise ValidationError("support_package_too_large", "attachment is too large")
|
|
if digest and not (isinstance(digest, str) and SHA256_RE.match(digest)):
|
|
raise ValidationError("support_rejected", "attachment sha256 must be lowercase hex")
|
|
|
|
return Request(
|
|
message=message,
|
|
contact=contact,
|
|
versions=versions,
|
|
idempotency_key=key,
|
|
attachment_size=size,
|
|
attachment_sha256=digest,
|
|
)
|
|
|
|
|
|
def check_attachment(body: bytes, filename: str | None, content_type: str, request: Request) -> None:
|
|
if content_type != "application/json":
|
|
raise ValidationError("support_rejected", "attachment must be application/json")
|
|
if not filename or not FILENAME_RE.match(filename):
|
|
raise ValidationError("support_rejected", "unexpected attachment filename")
|
|
if len(body) > config.MAX_ATTACHMENT_BYTES:
|
|
raise ValidationError("support_package_too_large", "attachment is too large")
|
|
if request.attachment_size and len(body) != request.attachment_size:
|
|
raise ValidationError("support_rejected", "attachment size does not match the declared one")
|
|
if request.attachment_sha256:
|
|
actual = hashlib.sha256(body).hexdigest()
|
|
if actual != request.attachment_sha256:
|
|
raise ValidationError("support_rejected", "attachment hash does not match the declared one")
|
|
try:
|
|
package = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValidationError("support_rejected", "attachment is not valid JSON") from exc
|
|
if not isinstance(package, dict):
|
|
raise ValidationError("support_rejected", "attachment must be an object")
|
|
if package.get("format") != PACKAGE_FORMAT:
|
|
raise ValidationError("support_rejected", "attachment format is not recognised")
|
|
if package.get("version") != PACKAGE_VERSION:
|
|
raise ValidationError("support_rejected", "unsupported attachment version")
|
|
unknown = set(package) - PACKAGE_ALLOWED_TOP_LEVEL
|
|
if unknown:
|
|
raise ValidationError("support_rejected", f"unknown package sections: {sorted(unknown)}")
|