"""Sanitise user-controlled values before they land in a :class:`NotificationEnvelope` body / subject / context. Why this module exists: prod incident posture. Multiple notification templates interpolate free-text fields that originate from end-user input (``project_name`` on an API-key request, ``display_name`` self-set on the profile page, etc.). Without sanitisation, a determined applicant can: 1. **CR/LF inject** their own "— Helix IT 平台" signature into the middle of the body — a phishing primitive routed through Helix's trusted Feishu / email channel. The recipient (often the applicant's manager, a finance reviewer, or IT admin) sees what looks like a Helix system message asking for credentials. 2. **Markup-marker inject** ``[r]…[/r]`` / ``[b]…[/b]`` markers that :func:`app.notifications.service._apply_inline_markup` rewrites to ``**…**`` — the attacker styles their payload with the platform's own "urgent / system-emphasis" red+bold formatting, indistinguishable from a legitimate alert flag. 3. **Body explosion** — pasting kilobytes of text into a free-text field would make the rendered card / email exceed the channel's per-message size limit (Feishu rejects >30k chars). The first incarnation of this helper lived inline in ``apikey/azure_openai_autogrant.py`` (PR #239 round-2 audit). When the ``apikey/budget_alerts.py`` module landed (PR #B for BU dept view + budget alerts) the security reviewer caught the same vulnerability pattern repeating — the helper is now promoted here so every notification-call site can apply identical sanitisation without copy-paste drift. Defensive posture: this is **call-site sanitisation**, paired with the renderer-side ``_strip_inline_markup`` (which only runs on subjects). Body templates that splice user content via ``str.format`` keep a clear contract: pass values through ``safe_envelope_text()`` before format-substitution. The helper is intentionally simple — no HTML escape (Feishu cards aren't HTML), no Markdown escape (we already strip the markers we know about) — so future renderers don't have to negotiate with prior sanitisation passes. """ from __future__ import annotations import re # Inline-markup markers the notification renderer interprets: # * ``[r]…[/r]`` → red+bold (cmdb.billing_alert / apikey.budget_alert) # * ``[b]…[/b]`` → bold (used in some leaver SLA templates) # * ``[h]…[/h]`` → bold + one size up (leave-balance card stats) # Stripping the OPENING tag is sufficient — without an open, a stray # closing tag renders as literal ``[/r]`` which is harmless at worst # and visibly ugly (signaling tampering) at best. We keep this list # narrow to whatever ``_apply_inline_markup`` actually rewrites; if # new markers are added there, mirror them here. _INLINE_MARKUP_PATTERN = re.compile(r"\[/?[rbh]\]", re.IGNORECASE) def safe_envelope_text(value: str | None, *, limit: int = 80) -> str: """Return a single-line, length-bounded, markup-stripped string. Use for any user-controlled value that lands in a notification envelope's body / subject / context (whether spliced via f-string or via the template's ``str.format`` substitution). Transformations: 1. CR/LF (``\\r\\n`` / ``\\n`` / ``\\r``) → single space. Defends against fake-signature injection in multi-line bodies. 2. ``[r]`` / ``[b]`` / ``[h]`` markers (opening and closing) stripped. Defends against attacker-styled "platform-emphasis" red/bold payloads. Case-insensitive — a future ``[R]…[/R]`` would still match. 3. Trim leading/trailing whitespace. 4. Truncate to ``limit`` characters (default 80, mirroring the Azure OpenAI auto-grant convention; pass ``limit=120`` for work_email and similar slightly-longer fields). ``None`` and empty string return ``""`` so callers can safely splice without a pre-check. The result is always ``str``. The helper is deliberately conservative — better to drop a legitimate ``[r]`` literal in a project name than to let a crafted one through. If a future template needs literal brackets, the template itself should escape them, not the call-site sanitiser. """ if not value: return "" # Order: strip markup BEFORE collapsing whitespace, so a # ``[r]\nproj[/r]`` first becomes ``\nproj`` then a single space- # joined ``proj`` rather than letting the newline survive an # already-markup-stripped pass. cleaned = _INLINE_MARKUP_PATTERN.sub("", value) # Strip Unicode bidirectional-control characters. These can be used # to make phishing payloads appear as benign text in Feishu card # previews while rendering differently in the actual client. # U+200E/200F (LRM/RLM), U+202A-202E (embeddings/overrides), # U+2066-2069 (isolates/PDI). cleaned = re.sub(r"[‎‏‪-‮⁦-⁩]", "", cleaned) cleaned = ( cleaned .replace("\r\n", " ") .replace("\n", " ") .replace("\r", " ") ) return cleaned.strip()[:limit] __all__ = ["safe_envelope_text"]