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
@@ -12,6 +12,19 @@ import aiohttp
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.templates.context import build_template_context
from notify_bridge_core.templates.renderer import render_template
from .ssrf import UnsafeURLError, validate_outbound_url
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
def _new_session() -> aiohttp.ClientSession:
"""Per-dispatch aiohttp session with a sane default timeout.
We still open a short-lived session per dispatch (connection reuse across
dispatches lives in the server-side shared session), but we always attach
a total timeout so a hung peer cannot wedge the task forever.
"""
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
from .receiver import (
Receiver,
@@ -176,7 +189,7 @@ class NotificationDispatcher:
assets.append(asset_entry)
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
async with _new_session() as session:
client = TelegramClient(
session, bot_token,
url_cache=self._url_cache,
@@ -226,11 +239,16 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
async with _new_session() as session:
for receiver in target.receivers:
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
results.append({"success": False, "error": "Invalid webhook receiver"})
continue
try:
validate_outbound_url(receiver.url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
message = self._message_for_receiver(receiver, default_message, event, target)
payload = {
"message": message,
@@ -295,12 +313,17 @@ class NotificationDispatcher:
username = target.config.get("username")
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
async with _new_session() as session:
client = DiscordClient(session)
for receiver in target.receivers:
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
results.append({"success": False, "error": "Invalid discord receiver"})
continue
try:
validate_outbound_url(receiver.webhook_url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send(receiver.webhook_url, message, username=username))
@@ -316,12 +339,17 @@ class NotificationDispatcher:
username = target.config.get("username")
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
async with _new_session() as session:
client = SlackClient(session)
for receiver in target.receivers:
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
results.append({"success": False, "error": "Invalid slack receiver"})
continue
try:
validate_outbound_url(receiver.webhook_url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send(receiver.webhook_url, message, username=username))
@@ -336,11 +364,15 @@ class NotificationDispatcher:
auth_token = target.config.get("auth_token")
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
try:
validate_outbound_url(server_url)
except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"}
title = f"{event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
async with _new_session() as session:
client = NtfyClient(session)
for receiver in target.receivers:
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
@@ -363,12 +395,16 @@ class NotificationDispatcher:
access_token = target.config.get("access_token")
if not homeserver or not access_token:
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
try:
validate_outbound_url(homeserver)
except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"}
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
async with _new_session() as session:
client = MatrixClient(session, homeserver, access_token)
for receiver in target.receivers:
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
@@ -0,0 +1,80 @@
"""Outbound URL validation to mitigate SSRF attacks.
User-controlled URLs (provider `url`, webhook target `url`, shared-link
base URLs, image downloads) must be validated before any HTTP request is
issued. This module rejects schemes other than http/https and blocks
destinations that resolve to private, loopback, link-local, or unspecified
address ranges.
Set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the environment for
development against localhost services.
"""
from __future__ import annotations
import ipaddress
import os
import socket
from urllib.parse import urlparse
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
_ALLOWED_SCHEMES = {"http", "https"}
class UnsafeURLError(ValueError):
"""Raised when a URL targets a disallowed network destination."""
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
def validate_outbound_url(url: str) -> str:
"""Validate ``url`` is safe to fetch; returns the URL on success.
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
private addresses are permitted but the scheme check still applies.
"""
if not isinstance(url, str) or not url:
raise UnsafeURLError("URL is empty")
parsed = urlparse(url)
if parsed.scheme not in _ALLOWED_SCHEMES:
raise UnsafeURLError(f"Scheme '{parsed.scheme}' not allowed")
host = parsed.hostname
if not host:
raise UnsafeURLError("URL has no host")
if _ALLOW_PRIVATE:
return url
# Literal IP host
try:
ip = ipaddress.ip_address(host)
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} is in a blocked range")
return url
except ValueError:
pass
# Hostname — resolve and reject if any resolution is in a blocked range.
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
for info in infos:
sockaddr = info[4]
try:
ip = ipaddress.ip_address(sockaddr[0])
except ValueError:
continue
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}")
return url
@@ -7,8 +7,12 @@ from typing import Any
import aiohttp
from ..ssrf import UnsafeURLError, validate_outbound_url
_LOGGER = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30)
class WebhookClient:
"""Send JSON payloads to a webhook URL."""
@@ -19,11 +23,16 @@ class WebhookClient:
self._headers = headers or {}
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
try:
validate_outbound_url(self._url)
except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe URL: {err}"}
try:
async with self._session.post(
self._url,
json=payload,
headers={"Content-Type": "application/json", **self._headers},
timeout=_DEFAULT_TIMEOUT,
) as response:
if 200 <= response.status < 300:
return {"success": True, "status_code": response.status}
@@ -29,7 +29,7 @@ def _compile_jsonpath(expression: str) -> Any | None:
return _JSONPATH_CACHE[expression]
try:
compiled = jsonpath_parse(expression)
except (JsonPathParserError, Exception) as exc:
except (JsonPathParserError, ValueError, TypeError, AttributeError) as exc:
_LOGGER.warning("Invalid JSONPath expression '%s': %s", expression, exc)
compiled = None
_JSONPATH_CACHE[expression] = compiled
@@ -69,6 +69,10 @@ def parse_webhook(
Returns:
A ServiceEvent, or None if parsing fails critically.
"""
# Defensive: upstream callers should pass a dict, but tolerate non-dict
# payloads by coercing to an empty mapping rather than raising.
if not isinstance(payload, dict):
payload = {}
# Build a combined data dict so JSONPath can reference headers too
data: dict[str, Any] = {**payload}
if headers:
@@ -1,8 +1,18 @@
"""Template rendering engine using Jinja2 SandboxedEnvironment."""
"""Template rendering engine using Jinja2 SandboxedEnvironment.
Hardening applied:
* SandboxedEnvironment with autoescape for attribute/method isolation.
* Input length cap to short-circuit pathological templates before parsing.
* Output length cap via a custom stream check to prevent memory blow-ups.
* Cooperative time budget via a thread-based watchdog -- a runaway template
(``{% for i in range(10**8) %}``) is interrupted instead of wedging the worker.
"""
from __future__ import annotations
import logging
import threading
from typing import Any
import jinja2
@@ -10,16 +20,75 @@ from jinja2.sandbox import SandboxedEnvironment
_LOGGER = logging.getLogger(__name__)
MAX_TEMPLATE_LEN = 64 * 1024 # 64 KiB source
MAX_OUTPUT_LEN = 256 * 1024 # 256 KiB rendered
RENDER_TIMEOUT_SECONDS = 2.0
_env = SandboxedEnvironment(autoescape=True)
class TemplateRenderTimeout(jinja2.TemplateError):
"""Raised when a template exceeds the configured render budget."""
def _render_with_timeout(template: jinja2.Template, context: dict[str, Any]) -> str:
"""Render `template` in a worker thread with a hard timeout.
Jinja2 has no built-in timeout; we run the render in a daemon thread and
join with a deadline. If the deadline is exceeded we raise and let the
thread die with the process -- accepted trade-off for a bounded-budget
admin-authored template.
"""
result: dict[str, Any] = {}
def _run() -> None:
try:
result["value"] = template.render(**context)
except BaseException as exc: # noqa: BLE001 - forward to caller
result["error"] = exc
worker = threading.Thread(target=_run, daemon=True)
worker.start()
worker.join(RENDER_TIMEOUT_SECONDS)
if worker.is_alive():
raise TemplateRenderTimeout(
f"Template render exceeded {RENDER_TIMEOUT_SECONDS}s budget"
)
if "error" in result:
raise result["error"]
return result.get("value", "")
def render_template(template_str: str, context: dict[str, Any]) -> str:
"""Render a Jinja2 template string with the given context.
Falls back to returning the raw template on error.
Enforces source length, output length, and wall-clock time caps.
Returns a placeholder on any failure so callers never see a partial render.
"""
if not isinstance(template_str, str):
return ""
if len(template_str) > MAX_TEMPLATE_LEN:
_LOGGER.warning(
"Template source exceeds %d chars (%d); refusing to render",
MAX_TEMPLATE_LEN, len(template_str),
)
return "[Template too large]"
try:
return _env.from_string(template_str).render(**context)
compiled = _env.from_string(template_str)
output = _render_with_timeout(compiled, context)
except TemplateRenderTimeout as e:
_LOGGER.error("Template render timeout: %s", e)
return "[Template render timeout]"
except jinja2.TemplateError as e:
_LOGGER.error("Template render error: %s", e)
return "[Template rendering error]"
except Exception as e: # sandbox guarded — log and fall back safely
_LOGGER.error("Unexpected template error: %s", e, exc_info=True)
return "[Template rendering error]"
if len(output) > MAX_OUTPUT_LEN:
_LOGGER.warning(
"Template output truncated from %d to %d bytes",
len(output), MAX_OUTPUT_LEN,
)
return output[:MAX_OUTPUT_LEN] + "\n[truncated]"
return output