Files
ObsiGate/backend/webhooks.py
T
bruno f783394f09
CI / lint (push) Successful in 3m17s
CI / security (push) Failing after 1m51s
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 1m50s
CI / e2e (push) Successful in 18m18s
feat: intégration TikTak #197-200 + scoped tokens, webhooks filtrés, listes mtime/size ; fix e2e-server BUG-112
2026-10-14 11:42:53 -04:00

381 lines
13 KiB
Python

"""
Webhook management and dispatch for ObsiGate.
Webhooks are HTTP POST callbacks triggered on file/directory events.
Configuration is persisted in data/webhooks.json.
Security (BUG-026):
* target URLs are validated against SSRF (scheme + resolved IP must be public);
* HTTP is refused unless ``OBSIGATE_WEBHOOK_ALLOW_HTTP=true``;
* private/loopback/link-local targets are refused unless
``OBSIGATE_WEBHOOK_ALLOW_PRIVATE=true``;
* redirects are never followed;
* signing secrets are **not** stored in the public config file — they live in
``data/webhook_secrets.json`` (0600) or in an environment variable named
``OBSIGATE_WEBHOOK_SECRET_<ID>``.
Events: file_created, file_deleted, file_modified, file_renamed,
directory_created, directory_deleted, directory_renamed
(#198 : chaque webhook peut porter un filtre optionnel ``vault`` et/ou
``path_prefix`` — seuls les événements correspondants sont livrés ; la
charge utile inclut toujours ``vault`` et le(s) chemin(s)).
"""
import asyncio
import hashlib
import hmac
import ipaddress
import json
import logging
import os
import socket
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import aiohttp
logger = logging.getLogger("obsigate.webhooks")
WEBHOOKS_FILE = Path("data/webhooks.json")
WEBHOOK_SECRETS_FILE = Path("data/webhook_secrets.json")
VALID_EVENTS = {
"file_created", "file_deleted", "file_modified", "file_renamed",
"directory_created", "directory_deleted", "directory_renamed",
}
def _allow_http() -> bool:
return os.environ.get("OBSIGATE_WEBHOOK_ALLOW_HTTP", "false").lower() == "true"
def _allow_private() -> bool:
return os.environ.get("OBSIGATE_WEBHOOK_ALLOW_PRIVATE", "false").lower() == "true"
def _is_public_ip(ip_str: str) -> bool:
"""True when *ip_str* is a globally routable unicast address."""
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return False
return not (
ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
or ip.is_multicast or ip.is_unspecified
)
def validate_webhook_url(url: str) -> str:
"""Validate the URL syntax/scheme and literal-IP safety at config time.
Raises:
ValueError: When the URL is malformed or points at an obviously
forbidden scheme/host.
"""
if not url or not isinstance(url, str):
raise ValueError("URL requise")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("L'URL doit utiliser http ou https")
if parsed.scheme == "http" and not _allow_http():
raise ValueError("HTTPS requis (définir OBSIGATE_WEBHOOK_ALLOW_HTTP=true pour autoriser http)")
host = parsed.hostname
if not host:
raise ValueError("Hôte manquant dans l'URL")
# Reject literal private/loopback IPs immediately (no DNS needed).
try:
ip = ipaddress.ip_address(host)
except ValueError:
return url # hostname — resolved and checked at dispatch time
if not _allow_private() and not _is_public_ip(str(ip)):
raise ValueError("Adresse privée/interne refusée")
return url
def is_safe_target(url: str) -> bool:
"""Full SSRF check performed right before dispatch (resolves the host).
Returns False when the URL is malformed, the scheme is forbidden, or any
resolved address is private/loopback/reserved.
"""
try:
validate_webhook_url(url)
except ValueError:
return False
if _allow_private():
return True
parsed = urlparse(url)
host = parsed.hostname or ""
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except socket.gaierror:
logger.warning(f"Webhook target host could not be resolved: {host}")
return False
for info in infos:
addr = str(info[4][0])
if not _is_public_ip(addr):
logger.warning(f"Webhook target resolves to a non-public address ({addr}); blocked")
return False
return True
def _normalize_vault_filter(vault: str | None) -> str | None:
"""Normalize an optional vault filter (``None``/empty = no filtering)."""
if not vault or not isinstance(vault, str):
return None
cleaned = vault.strip()
return cleaned or None
def _normalize_prefix_filter(prefix: str | None) -> str | None:
"""Normalize an optional path-prefix filter (``None``/empty = no filtering)."""
if not prefix or not isinstance(prefix, str):
return None
cleaned = prefix.strip().strip("/")
return cleaned or None
def _event_paths(data: dict) -> list[str]:
"""Vault-relative paths carried by a webhook payload.
File/directory events use ``path``; renames and moves carry
``old_path``/``new_path`` instead.
"""
paths = []
for key in ("path", "old_path", "new_path"):
value = data.get(key)
if isinstance(value, str) and value:
paths.append(value.strip("/"))
return paths
def webhook_matches(wh: dict, event_type: str, data: dict) -> bool:
"""True when webhook *wh* must receive (*event_type*, *data*).
Pure function (no I/O) so the filtering is unit-testable :
- event subscription (as before) ;
- #198 optional ``vault`` filter — exact match on ``data["vault"]`` ;
- #198 optional ``path_prefix`` filter — matches when any carried path
equals the prefix or lies underneath it (``Docs`` matches ``Docs`` and
``Docs/a.md``, not ``Docs2/b.md``).
Webhooks created before #198 carry neither key and keep receiving
everything they subscribe to (backward compatible).
"""
if event_type not in wh.get("events", []):
return False
vault_filter = _normalize_vault_filter(wh.get("vault"))
if vault_filter and data.get("vault") != vault_filter:
return False
prefix = _normalize_prefix_filter(wh.get("path_prefix"))
if prefix:
wanted = prefix + "/"
for candidate in _event_paths(data):
if candidate == prefix or candidate.startswith(wanted):
break
else:
return False
return True
def _read() -> list:
if not WEBHOOKS_FILE.exists():
return []
try:
return json.loads(WEBHOOKS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
def _write(webhooks: list):
WEBHOOKS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = WEBHOOKS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(webhooks, indent=2, default=str), encoding="utf-8")
tmp.replace(WEBHOOKS_FILE)
def _read_secrets() -> dict:
if not WEBHOOK_SECRETS_FILE.exists():
return {}
try:
return json.loads(WEBHOOK_SECRETS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
# ROADMAP #85 T10a — verrou autour des read-modify-write des deux stores
# (webhooks + secrets) : perte de mises à jour en cas de mutations
# concurrentes.
_lock = threading.RLock()
def _write_secrets(secrets: dict):
WEBHOOK_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = WEBHOOK_SECRETS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(secrets, indent=2), encoding="utf-8")
tmp.replace(WEBHOOK_SECRETS_FILE)
try:
WEBHOOK_SECRETS_FILE.chmod(0o600)
except OSError:
pass # Windows doesn't support Unix permissions
def _store_secret(wh_id: str, secret: str | None) -> None:
with _lock:
secrets = _read_secrets()
if secret:
secrets[wh_id] = secret
else:
secrets.pop(wh_id, None)
_write_secrets(secrets)
def _get_secret(wh: dict) -> str | None:
"""Resolve a webhook secret from env, dedicated store, or legacy record."""
env_key = "OBSIGATE_WEBHOOK_SECRET_" + wh["id"].replace("-", "_").upper()
env_val = os.environ.get(env_key)
if env_val:
return env_val
stored = _read_secrets().get(wh["id"])
if stored:
return stored
return wh.get("secret") # legacy inline secret
def _public_view(wh: dict) -> dict:
"""Return a webhook record safe to expose through the API."""
clean = {k: v for k, v in wh.items() if k != "secret"}
clean["has_secret"] = bool(_get_secret(wh))
return clean
def get_webhooks() -> list:
return [_public_view(wh) for wh in _read()]
def create_webhook(
name: str,
url: str,
events: list[str],
secret: str | None = None,
vault: str | None = None,
path_prefix: str | None = None,
) -> dict:
validate_webhook_url(url)
with _lock:
webhooks = _read()
wh_id = str(uuid.uuid4())
wh = {
"id": wh_id,
"name": name,
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_fired_at": None,
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
"vault": _normalize_vault_filter(vault),
"path_prefix": _normalize_prefix_filter(path_prefix),
}
webhooks.append(wh)
_write(webhooks)
if secret:
_store_secret(wh_id, secret)
logger.info(f"Created webhook '{name}' → {url}")
return _public_view(wh)
def update_webhook(wh_id: str, updates: dict) -> dict | None:
with _lock:
webhooks = _read()
for wh in webhooks:
if wh["id"] == wh_id:
if updates.get("url"):
validate_webhook_url(updates["url"])
if "secret" in updates:
_store_secret(wh_id, updates["secret"])
# #198 : normaliser les filtres ("" réinitialise le filtre).
for key, normalizer in (
("vault", _normalize_vault_filter),
("path_prefix", _normalize_prefix_filter),
):
if key in updates:
updates[key] = normalizer(updates[key])
safe_updates = {
k: v for k, v in updates.items()
if k not in ("id", "secret")
}
wh.update(safe_updates)
_write(webhooks)
return _public_view(wh)
return None
def delete_webhook(wh_id: str) -> bool:
with _lock:
webhooks = _read()
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
if len(new_list) == len(webhooks):
return False
_write(new_list)
secrets = _read_secrets()
if secrets.pop(wh_id, None) is not None:
_write_secrets(secrets)
return True
async def dispatch_webhooks(event_type: str, data: dict):
"""Fire all enabled webhooks subscribed to event_type.
#198 : les webhooks portant un filtre ``vault``/``path_prefix`` ne sont
livrés que pour les événements correspondants (voir :func:`webhook_matches`).
"""
webhooks = _read()
targets = [
wh for wh in webhooks
if wh.get("enabled", True) and webhook_matches(wh, event_type, data)
]
if not targets:
return
payload = {
"event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": data,
}
body = json.dumps(payload, default=str)
async def _post(wh):
try:
# BUG-026: re-check the target right before connecting (DNS rebinding).
if not is_safe_target(wh["url"]):
logger.warning(f"Webhook '{wh['name']}' blocked by SSRF policy")
return
headers = {"Content-Type": "application/json", "X-ObsiGate-Event": event_type}
secret = _get_secret(wh)
if secret:
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
headers["X-ObsiGate-Signature"] = f"sha256={sig}"
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session, session.post(
wh["url"], data=body, headers=headers, allow_redirects=False
) as resp:
if resp.status < 400:
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else:
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
except Exception as e:
logger.warning(f"Webhook '{wh['name']}' error: {e}")
tasks = [asyncio.create_task(_post(wh)) for wh in targets]
# Don't await — fire and forget (webhooks should not block the main thread)
# But we do register them so they run in background
for task in tasks:
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)