Sanitizing Data You Put Into an XML Document Safely
User text put into XML by string formatting can add elements and attributes. See the injection, the escaping that stops it, CDATA, bad characters and names.

Every article about XML security so far has been about parsing XML that someone sent you. The mirror problem is writing it: you build an XML document and put a value you got from outside inside it, a customer name, a search term, a comment. If the value contains markup characters and you did not escape them, the person who typed the value has edited your document. It is the XML version of SQL injection, and it is as easy to prevent.
Injection into an element
The code looks innocent. It builds a user record:
name = 'Bob</name><role>admin</role><name>x'
naive = f"<user><name>{name}</name><role>user</role></user>"
r = etree.fromstring(naive)
print("naive roles:", [e.text for e in r.iter("role")], "| names:", [e.text for e in r.iter("name")])naive roles: ['admin', 'user'] | names: ['Bob', 'x']The "name" closed the name element, added a role element of its own and opened another name so the rest of the template still parses. The result is well-formed, so no parser complains. The document now says the user has two roles, and the first one, admin, is the attacker's. Whether that matters depends on how the consumer reads it. If it takes the first role, the user is an administrator. If it takes the last, they are not. Either way, the document no longer says what the application meant to write, and the difference between those two readers is exactly the kind of inconsistency attackers use.
The fix is to escape the text so that markup characters become data:
from xml.sax.saxutils import escape
safe = f"<user><name>{escape(name)}</name><role>user</role></user>"
r = etree.fromstring(safe); print("escape roles:", [e.text for e in r.iter("role")], "| name:", r.findtext("name"))escape roles: ['user'] | name: Bob</name><role>admin</role><name>xOne role, and the name reads back exactly as it was typed, markup characters included, as plain text. escape replaces &, < and > with entity references. The XML on the wire has </name>, and the parser turns that back into the characters. A library builder does the same thing without a call to remember:
u = etree.Element("user")
etree.SubElement(u, "name").text = name
etree.SubElement(u, "role").text = "user"
print(etree.tostring(u).decode())<user><name>Bob</name><role>admin</role><name>x</name><role>user</role></user>The builder wrote the escaped form on its own. This is the preferred design: no string templates for XML, ever. The same advice applies in every language, with DOMDocument or XMLWriter in PHP, XmlWriter or XElement in .NET, XMLStreamWriter in Java and xmlbuilder in Node. Each escapes for you.
Injection into an attribute
Attributes need more. The characters that end an attribute value are the quote that delimits it, and escape does not touch quotes unless you tell it to:
attr = '" admin="1'
print("attr naive :", etree.fromstring(f'<u name="{attr}"/>').attrib)
print("attr escape:", etree.fromstring(f'<u name="{escape(attr, {chr(34): """})}"/>').attrib)attr naive : {'name': '', 'admin': '1'}
attr escape: {'name': '" admin="1'}The naive version added a second attribute, admin="1", with an empty name. The escaped version, with the double quote mapped to ", keeps the whole thing in one value. Remember which quote character your template uses: if the attribute is delimited by single quotes, escape the single quote ('). A builder avoids the question: el.set("name", value) handles whichever quote it writes.
CDATA sections
Some people reach for CDATA to avoid escaping: <![CDATA[ ... ]]> holds text with no entity references. It is not a sanitizer. The one sequence it cannot contain is its own terminator, ]]>, and user text can contain it:
payload = "end]]><evil/>"
try: etree.fromstring(f"<a><![CDATA[{payload}]]></a>")
except etree.XMLSyntaxError as e: print("cdata naive :", e.msg[:50])
split = payload.replace("]]>", "]]]]><![CDATA[>")
print("cdata split:", repr(etree.fromstring(f"<a><![CDATA[{split}]]></a>").text))cdata naive : Sequence ']]>' not allowed in content, line 1, col
cdata split: 'end]]><evil/>'Raw, the payload ended the CDATA early and the rest became markup, which here was a syntax error and in a different template could have been an injected element. The standard workaround is to split the terminator across two sections, writing ]]]]><![CDATA[>, which closes the first section after ]] and opens a new one with >. The text read back whole. The better advice is to avoid CDATA for dynamic values and escape instead; CDATA is for static blocks of code or markup that a human edits.
Characters XML cannot hold
XML 1.0 allows tab, line feed, carriage return and characters from U+0020 up (excluding the surrogates and two non-characters). It does not allow most control characters, even escaped. A value with a bell (U+0007) or a null cannot appear in a well-formed document at all:
'ok' accepted
'bell\\x07' ValueError: All strings must be XML compatible: Unic
'nul\\x00' ValueError: All strings must be XML compatible: Unic
'tab\\there' accepted
'emoji 😀' accepted
'lone \\ud800' ValueError: 'utf-8' codec can't encode character '\\ulxml refused the bell, the null and a lone surrogate, and accepted a tab and an emoji. A template would have written the bell into the file and produced a document no strict parser reads (invalid character in XML). So the decision is what to do with such values: strip them (a regular expression for the disallowed ranges), replace them with a visible marker, or reject the input. For data that came from a person typing, stripping is normal; for data that came from another system, it is a signal that something upstream is wrong and worth a log line.
Element and attribute names
When the name comes from the user (a column name becoming an element, a key in a map of custom fields), the rules are stricter. A name must start with a letter or underscore and contain only name characters; a user-supplied a b or 1x is not a legal name. lxml raises Invalid tag name. The options are to generate the name from a safe alphabet (converting JSON to XML with sensible element names) or to keep the user's text as a value, not a name: <field name="a b">x</field>.
Namespace prefixes and processing instructions are the same: do not build <?...?> or xmlns: declarations from user text.
A checklist for output
Build documents with a library, not with string formatting.
If you must format strings, escape every value for the context: text, double-quoted attribute, single-quoted attribute.
Do not use CDATA for dynamic values. If you do, split
]]>.Remove or reject the characters XML 1.0 forbids.
Keep user text out of names, prefixes and processing instructions.
Encode the document in the encoding you declare, and write bytes, not text with a platform default.
Test with the injection strings above: a closing tag, a quote and a terminator, and assert that the parsed result has the same structure as the clean one.
Related reading
For the matching hazard in query languages, see the injection articles in the JSON cluster, for instance JSON injection in a SQL-built request. The related CSV problem is in converting CSV to XML with a template. For the overview, see XML security problems to close before production. The character rules are in the XML 1.0 specification, section 2.2, and the OWASP Web Security Testing Guide describes how attackers probe for it.
When escaping is not enough
The consumer interprets the text. Escaping makes the XML correct. If the consumer takes a text value and uses it in a SQL statement, a shell command, a file path or an HTML page, it needs the escaping of that context too.
The text is a name or a path. Those need validation against an allow list, not escaping.
The value is HTML you want to keep. Embedding markup as an XML-escaped string is safe for the XML and still dangerous when the reader renders it. Sanitize HTML with an allow list.
The document is signed or hashed. Escaping choices (" or the quote itself, character references) change the bytes. Canonicalize before you sign (XML signature wrapping in plain language).


