XML maskieren und demaskieren
Sonderzeichen maskieren oder Entities zurück in Text wandeln.
Alles läuft in diesem Tab. Nichts, was Sie einfügen, wird hochgeladen, protokolliert oder irgendwohin gesendet. Öffnen Sie Ihr Netzwerkpanel und prüfen Sie es.
Fügen Sie links Text ein, und er kommt zurück, wobei jedes Zeichen, das XML als Markup behandelt, durch eine Entity-Referenz ersetzt ist. Drehen Sie die Richtung um, und Referenzen werden zurück in die Zeichen dekodiert, die sie benennen. Die Eingabe ist reiner Text und kein Dokument, sie muss sich also nicht parsen lassen: ein Fragment, ein einzelner Attributwert oder eine URL für sich allein funktionieren ebenso.
Man greift dazu, nachdem ein Parser sich über ein Und-Zeichen beschwert hat. „The entity name must immediately follow the '&'“ von Xerces, „EntityRef: expecting ';'“ von libxml2 und „invalid character in entity name“ von Expat sind ein und dasselbe Problem: ein rohes & im Text oder in einem Attribut, meist aus einem Querystring wie ?a=1&b=2, der in ein Element eingefügt wurde.
Anders ist hier, was das Werkzeug ablehnt. XML definiert fünf benannte Entities und sonst keine. Die meisten Escaper im Web sind HTML-Escaper mit XML-Etikett und liefern Ihnen , das kein XML-Parser akzeptiert. Dieser kennt die fünf sowie numerische Referenzen in Dezimal und Hex, lässt alles andere exakt so stehen, wie es geschrieben ist, und lädt nichts hoch.
Fünf vordefinierte Entities, und nicht mehr
XML 1.0 Abschnitt 4.6 definiert genau fünf benannte Entities. Das ist die ganze Menge. Es gibt keine geerbte HTML-Entity-Tabelle – die übliche Überraschung, wenn ein HTML-Block in eine XML-Konfigurationsdatei oder eine RSS-description wandert.
in einem Dokument ohne DTD zu schreiben ist kein Stilproblem, sondern ein Wohlgeformtheitsfehler: Der Parser hat einen Namen, und nichts sagt ihm, was der Name bedeutet. Nehmen Sie stattdessen   oder  , und ebenso für © und é. Eine DTD kann weitere Namen deklarieren, so funktioniert &companyName; in DocBook – deshalb lässt die Demaskier-Richtung jeden Namen, den sie nicht kennt, exakt so stehen.
- & für &
- < für <
- > für >
- " für "
- ' für '. HTML 4 hat ' nie definiert, weshalb Serialisierer, die gemischte Pipelines bedienen, oft ' ausgeben.
Numerische Zeichenreferenzen
Jedes zulässige Zeichen lässt sich stattdessen über seinen Codepunkt schreiben: é dezimal, é hexadezimal. Beides ist dasselbe Zeichen, führende Nullen sind erlaubt, und das x muss klein sein – A ist also keine Zeichenreferenz und wird als nicht deklarierte Entity abgelehnt.
Die Demaskier-Richtung beherrscht beide Formen und prüft, ob das Ergebnis im Unicode-Bereich liegt; eine fehlerhafte oder außerhalb liegende Referenz bleibt stehen, statt durch ein Fragezeichen ersetzt zu werden. Die Maskier-Richtung gibt nie numerische Referenzen aus: UTF-8 trägt Akzente, CJK und Emoji als sie selbst, sie zu maskieren kostet Lesbarkeit und bringt nichts.
Wann jedes Zeichen wirklich maskiert werden muss
Die Regeln sind enger, als die meisten annehmen, und das zählt, wenn Sie das Dokument eines anderen lesen und entscheiden, ob es kaputt ist. & und < sind überall Pflicht. Das > wird an genau einer Stelle verlangt: XML 1.0 Abschnitt 2.4 sagt, es muss innerhalb der wörtlichen Folge ]]> im Inhalt maskiert werden, wenn diese keinen CDATA-Abschnitt schließt. <code>if (a]]>b)</code> ist also Pflicht und <note>a > b</note> ist zulässig.
Dieses Werkzeug maskiert alle fünf bedingungslos, eine Obermenge dessen, was irgendein Kontext braucht – die Ausgabe lässt sich also überall einfügen, ohne dass Sie mitverfolgen müssten, in welchem Kontext Sie gerade sind. Es ist nicht die minimale Maskierung, und Sie wissen nun, welche Zeichen Sie zurücknehmen können.
- & und <: Pflicht im Elementinhalt und in Attributwerten, immer.
- >: optional, außer innerhalb der Folge ]]>.
- ": Pflicht nur innerhalb eines mit doppelten Anführungszeichen umschlossenen Attributwerts.
- ': Pflicht nur innerhalb eines mit einfachen Anführungszeichen umschlossenen Attributwerts. Im Elementinhalt muss keines von beiden maskiert werden.
Die Zeichen, die sich überhaupt nicht maskieren lassen
XML 1.0 legt über die Char-Produktion fest, welche Zeichen ein Dokument enthalten darf, und der größte Teil des C0-Steuerbereichs gehört nicht dazu: U+0001 bis U+0008, U+000B, U+000C und U+000E bis U+001F. Nur Tabulator, Zeilenvorschub und Wagenrücklauf überleben. U+0000 ist in jeder XML-Version unzulässig, und einzelne Surrogate ebenfalls.
Also ist  keine Maskierung für das ESC-Zeichen. Es verletzt die Bedingung Legal Character, weil das benannte Zeichen auf keinem Weg in einem XML-1.0-Dokument stehen darf, und es als Referenz zu schreiben wäscht es nicht rein. Wenn Python bei einer Logdatei voller ANSI-Escape-Sequenzen „not well-formed (invalid token)“ meldet, ist es genau das; für beliebige Bytes lautet die Antwort base64.
Das Werkzeug ist hier ehrlich statt schlau: Beim Maskieren geht ein Steuerzeichen unverändert durch, und beim Demaskieren wird  zu einem echten U+0001, weil die Referenz genau das sagt. Schicken Sie das Ergebnis durch die Syntaxprüfung, bevor Sie es in ein Dokument zurückgeben.
Dasselbe in Code
Jede Sprache bringt etwas dafür mit, und die meisten bringen das Falsche mit, weil die naheliegende Funktion ein HTML-Escaper ist. Diese Beispiele nehmen den XML-spezifischen Weg im jeweiligen Ökosystem und benennen, was jedes davon falsch macht.
// XML defines exactly five named entities. A single regex pass avoids the
// classic bug of replacing & after < and turning < into &lt;.
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
// Element content: & and < are mandatory, > only inside the sequence ]]>.
function escapeText(s) {
return s.replace(/[&<]/g, (c) => ESCAPES[c]).replace(/]]>/g, ']]>');
}
// Attribute value: escape the delimiter you are using, plus tab, newline and
// carriage return. Attribute-value normalisation turns a literal one of those
// into a plain space, so a reference is the only way to keep it.
function escapeAttribute(s) {
return s
.replace(/[&<"]/g, (c) => ESCAPES[c])
.replace(/\t/g, '	')
.replace(/\n/g, ' ')
.replace(/\r/g, ' ');
}
// The reverse. Note the deliberate absence of an HTML entity table: is
// undefined in XML, so leaving it alone is more honest than decoding it.
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
function unescapeXml(s) {
return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);/g, (whole, body) => {
if (body[0] === '#') {
const hex = body[1] === 'x';
const cp = parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : whole;
}
return NAMED[body] !== undefined ? NAMED[body] : whole;
});
}from xml.sax.saxutils import escape, quoteattr, unescape
import re
# escape() handles & < > only and knows nothing about quotes. The > is not
# strictly required but it is harmless and covers the ]]> case for free.
text = escape('Terms & conditions <see clause 4>')
# Terms & conditions <see clause 4>
# The two quote entities have to be supplied yourself.
FIVE = {'"': '"', "'": '''}
text = escape(source, FIVE)
# quoteattr() returns the value WITH its delimiters, picking single quotes when
# the value contains a double quote, and turning tab, newline and carriage
# return into numeric references so normalisation cannot flatten them.
attr = quoteattr('say "hello" then stop')
# 'say "hello" then stop' <- single-quoted, so the " needs no escape
# unescape() reverses & < > plus whatever mapping you pass. It does NOT decode
# numeric character references, so é comes back unchanged. html.unescape()
# does decode them, but it also decodes and 250 other HTML names XML
# never defined, which silently rewrites your data. Do it explicitly instead:
def unescape_xml(s: str) -> str:
s = unescape(s, {'"': '"', ''': "'"})
return re.sub(
r'&#(x[0-9a-fA-F]+|[0-9]+);',
lambda m: chr(int(m.group(1)[1:], 16) if m.group(1)[0] == 'x' else int(m.group(1))),
s,
)// The JDK has no public XML escaper for a bare string. Two right answers.
// 1. Let a writer do it. XMLStreamWriter escapes for the context it is
// writing into, the only approach that gets attribute values right.
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamWriter;
XMLStreamWriter w = XMLOutputFactory.newInstance().createXMLStreamWriter(out);
w.writeStartElement("note");
w.writeAttribute("href", "https://example.com/?a=1&b=2"); // escaped for you
w.writeCharacters("Terms & conditions <apply>"); // escaped for you
w.writeEndElement();
w.close();
// 2. Escape a standalone string with commons-text. Do NOT use commons-lang3's
// deprecated StringEscapeUtils.escapeXml, which knew nothing about the
// characters XML cannot represent.
import org.apache.commons.text.StringEscapeUtils;
String safe = StringEscapeUtils.escapeXml10("Terms & conditions <apply>");
// Terms & conditions <apply>
// escapeXml10 does something its name does not advertise: it DELETES the
// characters XML 1.0 cannot hold (U+0001 to U+0008, U+000B, U+000C,
// U+000E to U+001F, unpaired surrogates) rather than escaping them, because
// no escape for them exists. escapeXml11 writes the control characters as
// numeric references instead, which is only legal if the document declares
// version="1.1", and it still drops U+0000 and unpaired surrogates.
String back = StringEscapeUtils.unescapeXml(safe);
// The five predefined entities plus decimal and hex character references.
// Names it does not know are left as written.using System.Linq;
using System.Security;
using System.Xml;
// SecurityElement.Escape does the five predefined entities in one call:
// < > & " ' every time, a superset of what any single context needs.
string safe = SecurityElement.Escape("Terms & conditions <apply>");
// Terms & conditions <apply>
// It does not remove the characters XML cannot carry, so check those yourself.
// XmlConvert.IsXmlChar implements the Char production from XML 1.0. Keep
// surrogates or astral characters (emoji, rarer CJK) are destroyed.
static string StripIllegal(string s) =>
string.Concat(s.Where(c => XmlConvert.IsXmlChar(c) || char.IsSurrogate(c)));
// In practice prefer a writer. It escapes for the context and throws on a
// character the document cannot hold, instead of producing a file that fails
// to parse somewhere downstream.
var settings = new XmlWriterSettings { Indent = true, CheckCharacters = true };
using var writer = XmlWriter.Create(Console.Out, settings);
writer.WriteStartElement("note");
writer.WriteAttributeString("href", "https://example.com/?a=1&b=2");
writer.WriteString("Terms & conditions <apply>");
writer.WriteEndElement();
// There is no framework unescaper, and WebUtility.HtmlDecode is the wrong
// tool: it decodes and the rest of the HTML set. Read a fragment
// instead, which decodes exactly what XML defines and nothing else.
static string Unescape(string escaped)
{
var rs = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
};
using var r = XmlReader.Create(new StringReader("<r>" + escaped + "</r>"), rs);
r.ReadToFollowing("r");
return r.ReadElementContentAsString();
}<?php
// ENT_XML1 is the flag that matters and the one everybody omits. Without it
// you get the HTML entity table: an apostrophe becomes ' (harmless), and
// with ENT_HTML5 you can get names XML will reject outright.
$safe = htmlspecialchars(
$text,
ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
// & < > " ' become & < > " '
//
// ENT_SUBSTITUTE replaces invalid UTF-8 with U+FFFD. Before PHP 8.1 the
// default was to return an empty string on invalid input, which is very easy
// to miss in a feed generator fed by a legacy database.
$back = html_entity_decode($safe, ENT_XML1 | ENT_QUOTES, 'UTF-8');
// With ENT_XML1 this decodes the five predefined entities and numeric
// character references and leaves alone. Drop the flag and it decodes
// into U+00A0, silently rewriting your data.
// Building a document rather than a string: DOMDocument escapes on save and
// rejects characters XML cannot represent, which htmlspecialchars passes
// straight through.
$doc = new DOMDocument('1.0', 'UTF-8');
$note = $doc->createElement('note');
$note->appendChild($doc->createTextNode($text));
$doc->appendChild($note);
echo $doc->saveXML();Das Muster über alle fünf hinweg: Der Escaper auf Stringebene ist die bequeme Antwort, der Writer die richtige – denn nur der Writer weiß, ob er Elementinhalt oder einen Attributwert schreibt, und nur der Writer verweigert Zeichen, die sich überhaupt nicht maskieren lassen.
Häufige Fragen
Warum bricht mein XML?
Weil XML es nie definiert hat. Die fünf vordefinierten Entities sind &, <, >, " und ', und das ist die vollständige Liste. Alles andere, was HTML Ihnen gibt, stammt aus einer Entity-Tabelle, die XML bewusst nicht geerbt hat.
Ein Parser, der ohne DTD im Gültigkeitsbereich antrifft, meldet eine nicht deklarierte Entity: Er hat einen Namen, und nichts sagt ihm, was der Name bedeutet. Nehmen Sie stattdessen   oder  . Die Ausnahme ist ein Dokument, dessen DTD den Namen deklariert – deshalb funktioniert dasselbe Markup in XHTML und scheitert in einer schlichten XML-Konfigurationsdatei.
Muss ich das Größer-als-Zeichen maskieren?
Fast nie. XML 1.0 verlangt es in einer Situation: wenn > in der wörtlichen Folge ]]> innerhalb von Elementinhalt steht und dabei keinen CDATA-Abschnitt schließt – dort käme ein Parser, der das Ende eines CDATA-Abschnitts sucht, sonst durcheinander.
Überall sonst ist es optional, auch in Attributwerten, <note>a > b</note> ist also wohlgeformt, wie es dasteht. Dieses Werkzeug maskiert es trotzdem, damit die Ausgabe in jeden Kontext eingefügt werden kann. Für die minimale Form setzen Sie > überall wieder ein, außer innerhalb einer ]]>-Folge.
Sollte ich lieber CDATA verwenden als zu maskieren?
CDATA unterdrückt, dass < und & als Markup erkannt werden. Mehr tut es nicht, und es ist das richtige Mittel, wenn ein Mensch den Inhalt von Hand pflegt: eingebetteter Quelltext, ein XSLT-Ausdruck voller spitzer Klammern, handgepflegtes SQL.
In allen anderen Fällen ist es das falsche Mittel. Es kann die Folge ]]> nicht enthalten, weil diese es beendet, was es zu einem echten Injektionsvektor für nicht vertrauenswürdige Inhalte macht. Es expandiert keine Entities, <![CDATA[&]]> ergibt also sechs wörtliche Zeichen, und es erlaubt keine unzulässigen Zeichen. Die meisten Serialisierer bewahren es auch nicht als eigenen Knoten – behandeln Sie „ist CDATA“ daher nie als bedeutungstragend.
Wird der eingefügte Text irgendwohin geschickt?
Nein. Das Maskieren ist eine Stringersetzung, die als JavaScript in diesem Tab läuft. Es gibt keine Anfrage zu stellen, also auch keinen Server, an den sie ginge.
Das zählt hier mehr, als es aussieht: Dieses Werkzeug wird auf den Teil einer Nutzlast angewandt, der kaputtgegangen ist – in der Praxis also URLs mit Token im Querystring, Connection Strings und Fehlertexte aus einem Log. Öffnen Sie Ihr Netzwerkpanel beim Tippen, und es bleibt leer. Ihre Eingabe liegt im localStorage dieses Browsers, damit ein Neuladen sie nicht verliert, wird nie übertragen, und „Leeren“ entfernt sie sofort.
Warum wurde aus meinem & ein &amp;?
Der Text wurde zweimal maskiert, meist durch eine manuelle Maskierung auf einen String, den ein Writer bereits maskiert hatte, oder durch ein Template, das bei der Ausgabe maskiert und einen bei der Eingabe maskierten Wert bekam.
Die Demaskier-Richtung nimmt eine Schicht zurück: einmal laufen lassen ergibt &, ein zweites Mal &. Taucht &amp; in Produktion immer wieder auf, ist der Fehler fast immer eine selbstgebaute Maskierung vor einem DOM- oder Stream-Writer, der die Arbeit schon gemacht hat. In der falschen Reihenfolge zu maskieren scheitert andersherum: Ersetzt man < vor &, wird aus < ein &lt; – deshalb macht das JavaScript-Beispiel einen einzigen Durchlauf mit einem regulären Ausdruck.