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