MrMasterbay b57f454736 security(ssrf): pin webhook target to resolved IP + gate custom-template image_url at wget sink
- webhooks._guard_url now returns (ok, reason, url_to_use); public http(s) targets are
  IP-pinned via resolve_and_pin_url so a DNS rebind between the guard check and the POST
  can't swing the request to an internal host. allow_private opt-in skips the pin so an
  internal ntfy/Gotify keeps working. Both send_to_channel / _post_ntfy callers use the
  pinned url. (Aikido #469089218)
- templates_lib deploy: re-run the SSRF guard on tpl['image_url'] right before the node
  wgets it. add_custom_template validates on entry, but built-in catalog and DB-stored
  URLs reached the sink unchecked. (Aikido #469089270)
- tests: webhook-guard 3-tuple contract + metadata/loopback block + allow_private no-pin.
2026-08-10 08:53:20 +02:00

266 lines
10 KiB
Python

# -*- coding: utf-8 -*-
"""
MK Apr 2026 — Webhook alert dispatcher for Slack / Discord / Teams / ntfy /
generic JSON endpoints.
A single channel config looks like::
{
"id": "short-uuid",
"name": "Ops Slack",
"type": "slack" | "discord" | "teams" | "ntfy" | "generic",
"url": "https://hooks.slack.com/services/...",
"token": "", # ntfy — if the topic requires auth
"topic": "", # ntfy — topic, if url is the server base only
"enabled": true
}
send_to_channels(alert) loops enabled channels and POSTs a provider-shaped body.
Short timeout + per-channel try/except so one dead webhook can't block the rest.
"""
import json
import logging
import re
import uuid
from datetime import datetime
try:
import requests
except ImportError:
requests = None
def _guard_url(url):
"""SSRF guard for admin-configured alert webhooks (audit M-7/M-8). Blocks
metadata / loopback / RFC1918 targets unless the operator opts in via the
`alert_webhook_allow_private` server setting (some shops run an internal
ntfy/Gotify on the LAN). Mirrors the siem.py / site_recovery.py pattern.
Imports are deferred so this utils-layer module doesn't pull api.* at load.
Returns (ok, reason, url_to_use). url_to_use is IP-pinned so a re-resolve between this
check and the POST can't be rebound to an internal address (Aikido #469089218)."""
try:
from pegaprox.utils.url_security import is_safe_outbound_url, resolve_and_pin_url, SsrfError
from pegaprox.api.helpers import load_server_settings
except Exception:
return True, '', url # guard module unavailable → don't silently break sends
allow_priv = bool((load_server_settings() or {}).get('alert_webhook_allow_private', False))
ok, reason = is_safe_outbound_url(url, allowed_schemes=('https', 'http'), allow_private=allow_priv)
if not ok:
return False, reason, url
if allow_priv:
return True, '', url # operator opted into internal targets — don't pin
try:
return True, '', resolve_and_pin_url(url, allowed_schemes=('https', 'http'))
except SsrfError as e:
return False, str(e), url
except Exception:
return True, '', url # pinning unavailable — the preflight guard already passed
def _severity_color(sev):
"""Returns hex color for Slack/Discord embed side-stripe."""
return {'critical': '#f54f47', 'warning': '#efc006'}.get((sev or '').lower(), '#60b515')
# MK May 2026 (audit fix M-11) — Slack/Discord/Teams/HEC webhook URLs embed
# their secret as part of the path. `requests` exceptions render either the
# full URL or just the path-only fragment (e.g. "with url: /services/..."),
# both of which end up in our log file. Redact both shapes.
_WEBHOOK_URL_RE = re.compile(
r'https?://[A-Za-z0-9_.\-:]+/('
r'services/[A-Za-z0-9/_=?&.-]+'
r'|api/webhooks/[A-Za-z0-9/_=?&.-]+'
r'|webhook[A-Za-z0-9/_=?&.-]+'
r'|services/collector[A-Za-z0-9/_=?&.-]*'
r')',
re.IGNORECASE,
)
# detached path-only patterns: appear when requests stringifies certain
# exceptions as "...url: /services/T01.../B02.../SECRET..." without the
# leading scheme/host. Three or more slash-separated segments → secret token.
_WEBHOOK_PATH_ONLY_RE = re.compile(
r'(/(?:services|api/webhooks|webhook[^\s/]*|services/collector)/[A-Za-z0-9/_.=?&-]+)',
re.IGNORECASE,
)
def _redact_webhook_url(s):
"""Strip secret-bearing webhook URL paths from a string before logging."""
if not s: return s
s = _WEBHOOK_URL_RE.sub('[REDACTED-WEBHOOK-URL]', str(s))
s = _WEBHOOK_PATH_ONLY_RE.sub('[REDACTED-WEBHOOK-PATH]', s)
return s
def _ntfy_priority(sev):
"""ntfy uses 1-5 where 5 is urgent. Map severities."""
return {'critical': 5, 'warning': 4, 'info': 3}.get((sev or '').lower(), 3)
def _build_slack(alert):
title = alert.get('alert_name') or 'PegaProx alert'
message = alert.get('message', '')
color = _severity_color(alert.get('severity'))
return {
'attachments': [{
'color': color,
'title': f":rotating_light: {title}",
'text': message,
'fields': [
{'title': 'Severity', 'value': alert.get('severity', 'info'), 'short': True},
{'title': 'Target', 'value': f"{alert.get('target_type', '')}: {alert.get('target_name', '')}", 'short': True},
{'title': 'Metric', 'value': f"{alert.get('metric', '')} = {alert.get('current_value', '')}", 'short': True},
{'title': 'Cluster', 'value': alert.get('cluster_id', '-'), 'short': True},
],
'footer': 'PegaProx',
'ts': int(datetime.now().timestamp()),
}]
}
def _build_discord(alert):
title = alert.get('alert_name') or 'PegaProx alert'
color_hex = _severity_color(alert.get('severity'))
try:
color_int = int(color_hex.lstrip('#'), 16)
except Exception:
color_int = 0xe57000
return {
'embeds': [{
'title': f"🚨 {title}",
'description': alert.get('message', ''),
'color': color_int,
'fields': [
{'name': 'Severity', 'value': alert.get('severity', 'info'), 'inline': True},
{'name': 'Target', 'value': f"{alert.get('target_type', '')}: {alert.get('target_name', '')}", 'inline': True},
{'name': 'Metric', 'value': f"{alert.get('metric', '')} = {alert.get('current_value', '')}", 'inline': True},
],
'footer': {'text': 'PegaProx'},
'timestamp': datetime.now().isoformat(),
}]
}
def _build_teams(alert):
# MS Teams MessageCard (legacy but still works everywhere). Adaptive Cards
# need an auth-token dance that ops teams rarely bother with.
title = alert.get('alert_name') or 'PegaProx alert'
return {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': _severity_color(alert.get('severity')).lstrip('#'),
'summary': title,
'title': title,
'text': alert.get('message', ''),
'sections': [{
'facts': [
{'name': 'Severity', 'value': alert.get('severity', 'info')},
{'name': 'Target', 'value': f"{alert.get('target_type', '')}: {alert.get('target_name', '')}"},
{'name': 'Metric', 'value': f"{alert.get('metric', '')} = {alert.get('current_value', '')}"},
{'name': 'Cluster', 'value': alert.get('cluster_id', '-')},
],
}],
}
def _post_ntfy(channel, alert):
"""ntfy wants a plaintext body + headers. Different shape from JSON webhooks."""
if not requests:
return False, 'requests not available'
url = channel.get('url', '').rstrip('/')
topic = (channel.get('topic') or '').strip()
if topic:
url = f"{url}/{topic}"
headers = {
'Title': (alert.get('alert_name') or 'PegaProx alert')[:200],
'Priority': str(_ntfy_priority(alert.get('severity'))),
'Tags': alert.get('severity', 'info'),
}
token = channel.get('token')
if token:
headers['Authorization'] = f'Bearer {token}'
body = alert.get('message', '')
ok_url, _why, url = _guard_url(url)
if not ok_url:
return False, 'blocked: unsafe url'
try:
r = requests.post(url, data=body.encode('utf-8'), headers=headers, timeout=6, allow_redirects=False)
return 200 <= r.status_code < 300, f'HTTP {r.status_code}'
except Exception as e:
return False, _redact_webhook_url(str(e))
def send_to_channel(channel, alert):
"""Fire one alert to one channel. Returns (success, detail)."""
if not requests:
return False, 'requests library not installed'
if not channel.get('enabled', True):
return False, 'disabled'
url = (channel.get('url') or '').strip()
if not url:
return False, 'no url'
# M-7/M-8: gate the admin-supplied URL before any send (covers all 5 types —
# ntfy only appends a path to this same host).
ok_url, _why, url = _guard_url(url)
if not ok_url:
return False, 'blocked: unsafe url'
ctype = (channel.get('type') or 'generic').lower()
if ctype == 'ntfy':
return _post_ntfy(channel, alert)
if ctype == 'slack':
body = _build_slack(alert)
elif ctype == 'discord':
body = _build_discord(alert)
elif ctype == 'teams':
body = _build_teams(alert)
else:
# generic — raw alert payload; caller-side webhook can map as needed
body = {'alert': alert, 'source': 'pegaprox', 'timestamp': datetime.now().isoformat()}
try:
r = requests.post(url, json=body, timeout=6, allow_redirects=False)
return 200 <= r.status_code < 400, f'HTTP {r.status_code}'
except Exception as e:
return False, _redact_webhook_url(str(e))
def send_to_channels(alert, channel_ids=None):
"""Fire an alert to webhook channels.
channel_ids: optional iterable of channel IDs to restrict dispatch to.
If None (default) ALL enabled channels fire (pre-#213 behaviour).
Pass `[]` to fire nothing.
"""
try:
from pegaprox.api.helpers import load_server_settings
channels = (load_server_settings() or {}).get('alert_webhooks') or []
except Exception as e:
logging.debug(f"[webhooks] could not load channels: {e}")
return
if channel_ids is not None:
wanted = {str(c) for c in channel_ids}
channels = [c for c in channels if str(c.get('id')) in wanted]
for ch in channels:
try:
ok, detail = send_to_channel(ch, alert)
# MK May 2026 (M-11) — detail comes from send_to_channel which already
# redacts; redact again on outer dispatch-error to be defensive.
if ok:
logging.info(f"[webhooks] → {ch.get('name', ch.get('id'))}: {detail}")
else:
logging.warning(f"[webhooks] → {ch.get('name', ch.get('id'))}: FAILED ({detail})")
except Exception as e:
logging.debug(f"[webhooks] channel {ch.get('id')} dispatch error: {_redact_webhook_url(str(e))}")
def new_channel(payload):
"""Normalize admin-submitted channel data — strips unknown fields, assigns id."""
allowed = {'name', 'type', 'url', 'token', 'topic', 'enabled'}
out = {k: v for k, v in (payload or {}).items() if k in allowed}
out.setdefault('enabled', True)
out.setdefault('type', 'generic')
out['id'] = (payload or {}).get('id') or uuid.uuid4().hex[:12]
return out