feat: security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish

- Add outbound URL validation (SSRF) for webhook/Discord/Slack/ntfy/Matrix dispatch
- Template renderer: input/output caps and thread-based render timeout
- Webhook log filter: strip Authorization/signature/token-like headers; atomic prune
- Auth/JWT/backup/config tightening; misc frontend UX fixes
This commit is contained in:
2026-04-16 03:21:45 +03:00
parent 734e5c9340
commit f0739ca949
30 changed files with 567 additions and 105 deletions
@@ -34,6 +34,44 @@ _LOGGER = logging.getLogger(__name__)
# Fields to skip when serializing TrackingConfig into the generic `fields` dict
_TRACKING_SKIP = frozenset(("id", "user_id", "provider_type", "name", "icon", "created_at"))
# Import-time config hardening limits
_MAX_CONFIG_DEPTH = 6
_MAX_CONFIG_KEYS = 200
_MAX_STRING_LEN = 8192
def _sanitize_config(value: Any, depth: int = 0) -> Any:
"""Clamp imported config values to safe shapes before persistence.
Rejects anything that is not a JSON-compatible primitive/container, truncates
over-long strings, and caps dict/list sizes. Returns a defensively-copied
structure; the caller should never see attacker-controlled references.
"""
if depth > _MAX_CONFIG_DEPTH:
raise ValueError("Config nesting exceeds maximum depth")
if value is None or isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
return value[:_MAX_STRING_LEN]
if isinstance(value, list):
if len(value) > _MAX_CONFIG_KEYS:
raise ValueError("Config list exceeds maximum length")
return [_sanitize_config(v, depth + 1) for v in value]
if isinstance(value, dict):
if len(value) > _MAX_CONFIG_KEYS:
raise ValueError("Config dict exceeds maximum key count")
cleaned: dict[str, Any] = {}
for k, v in value.items():
if not isinstance(k, str):
raise ValueError("Config keys must be strings")
if len(k) > 128:
raise ValueError(f"Config key too long: {k[:40]}...")
cleaned[k] = _sanitize_config(v, depth + 1)
return cleaned
raise ValueError(f"Unsupported config value type: {type(value).__name__}")
# ---------------------------------------------------------------------------
# Export
@@ -530,9 +568,14 @@ async def import_backup(
)
if name is None:
continue
try:
safe_cfg = _sanitize_config(p.config or {})
except ValueError as exc:
result.warnings.append(f"Skipped provider '{p.name}': {exc}")
continue
new_p = ServiceProvider(
user_id=user_id, type=p.type, name=name,
icon=p.icon, config=p.config,
icon=p.icon, config=safe_cfg,
)
session.add(new_p)
await session.flush()
@@ -635,17 +678,27 @@ async def import_backup(
)
if name is None:
continue
try:
safe_tgt_cfg = _sanitize_config(tgt.config or {})
except ValueError as exc:
result.warnings.append(f"Skipped target '{tgt.name}': {exc}")
continue
new_tgt = NotificationTarget(
user_id=user_id, type=tgt.type, name=name,
icon=tgt.icon, config=tgt.config,
icon=tgt.icon, config=safe_tgt_cfg,
chat_action=tgt.chat_action,
)
session.add(new_tgt)
await session.flush()
id_map["targets"][tgt.id] = new_tgt.id
for r in tgt.receivers:
try:
safe_r_cfg = _sanitize_config(r.config or {})
except ValueError as exc:
result.warnings.append(f"Skipped receiver in '{tgt.name}': {exc}")
continue
session.add(TargetReceiver(
target_id=new_tgt.id, name=r.name, config=r.config,
target_id=new_tgt.id, name=r.name, config=safe_r_cfg,
receiver_key=r.receiver_key, locale=r.locale,
enabled=r.enabled,
))