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:
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user