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
@@ -249,6 +249,22 @@ async def load_link_data(
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
slots_by_config.setdefault(s.config_id, {}).setdefault(event_key, {})[s.locale] = s.template
# Pre-resolve broadcast children in one query to avoid N+1 per-child fetches
broadcast_child_ids: set[int] = set()
for tt in active_links:
target = target_map.get(tt.target_id)
if target and target.type == "broadcast":
disabled_ids = set(target.config.get("disabled_child_ids", []))
for cid in target.config.get("child_target_ids", []):
if cid not in disabled_ids:
broadcast_child_ids.add(cid)
child_target_map: dict[int, NotificationTarget] = {}
if broadcast_child_ids:
child_rows = await session.exec(
select(NotificationTarget).where(NotificationTarget.id.in_(broadcast_child_ids))
)
child_target_map = {t.id: t for t in child_rows.all()}
link_data: list[dict[str, Any]] = []
for tt in active_links:
target = target_map.get(tt.target_id)
@@ -262,14 +278,13 @@ async def load_link_data(
template_config = tmpl_map.get(tmpl_id) if tmpl_id else None
template_slots = slots_by_config.get(template_config.id) if template_config else None
# Broadcast target: expand into child targets
# Broadcast target: expand into child targets (pre-loaded above)
if target.type == "broadcast":
child_ids = target.config.get("child_target_ids", [])
disabled_ids = set(target.config.get("disabled_child_ids", []))
for child_id in child_ids:
for child_id in target.config.get("child_target_ids", []):
if child_id in disabled_ids:
continue
child_target = await session.get(NotificationTarget, child_id)
child_target = child_target_map.get(child_id)
if not child_target or child_target.type == "broadcast":
continue
resolved = await _resolve_target(session, child_target)