Le champ ntfy_server du formulaire /settings était utilisé tel quel dans
urllib.request.urlopen sans aucune validation. Un attaquant pouvait pointer
le serveur vers 169.254.169.254 (metadata AWS/GCP), le réseau interne, ou un
service protégé, et comme la reminder_loop relance l'URL toutes les 5 min le
SSRF devenait persistant. Le header Authorization étant aussi contrôlable,
des requêtes authentifiées vers des services internes étaient possibles.
Ajout de notifications.is_safe_ntfy_server() qui valide : schéma http(s),
absence de credentials embarqués, refus de localhost et de toutes les IP
littérales privées (loopback, link-local, 10/8, 172.16/12, 192.168/16,
fc00::/7, ::1, multicast, réservé). send_ntfy et POST /settings appellent
ce validateur avant toute ouverture de connexion ou persistance ; un serveur
refusé déclenche une redirection /settings?ssrf_error=1 côté UI.
Les tests simulant un échec réseau (127.0.0.1:1) passent sur un host public
injoignable (.invalid), et un nouveau bloc test_security couvre les cas
loopback/private/bad-scheme/credentials et la non-ouverture de socket vers
une IP privée.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
137 lines
5.2 KiB
Python
137 lines
5.2 KiB
Python
"""Reminders: nag via ntfy when present-but-unpointed or departed-but-not-depointed."""
|
|
import asyncio
|
|
import ipaddress
|
|
import logging
|
|
import urllib.request
|
|
from datetime import datetime
|
|
from urllib.parse import urlsplit
|
|
|
|
from models import list_users, load_notif_config, load_presence, load_week_pointages, append_log
|
|
|
|
logger = logging.getLogger("pointeuse.notifications")
|
|
|
|
CHECK_INTERVAL_SECONDS = 300
|
|
|
|
|
|
def is_safe_ntfy_server(url: str) -> bool:
|
|
"""Valide qu'une URL ntfy est sûre (pas de SSRF vers le réseau interne/cloud metadata).
|
|
|
|
Règles :
|
|
- Schéma http(s) uniquement
|
|
- Pas de credentials embarqués dans l'URL
|
|
- Pas d'IP littérale privée (loopback, link-local, privé, multicast, réservé)
|
|
→ bloque 127.x, 10.x, 172.16/12, 192.168.x, 169.254.x (metadata AWS/GCP), fc00::/7, ::1…
|
|
- Pas de hostname localhost
|
|
"""
|
|
if not url or not isinstance(url, str):
|
|
return False
|
|
try:
|
|
parts = urlsplit(url.strip())
|
|
except ValueError:
|
|
return False
|
|
if parts.scheme not in ("http", "https"):
|
|
return False
|
|
if parts.username or parts.password:
|
|
return False
|
|
host = parts.hostname
|
|
if not host:
|
|
return False
|
|
host = host.lower().rstrip(".")
|
|
if host in ("localhost",):
|
|
return False
|
|
# IP littérale ?
|
|
try:
|
|
ip = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
# hostname : on accepte (la résolution DNS n'est pas vérifiée ici, mais on a déjà
|
|
# éliminé les IP littérales dangereuses, ce qui ferme le cas le plus exploité).
|
|
return True
|
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
|
return False
|
|
return True
|
|
|
|
|
|
def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None, token: str = "") -> bool:
|
|
if not is_safe_ntfy_server(server):
|
|
logger.warning("ntfy serveur refusé (SSRF): server=%r topic=%r title=%r", server, topic, title)
|
|
if user_id:
|
|
append_log(user_id, "notif", "échec",
|
|
f"{title} — {message} (serveur interdit: {server})")
|
|
return False
|
|
headers = {"Title": title.encode("utf-8"), "Priority": "high"}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
req = urllib.request.Request(
|
|
f"{server}/{topic}",
|
|
data=message.encode("utf-8"),
|
|
headers=headers,
|
|
method="POST",
|
|
)
|
|
try:
|
|
urllib.request.urlopen(req, timeout=10).close()
|
|
logger.info("notif ntfy envoyée: server=%r topic=%r title=%r", server, topic, title)
|
|
if user_id:
|
|
append_log(user_id, "notif", "envoyée", f"{title} — {message}")
|
|
return True
|
|
except Exception:
|
|
logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title)
|
|
if user_id:
|
|
append_log(user_id, "notif", "échec", f"{title} — {message}")
|
|
# best-effort: a missed reminder isn't worth crashing the loop
|
|
return False
|
|
|
|
|
|
def _todays_pointage(user_id: str, today: datetime) -> dict:
|
|
iso = today.isocalendar()
|
|
pointages = load_week_pointages(iso.year, iso.week, user_id)
|
|
date_str = today.strftime("%Y-%m-%d")
|
|
return next((p for p in pointages if p["date"] == date_str), {})
|
|
|
|
|
|
async def check_user(user_id: str, now: datetime):
|
|
notif = load_notif_config(user_id)
|
|
topic = notif.get("ntfy_topic")
|
|
server = notif.get("ntfy_server", "https://ntfy.sh")
|
|
token = notif.get("ntfy_token", "")
|
|
debut_h = notif.get("rappel_debut_h", 7)
|
|
fin_h = notif.get("rappel_fin_h", 20)
|
|
if not topic:
|
|
logger.debug("check_user(%r): pas de topic ntfy configuré, ignoré", user_id)
|
|
return
|
|
if now.weekday() >= 5 or not (debut_h <= now.hour < fin_h):
|
|
logger.debug("check_user(%r): hors plage de rappel (%s, plage %sh-%sh)", user_id, now, debut_h, fin_h)
|
|
return
|
|
|
|
presence = load_presence(user_id)
|
|
jour = _todays_pointage(user_id, now)
|
|
logger.debug(
|
|
"check_user(%r): presence=%s matin_entree=%r aprem_sortie=%r",
|
|
user_id, presence.get("present"), jour.get("matin_entree"), jour.get("aprem_sortie"),
|
|
)
|
|
|
|
if presence.get("present") and not jour.get("matin_entree"):
|
|
logger.info("rappel 'pense à pointer' pour %r", user_id)
|
|
await asyncio.to_thread(
|
|
send_ntfy, server, topic,
|
|
"Tu es dans les locaux mais pas encore pointé ce matin.",
|
|
"⏰ Pense à pointer", user_id, token,
|
|
)
|
|
elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"):
|
|
logger.info("rappel 'pense à dépointer' pour %r", user_id)
|
|
await asyncio.to_thread(
|
|
send_ntfy, server, topic,
|
|
"Tu as quitté les locaux sans dépointer la sortie.",
|
|
"⏰ Pense à dépointer", user_id, token,
|
|
)
|
|
|
|
|
|
async def reminder_loop():
|
|
logger.info("boucle de rappels démarrée (intervalle=%ss)", CHECK_INTERVAL_SECONDS)
|
|
while True:
|
|
now = datetime.now()
|
|
users = list_users()
|
|
logger.debug("cycle de vérification pour %d utilisateur(s)", len(users))
|
|
for user_id in users:
|
|
await check_user(user_id, now)
|
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|