Output Sanitization¶
Free-form fields on Zscaler resources — rule descriptions, label descriptions, location names, custom URL category names, segment group descriptions — are admin-editable. The Zscaler APIs return these fields verbatim. If an attacker (or a careless admin) stuffs invisible Unicode characters, raw HTML, or fake code fences into one of those fields, the agent that consumes the tool response can be tricked into following injected instructions.
The MCP server therefore runs every string in every tool result through a three-stage sanitizer before it leaves the wire. This is a defense-in-depth layer that runs in addition to the network and authentication hardening.
The three stages¶
Stage 1 — Invisible / control-character stripping. Removes zero-width characters (ZWSP, ZWJ, ZWNJ, word joiner, invisible times/separator/plus), the full BiDi control range (LRO, RLO, LRE, RLE, PDF, LRI, RLI, FSI, PDI, LTR/RTL marks), Arabic letter mark, soft hyphen, BOM, and any unassigned/private/format-category codepoint. NBSP (U+00A0) is normalized to a regular space. Tab, LF, and CR survive (multi-line descriptions are legitimate).
Stage 2 — HTML / Markdown sanitization.
Uses Bleach (Mozilla’s Python equivalent of bluemonday) configured with an empty tag/attribute allowlist — every HTML tag and HTML comment is stripped; printable text is kept. A regex pass then collapses Markdown image syntax  to alt (so embedded URLs never reach the agent) and Markdown link syntax [text](url) to text (url) (URL is visible but no longer a directive).
Stage 3 — Code-fence info-string filtering.
Markdown fenced blocks whose info-string contains role/override tokens (system, user, assistant, tool, function, developer, ignore, override, instruction, prompt, role) get their info-string rewritten to text. The code body itself is preserved. Empty info-strings and legitimate language tags (python, json, bash, …) pass through unchanged.
Where it runs¶
Sanitization is applied recursively to dicts, lists, and tuples. Dict keys are not sanitized — they’re machine-defined field names; touching them would break callers that index by key. Bounded recursion (depth 32) protects against pathological structures.
The wrapper that drives it (zscaler_mcp/common/tool_helpers.py::_wrap_with_audit) covers every registered tool — read, write, or meta. Sanitization runs even when audit logging is off.
Default: on¶
Sanitization is enabled by default. There is no CLI flag to disable it — the choice is intentional. Operators who need to inspect raw output (typically for diagnostics) can opt out via env var:
export ZSCALER_MCP_DISABLE_OUTPUT_SANITIZATION=true
This removes a defense-in-depth layer. Only do this temporarily and under audit. The env-var-only switch is deliberate — making the opt-out explicit forces the operator to acknowledge what they’re disabling.
What an injection attempt looks like¶
Concrete examples of payloads that sanitization neutralizes:
Payload pattern |
Why it matters |
|---|---|
|
Right-to-Left Override (U+202E) flips the visible text direction in the agent’s prompt, hiding the actual string the agent will execute. |
|
Zero-width characters smuggle invisible content past visual review while still affecting tokenizer state. |
|
Raw HTML in an admin-editable field that could be rendered by a downstream UI or interpreted by an HTML-aware tokenizer. |
|
A Markdown link the agent might treat as a directive. |
Code fence with info-string |
A fake “system message” hidden inside what looks like a code example. |
All five patterns either disappear entirely (BiDi, ZWSP) or are converted to safe forms (<script> → empty, [here](url) → here (url), \`\`\`system → \`\`\`text) before the response reaches the agent.
What sanitization does NOT do¶
It does not validate the content of admin-editable fields. A rule description that literally says “ignore previous instructions” is not blocked — that’s a prompt-injection problem the model has to handle, not a transport-layer one.
It does not protect against prompt injection in fields that are tool inputs (search keys, JMESPath queries). Those are validated separately.
It does not protect against malicious Zscaler-side bugs. If a Zscaler API returns a malformed payload, the sanitizer makes a best effort but ultimately trusts the response shape.
Implementation¶
The sanitizer lives in zscaler_mcp/common/sanitize.py:
sanitize_text(value)— single string, runs all three stages.sanitize_value(value)— recursive traversal for dicts/lists/tuples with depth limit.Private stage functions:
_strip_invisible,_sanitize_html_markdown,_sanitize_code_fences.
46 tests in tests/test_sanitize.py cover golden injection inputs (RLO override, ZWSP, embedded <script>, fake system fence) plus the integration through the audit wrapper.
Environment summary¶
Setting |
Default |
Purpose |
|---|---|---|
|
|
Bypass the three-stage sanitizer. Diagnostics only. |
See also¶
Write Operations — HMAC confirmations defend against the next layer (the agent calling a destructive tool); sanitization defends against the agent being tricked by what it reads.
Tool-Call Audit Logging — once the agent has decided to act, audit logging captures the call.