acemq_amqp.codecs.xml
Reads and writes XML, with no extra to install::
from acemq_amqp.codecs.xml import XmlCodec
The only one of the five that needs nothing: it is written against
xml.parsers.expat and xml.etree.ElementTree, both standard
library. There is deliberately no [xml] extra, because there would be
nothing in it. defusedxml was considered and rejected for the same reason —
what it does is turn handlers off, and this module turns the same handlers off
directly and in a way nobody can turn back on.
Every document with a DTD is refused, and that is not configurable.
A message body arrives from a queue, which is exactly the sort of place a message from somewhere unexpected turns up, and an XML parser handed one is a well-known way to read files off the machine and open connections on behalf of whoever sent it. Python's position is better than it is often given credit for and worse than it needs to be:
- External entities are already inert.
<!ENTITY x SYSTEM "file:///etc/passwd">is not resolved byxml.etree.ElementTree; the reference fails as an undefined entity. So plain XXE and DTD retrieval are not live hazards. - Internal entity expansion is not inert. The billion-laughs attack needs no
network access and no file access — a few nested internal entities expand to
gigabytes inside the parser and take a consumer down — and
ElementTree.fromstringexpands them happily. This was verified against the interpreter this library is tested on rather than taken from a table.
So rather than disabling the individual hazards, this refuses the whole
construct they all need: the parser's StartDoctypeDeclHandler,
EntityDeclHandler, UnparsedEntityDeclHandler and
ExternalEntityRefHandler each raise. A body carrying <!DOCTYPE is a
~acemq_amqp.ack.FatalError naming the reason, whatever the DTD would
have said. There is no constructor argument to relax it, matching the Java
codec, which says the same thing about the same decision: the configuration
would only ever be wrong.
A message body has no legitimate use for a DTD. It is one document, produced by
a serializer at the other end, and neither Jackson's XmlMapper nor Go's
encoding/xml writes one.
The Python payload is a dict, and XML has no types. Everything decodes to a
string, a nested dict, or a list where a tag repeats — the same thing Jackson
gives when an XML message is read into a Map. A consumer that wants an
int converts it, where it can decide what a missing or unparseable field
means.
1# Copyright 2026 AceMQ. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Reads and writes XML, with no extra to install:: 16 17 from acemq_amqp.codecs.xml import XmlCodec 18 19The only one of the five that needs nothing: it is written against 20:mod:`xml.parsers.expat` and :mod:`xml.etree.ElementTree`, both standard 21library. There is deliberately no ``[xml]`` extra, because there would be 22nothing in it. ``defusedxml`` was considered and rejected for the same reason — 23what it does is turn handlers off, and this module turns the same handlers off 24directly and in a way nobody can turn back on. 25 26**Every document with a DTD is refused, and that is not configurable.** 27 28A message body arrives from a queue, which is exactly the sort of place a 29message from somewhere unexpected turns up, and an XML parser handed one is a 30well-known way to read files off the machine and open connections on behalf of 31whoever sent it. Python's position is better than it is often given credit for 32and worse than it needs to be: 33 34* External entities are already inert. ``<!ENTITY x SYSTEM "file:///etc/passwd">`` 35 is not resolved by :mod:`xml.etree.ElementTree`; the reference fails as an 36 undefined entity. So plain XXE and DTD retrieval are not live hazards. 37* **Internal** entity expansion is not inert. The billion-laughs attack needs no 38 network access and no file access — a few nested internal entities expand to 39 gigabytes inside the parser and take a consumer down — and 40 ``ElementTree.fromstring`` expands them happily. This was verified against the 41 interpreter this library is tested on rather than taken from a table. 42 43So rather than disabling the individual hazards, this refuses the whole 44construct they all need: the parser's ``StartDoctypeDeclHandler``, 45``EntityDeclHandler``, ``UnparsedEntityDeclHandler`` and 46``ExternalEntityRefHandler`` each raise. A body carrying ``<!DOCTYPE`` is a 47:class:`~acemq_amqp.ack.FatalError` naming the reason, whatever the DTD would 48have said. There is no constructor argument to relax it, matching the Java 49codec, which says the same thing about the same decision: the configuration 50would only ever be wrong. 51 52A message body has no legitimate use for a DTD. It is one document, produced by 53a serializer at the other end, and neither Jackson's ``XmlMapper`` nor Go's 54``encoding/xml`` writes one. 55 56**The Python payload is a dict, and XML has no types.** Everything decodes to a 57string, a nested dict, or a list where a tag repeats — the same thing Jackson 58gives when an XML message is read into a ``Map``. A consumer that wants an 59``int`` converts it, where it can decide what a missing or unparseable field 60means. 61""" 62 63from __future__ import annotations 64 65import dataclasses 66import xml.etree.ElementTree as ElementTree 67from typing import Any 68from xml.parsers import expat 69 70from ..ack import FatalError 71from ..codec import register_codec 72 73#: What this codec writes, and what Java, Go and .NET write. 74XML_CONTENT_TYPE = "application/xml" 75 76#: Every content type this codec answers for, besides any ``+xml`` suffix — 77#: ``application/atom+xml`` and the rest. Java and Go accept exactly these. 78XML_ACCEPTS = (XML_CONTENT_TYPE, "text/xml") 79 80#: The root element name used for a payload that does not carry one — a plain 81#: dict has no class name to take it from. 82DEFAULT_ROOT = "message" 83 84_DTD_REFUSED = ( 85 "acemq: this message carries a document type declaration. A message body has no " 86 "use for one, and a parser that reads DTDs will expand entities on behalf of " 87 "whoever sent the message. This codec refuses every DTD and the refusal is not " 88 "configurable." 89) 90 91 92def _refuse_dtd(*_: object, **__: object) -> None: 93 """Every DTD-shaped callback expat has, wired to the same refusal.""" 94 raise FatalError(_DTD_REFUSED) 95 96 97class XmlCodec: 98 """Reads and writes XML. 99 100 :param root: the element name to wrap a payload in when it has no name of 101 its own. A dataclass uses its class name, the way Jackson uses the 102 class's simple name and Go uses the struct's; a plain mapping has 103 nothing to take one from, so this is used 104 """ 105 106 def __init__(self, root: str = DEFAULT_ROOT) -> None: 107 if not root: 108 raise ValueError("acemq: an XML codec needs a root element name") 109 self._root = root 110 111 @property 112 def content_type(self) -> str: 113 return XML_CONTENT_TYPE 114 115 @property 116 def root(self) -> str: 117 """The root element name used for payloads that carry no name.""" 118 return self._root 119 120 def encode(self, payload: Any) -> bytes: 121 name = self._root 122 if dataclasses.is_dataclass(payload) and not isinstance(payload, type): 123 # The class name, which is what Jackson and encoding/xml write, so a 124 # dataclass published here reaches Java looking like the record it 125 # would have come from. 126 name = type(payload).__name__ 127 payload = dataclasses.asdict(payload) 128 129 if not isinstance(payload, dict): 130 raise TypeError( 131 f"acemq: cannot encode a {type(payload).__name__} as XML: an XML document " 132 "has one root element with named children, so the top level has to be a " 133 "mapping or a dataclass. Wrap a list or a scalar in a mapping with a " 134 "named key, or use JsonCodec." 135 ) 136 137 element = ElementTree.Element(name) 138 _fill(element, payload) 139 written: bytes = ElementTree.tostring(element, encoding="utf-8", xml_declaration=False) 140 return written 141 142 def decode(self, body: bytes, content_type: str | None = None) -> Any: 143 return _to_value(_parse(body)) 144 145 def can_decode(self, content_type: str | None) -> bool: 146 """Accepts ``application/xml``, ``text/xml`` and any ``+xml`` type. 147 148 Never a message with no content type. XML is rarely what arrives 149 unannounced, and a codec that guesses wrong here turns a readable 150 message into a rejected one. 151 """ 152 if not content_type: 153 return False 154 lowered = content_type.lower() 155 return lowered.startswith(XML_ACCEPTS) or "+xml" in lowered 156 157 def __repr__(self) -> str: 158 return f"XmlCodec({self._root!r})" 159 160 161def _parse(body: bytes) -> ElementTree.Element: 162 """Parses a body with a parser that will not read a DTD. 163 164 Built here rather than reused, because an expat parser is single-use and 165 because a shared one would be a shared mutable across every consumer thread. 166 """ 167 builder = ElementTree.TreeBuilder() 168 # Namespace processing on, so a namespaced document is read rather than 169 # refused for an undeclared prefix, and so xmlns attributes do not arrive as 170 # data. Tags come through as "uri}local" and are cut back to the local name 171 # below: a consumer wants the field, not the namespace it was declared in, 172 # which is also what Jackson gives when XML is read into a Map. 173 # Any, because the handler attributes expat exposes are typed for the 174 # signatures it calls them with and every one of these is the same refusal. 175 parser: Any = expat.ParserCreate(namespace_separator="}") 176 parser.StartDoctypeDeclHandler = _refuse_dtd 177 parser.EntityDeclHandler = _refuse_dtd 178 parser.UnparsedEntityDeclHandler = _refuse_dtd 179 parser.ExternalEntityRefHandler = _refuse_dtd 180 parser.buffer_text = True 181 parser.StartElementHandler = lambda tag, attributes: builder.start(tag, attributes) 182 parser.EndElementHandler = builder.end 183 parser.CharacterDataHandler = builder.data 184 185 try: 186 parser.Parse(body, True) 187 return builder.close() 188 except FatalError: 189 raise 190 except (expat.ExpatError, ValueError) as failure: 191 # Fatal rather than retryable: bytes that are not XML will not become 192 # XML on a redelivery. 193 raise FatalError(f"acemq: this message is not XML: {failure}") from failure 194 195 196def _fill(element: ElementTree.Element, mapping: dict[Any, Any]) -> None: 197 """Writes a mapping's items as child elements of ``element``.""" 198 for key, value in mapping.items(): 199 _append(element, str(key), value) 200 201 202def _append(parent: ElementTree.Element, name: str, value: Any) -> None: 203 if isinstance(value, list | tuple): 204 # A repeated tag, which is how every XML serializer writes a sequence 205 # and how this codec reads one back. 206 for item in value: 207 _append(parent, name, item) 208 return 209 210 child = ElementTree.SubElement(parent, name) 211 if dataclasses.is_dataclass(value) and not isinstance(value, type): 212 value = dataclasses.asdict(value) 213 if isinstance(value, dict): 214 _fill(child, value) 215 elif value is None: 216 # An empty element, which reads back as the empty string. XML has no 217 # null, and inventing xsi:nil here would produce something Java and Go 218 # do not write. 219 child.text = None 220 elif isinstance(value, bool): 221 # Lowercase, which is what Jackson and encoding/xml write. Python's 222 # str(True) would put "True" on the wire and nothing else would read it. 223 child.text = "true" if value else "false" 224 else: 225 child.text = str(value) 226 227 228def _local(tag: str) -> str: 229 """``uri}local`` back to ``local``.""" 230 _, separator, local = tag.rpartition("}") 231 return local if separator else tag 232 233 234def _to_value(element: ElementTree.Element) -> Any: 235 """One element as a string, or as a dict when it has children or attributes.""" 236 result: dict[str, Any] = {} 237 for key, value in element.attrib.items(): 238 result[_local(key)] = value 239 240 for child in element: 241 name = _local(child.tag) 242 value = _to_value(child) 243 existing = result.get(name) 244 if existing is None and name not in result: 245 result[name] = value 246 elif isinstance(existing, list): 247 existing.append(value) 248 else: 249 # The second one of a repeated tag turns the first into a list, 250 # which is the only point at which a repeat is visible. 251 result[name] = [existing, value] 252 253 if result: 254 return result 255 return element.text or "" 256 257 258register_codec("xml", XmlCodec)
98class XmlCodec: 99 """Reads and writes XML. 100 101 :param root: the element name to wrap a payload in when it has no name of 102 its own. A dataclass uses its class name, the way Jackson uses the 103 class's simple name and Go uses the struct's; a plain mapping has 104 nothing to take one from, so this is used 105 """ 106 107 def __init__(self, root: str = DEFAULT_ROOT) -> None: 108 if not root: 109 raise ValueError("acemq: an XML codec needs a root element name") 110 self._root = root 111 112 @property 113 def content_type(self) -> str: 114 return XML_CONTENT_TYPE 115 116 @property 117 def root(self) -> str: 118 """The root element name used for payloads that carry no name.""" 119 return self._root 120 121 def encode(self, payload: Any) -> bytes: 122 name = self._root 123 if dataclasses.is_dataclass(payload) and not isinstance(payload, type): 124 # The class name, which is what Jackson and encoding/xml write, so a 125 # dataclass published here reaches Java looking like the record it 126 # would have come from. 127 name = type(payload).__name__ 128 payload = dataclasses.asdict(payload) 129 130 if not isinstance(payload, dict): 131 raise TypeError( 132 f"acemq: cannot encode a {type(payload).__name__} as XML: an XML document " 133 "has one root element with named children, so the top level has to be a " 134 "mapping or a dataclass. Wrap a list or a scalar in a mapping with a " 135 "named key, or use JsonCodec." 136 ) 137 138 element = ElementTree.Element(name) 139 _fill(element, payload) 140 written: bytes = ElementTree.tostring(element, encoding="utf-8", xml_declaration=False) 141 return written 142 143 def decode(self, body: bytes, content_type: str | None = None) -> Any: 144 return _to_value(_parse(body)) 145 146 def can_decode(self, content_type: str | None) -> bool: 147 """Accepts ``application/xml``, ``text/xml`` and any ``+xml`` type. 148 149 Never a message with no content type. XML is rarely what arrives 150 unannounced, and a codec that guesses wrong here turns a readable 151 message into a rejected one. 152 """ 153 if not content_type: 154 return False 155 lowered = content_type.lower() 156 return lowered.startswith(XML_ACCEPTS) or "+xml" in lowered 157 158 def __repr__(self) -> str: 159 return f"XmlCodec({self._root!r})"
Reads and writes XML.
Parameters
- root: the element name to wrap a payload in when it has no name of its own. A dataclass uses its class name, the way Jackson uses the class's simple name and Go uses the struct's; a plain mapping has nothing to take one from, so this is used
116 @property 117 def root(self) -> str: 118 """The root element name used for payloads that carry no name.""" 119 return self._root
The root element name used for payloads that carry no name.
121 def encode(self, payload: Any) -> bytes: 122 name = self._root 123 if dataclasses.is_dataclass(payload) and not isinstance(payload, type): 124 # The class name, which is what Jackson and encoding/xml write, so a 125 # dataclass published here reaches Java looking like the record it 126 # would have come from. 127 name = type(payload).__name__ 128 payload = dataclasses.asdict(payload) 129 130 if not isinstance(payload, dict): 131 raise TypeError( 132 f"acemq: cannot encode a {type(payload).__name__} as XML: an XML document " 133 "has one root element with named children, so the top level has to be a " 134 "mapping or a dataclass. Wrap a list or a scalar in a mapping with a " 135 "named key, or use JsonCodec." 136 ) 137 138 element = ElementTree.Element(name) 139 _fill(element, payload) 140 written: bytes = ElementTree.tostring(element, encoding="utf-8", xml_declaration=False) 141 return written
146 def can_decode(self, content_type: str | None) -> bool: 147 """Accepts ``application/xml``, ``text/xml`` and any ``+xml`` type. 148 149 Never a message with no content type. XML is rarely what arrives 150 unannounced, and a codec that guesses wrong here turns a readable 151 message into a rejected one. 152 """ 153 if not content_type: 154 return False 155 lowered = content_type.lower() 156 return lowered.startswith(XML_ACCEPTS) or "+xml" in lowered
Accepts application/xml, text/xml and any +xml type.
Never a message with no content type. XML is rarely what arrives unannounced, and a codec that guesses wrong here turns a readable message into a rejected one.