Servicio notification_service — webhook y notificación con DNS-rebinding defense
{“related”: [“feature—monitoring—cierre-deudas-sa3”, “entity—monitoring—service—net-guard”, “entity—monitoring—service—http-service”, “entity—monitoring—model—ai-insight”, “incident—20260904—cola-auditoria-c-itsm-sla-notificaciones”], “sources”: [{“type”: “code”, “ref”: “monitoring/services/notification_service.py”}, {“type”: “commit”, “ref”: “cb4fe5e”}, {“type”: “commit”, “ref”: “63b626352ba4a1971d121299719507e341a5f3b4”, “last_seen”: “2026-09-04”}], “content”: ”# Servicio notification_service\n\nArchivo: monitoring/services/notification_service.py | Versión: Etapa 3 (cb4fe5e+) → cola auditoría C (63b6263, v1.106.0) | Status: Activo\n\n## Propósito\n\nEnvía notificaciones sobre insights de IA a canales externos: webhook genérico, Slack, Microsoft Teams, email. Integrado con net_guard.resolve_and_validate() para eliminar DNS-rebinding / TOCTOU en webhooks — el cliente nunca re-resuelve la URL del webhook después de la validación inicial.\n\n## Modelos de datos\n\n### NotificationChannel\n\npython\nclass NotificationChannel(models.Model):\n organization = models.ForeignKey(\"core.Organization\", ...)\n channel_type = models.CharField(\n choices=[\n (\"generic_webhook\", \"Generic Webhook\"),\n (\"slack\", \"Slack\"),\n (\"teams\", \"Microsoft Teams\"),\n (\"email\", \"Email\"),\n ]\n )\n config = models.JSONField() # {\"url\": \"...\", \"headers\": {...}, \"username\": \"...\", etc.}\n is_active = models.BooleanField(default=True)\n\n\n## API Pública\n\n### dispatch_notification(insight, event=\"created\", only_channel_id=None) -> bool\n\nRuta la notificación a todos los canales que apliquen (org + nivel de riesgo del insight), o solo a only_channel_id en escalada dirigida por nivel. Se llama de forma síncrona tras la creación/escalada de un insight, y desde v1.106.0 también por insight (transacción propia) tras un breach de SLA.\n\nDevuelve (contrato ampliado en v1.106.0, cola auditoría C, task #286): True si se envió a al menos un canal aplicable, o si no había ningún canal aplicable — nada que hacer no es un fallo. False únicamente cuando el rate-limit del tenant o una ventana de mantenimiento activa suprimieron TODOS los canales aplicables: quien llama debe interpretarlo como “reintentar más tarde”, nunca como éxito. sla_service.breach_ack_event/breach_resolve_event y escalation_service.notify_escalation dependen de este contrato para decidir si marcan el evento como resuelto.\n\nGuardias:\n- Rate limiting por org (RATE_LIMIT_PER_TENANT).\n- Supresión por ventana de mantenimiento — org-wide o vinculada al target, vía sla_service.active_maintenance_windows(target, suppress_field=\"suppress_notifications\") (antes de v1.106.0 solo cubría ventanas vinculadas explícitamente al target).\n- Sanitización de headers (rechaza headers bloqueados como Authorization en raw).\n\n### send_test_notification(channel: NotificationChannel) -> int | None\n\nEnvía una notificación de prueba (payload simple) a un canal. Retorna status code HTTP o None si falla.\n\n## Flujo de notificación genérica (webhook)\n\n\ndispatch_notification(insight, \"created\")\n ↓\n[Rate limit check: tenant]\n ↓\n[Filtrar canales aplicables por risk_level; ventana de mantenimiento fail-closed]\n ↓\n_send_webhook(channel, payload) / _send_slack(...) / _send_teams(...)\n ↓\n_post_pinned(url, payload, extra_headers=channel.config.get(\"headers\", {}))\n ↓\nresolve_and_validate(url) → ResolvedDestination(ips=[...], ...)\n ↓\n[SSRF rejected] → raise ValueError\n ↓\npinned_requests_session(dest) → requests.Session con HTTPAdapter pineado\n ↓\ntry_each_ip(dest, _attempt)\n where _attempt(ip):\n session.post(\n pinned_url(dest, ip), # p.ej. https://203.0.113.1:8080/webhook\n json=payload,\n headers={..., \"Host\": \"webhook.internal.example.com\"},\n timeout=WEBHOOK_TIMEOUT,\n allow_redirects=False\n )\n ↓\nreturn status_code (p.ej. 200, 404, 500, timeout)\n\n\nCrítico (Etapa 3):\n- resolve_and_validate() resuelve la URL una sola vez.\n- Iterar dest.ips → nunca re-resolver.\n- allow_redirects=False → Location no se sigue (evita SSRF).\n- Host header + TLS SNI → hostname original (no IP).\n\n## Canales soportados\n\n### Generic Webhook\n\njson\n{\n \"channel_type\": \"generic_webhook\",\n \"config\": {\n \"url\": \"https://webhook.example.com/insights\",\n \"headers\": {\"X-API-Key\": \"secret123\"} // Sanitizados\n }\n}\n\n\nPayload:\njson\n{\n \"insight_id\": 123,\n \"event\": \"created\",\n \"summary\": \"Interface Down\",\n \"severity\": \"high\",\n \"organization\": \"acme-corp\",\n \"target\": \"router-01\",\n \"timestamp\": \"2026-06-05T15:47:30Z\"\n}\n\n\nEnvío vía _post_pinned() (DNS-rebinding defense).\n\n### Slack\n\njson\n{\n \"channel_type\": \"slack\",\n \"config\": {\n \"url\": \"https://hooks.slack.com/services/T000.../B000.../.../...\"\n }\n}\n\n\nPayload:\njson\n{\n \"text\": \"CreaRack CNS Insight\",\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\"type\": \"mrkdwn\", \"text\": \"*Interface Down* (router-01)\\n...\"}\n }\n ]\n}\n\n\nEnvío vía _post_pinned() (Etapa 3: DNS-rebinding defense sobre hooks.slack.com).\n\n### Microsoft Teams\n\njson\n{\n \"channel_type\": \"teams\",\n \"config\": {\n \"url\": \"https://outlook.webhook.office.com/webhookb2/...\"\n }\n}\n\n\nPayload:\njson\n{\n \"type\": \"message\",\n \"attachments\": [\n {\n \"contentType\": \"application/vnd.microsoft.card.adaptive\",\n \"contentUrl\": null,\n \"content\": {\"$schema\": \"...\", \"type\": \"AdaptiveCard\", ...}\n }\n ]\n}\n\n\nEnvío vía _post_pinned() (Etapa 3: DNS-rebinding defense).\n\n### Email\n\njson\n{\n \"channel_type\": \"email\",\n \"config\": {\n \"recipients\": [\"ops@example.com\", \"ciso@example.com\"]\n }\n}\n\n\nEnvío: Vía Django send_mail() (no requiere pinning, es SMTP local/configurado).\n\n## Funciones internas\n\n### _safe_webhook_headers(raw_headers: dict) -> dict\n\nSanitiza headers de webhook:\n- Rechaza Authorization, Cookie, X-Auth-* (evita leak de credenciales en logs).\n- Preserva headers seguros (User-Agent, X-Custom-*, etc.).\n\n### _safe_error_message(e: Exception) -> str (v1.106.0, cola auditoría C)\n\nDevuelve un mensaje redactado para NotificationLog.error_message: solo el tipo de excepción + código HTTP si lo hay, nunca la URL cruda. El texto de una excepción de conexión a Slack/Teams incluye el path del webhook —que ES el secreto— y antes se guardaba en claro y se servía por /notifications/log a cualquier usuario con itsm:view. Única excepción: el diagnóstico propio de net_guard (\"Blocked webhook URL: ...\"), construido solo con esquema/host/puerto, nunca el path.\n\n### _post_pinned(url, payload, extra_headers=None) -> status_code\n\nEtapa 3: Nueva función centralizada que implementa DNS-rebinding defense.\n\npython\ndef _post_pinned(url, payload, extra_headers=None):\n dest, reason = resolve_and_validate(url)\n if dest is None:\n raise ValueError(f\"Blocked webhook URL: {reason}\")\n \n headers = _safe_webhook_headers(extra_headers or {})\n headers[\"Host\"] = host_header(dest)\n session = pinned_requests_session(dest)\n \n def _attempt(ip):\n resp = session.post(\n pinned_url(dest, ip),\n json=payload,\n headers=headers,\n timeout=WEBHOOK_TIMEOUT,\n allow_redirects=False\n )\n return resp.status_code\n \n return try_each_ip(dest, _attempt)\n\n\nUsado por _send_webhook(), _send_slack(), _send_teams().\n\n### _send_webhook(channel, payload)\n\nExtrae URL del config, llama _post_pinned().\n\n### _send_slack(channel, insight, event)\n\nConstruye payload Slack, llama _post_pinned().\n\n### _send_teams(channel, insight, event)\n\nConstruye payload Teams, llama _post_pinned().\n\n### _send_email(channel, insight, event)\n\nConstruye email HTML, llama send_mail().\n\n### _tenant_rate_limited(organization) -> bool\n\nDevuelve True si la org ya envió >= RATE_LIMIT_PER_TENANT notificaciones esta hora.\n\n### _channel_rate_limited(channel) -> bool\n\nDevuelve True si el canal ya envió >= RATE_LIMIT_PER_DEVICE notificaciones esta hora.\n\n## Guardias de seguridad\n\n| Guardia | Nivel | Implementación |\n|---------|-------|-----------------|\n| SSRF | Temprano | resolve_and_validate() en _post_pinned() |\n| DNS-rebinding | Connection-time | pinned_requests_session() + try_each_ip() |\n| Redirect SSRF | Response-time | allow_redirects=False |\n| Header injection | Sanitización | _safe_webhook_headers() rechaza sensibles |\n| Rate limit DOS | Tenancy/channel | _tenant_rate_limited(), _channel_rate_limited() |\n| Credential leak | Logging | Headers sensibles excluidas de logs |\n| Fail-closed en ventana de mantenimiento (v1.106.0) | Runtime | Si active_maintenance_windows() lanza excepción, no se envía y se deja traza NotificationLog(status=\"failed\") en vez de enviar igualmente |\n| Redacción de error_message (v1.106.0) | Logging | _safe_error_message() — nunca la URL/secreto del webhook |\n| Canal de tipo desconocido (v1.106.0) | Runtime | Se marca status=\"failed\" en vez de perderse en silencio |\n\n## Integración con net_guard\n\n| Función | Uso |\n|---------|-----|\n| resolve_and_validate(url) | Resolver + validar webhook URL una sola vez |\n| pinned_url(dest, ip) | Reconstruir webhook URL con IP pinneada |\n| try_each_ip(dest, _attempt) | Retry entre IPs del webhook |\n| host_header(dest) | Host header para presentar hostname original |\n| pinned_requests_session(dest) | requests.Session con SNI/cert verification pineados |\n\n## Usos principales\n\n- Webhook notification on insight created: Cuando el Tutor genera un nuevo AIInsight, notificar a todos los canales del usuario.\n- Webhook notification on insight acknowledged: El usuario marca un insight como visto, notificar canales.\n- Test webhook: Admin prueba la configuración de un canal con send_test_notification().\n- Escalation: Un insight escalado notifica solo al canal configurado para ese nivel (only_channel_id).\n- SLA breach (v1.106.0): sla_service.breach_ack_event/breach_resolve_event llaman a dispatch_notification por insight, cada uno en su propia transacción corta — ver [[incident—20260904—cola-auditoria-c-itsm-sla-notificaciones]].\n\n## Véase también\n\n- [[feature—monitoring—cierre-deudas-sa3]]\n- [[entity—monitoring—service—net-guard]]\n- [[entity—monitoring—service—http-service]]\n- [[entity—monitoring—model—ai-insight]]\n- [[incident—20260904—cola-auditoria-c-itsm-sla-notificaciones]]\n”}