Python ElementInclude: Safe XInclude

Published on: August 23, 2026
Reading time: 4 minutes
Close-up view of a computer screen displaying code in a software development environment.

The xml.etree.ElementInclude module adds limited XInclude support to trees created with xml.etree.ElementTree. XInclude lets an XML document declare that another XML file or text resource should be inserted at a specific point.

This can simplify configuration files, documentation, catalogs, and large documents split into reusable sections. It also creates security risks: reading files outside an approved directory, remote requests, cycles, content expansion, and hidden dependencies. Custom loaders, allowlisted paths, and a strict depth limit are essential.

What XInclude does

XInclude uses the namespace http://www.w3.org/2001/XInclude. An xi:include element provides an href and may use parse="xml" or parse="text".

<document xmlns:xi="http://www.w3.org/2001/XInclude">
  <title>Report</title>
  <xi:include href="sections/summary.xml" parse="xml"/>
</document>

During expansion, the include element is replaced by the root of the referenced XML. For text mode, the referenced text is inserted as character data.

A first example

from xml.etree import ElementTree, ElementInclude

tree = ElementTree.parse("document.xml")
root = tree.getroot()

ElementInclude.include(root)

tree.write(
    "result.xml",
    encoding="utf-8",
    xml_declaration=True,
)

The default loader treats href as a filename. That is convenient for trusted local projects, but unsafe when XML or paths can be influenced by users.

ElementTree integration

ElementInclude works with Element or ElementTree objects. Before using XInclude, understand parsing, namespaces, queries, and serialization. The guide to Python ElementTree covers those operations.

Expansion happens in place, modifying the original tree. If the pre-expansion document must be retained, parse it again or create a controlled copy.

XML mode and text mode

With parse="xml", a loader must return an Element. With parse="text", it returns a string. The default text encoding is UTF-8 unless another encoding is specified.

<document xmlns:xi="http://www.w3.org/2001/XInclude">
  <footer>
    <xi:include href="year.txt" parse="text" encoding="utf-8"/>
  </footer>
</document>

Included text does not automatically become XML markup. It remains character data and is escaped during serialization.

Resolve relative references with base_url

The base_url argument helps resolve references relative to the main document.

from pathlib import Path
from xml.etree import ElementTree, ElementInclude

file = Path("configs/main.xml").resolve()
tree = ElementTree.parse(file)

ElementInclude.include(
    tree.getroot(),
    base_url=file.as_uri(),
    max_depth=4,
)

base_url is not a security boundary. It resolves references but does not block ../, absolute paths, or remote URLs when the loader accepts them.

Maximum depth

include() uses max_depth=6 by default. The limit reduces recursive inclusion and content-explosion risk. Passing None disables the limit and is rarely appropriate.

Choose a smaller number when the project structure is known. A configuration format that supports only one or two nested levels should use a matching policy.

Inclusion cycles

A cycle occurs when a.xml includes b.xml and b.xml includes a.xml. Maximum depth eventually stops expansion, but a custom loader should detect repeated paths and report a clear error.

A local allowlist loader

from pathlib import Path
from xml.etree import ElementTree

ROOT = Path("content").resolve()

class LocalLoader:
    def __init__(self):
        self.visited = set()

    def __call__(self, href, parse, encoding=None):
        path = (ROOT / href).resolve()

        if ROOT not in path.parents and path != ROOT:
            raise ValueError("Include is outside the allowed directory")

        if path in self.visited:
            raise ValueError("XInclude cycle detected")
        self.visited.add(path)

        if path.stat().st_size > 2 * 1024 * 1024:
            raise ValueError("Included file is too large")

        if parse == "xml":
            return ElementTree.parse(path).getroot()
        if parse == "text":
            return path.read_text(encoding=encoding or "utf-8")

        raise ValueError(f"Unsupported parse mode: {parse}")

Pass the loader explicitly:

loader = LocalLoader()
ElementInclude.include(
    tree.getroot(),
    loader=loader,
    max_depth=4,
)

The example blocks path traversal, simple cycles, and oversized files. Production code should also limit total included bytes, file count, nesting, and processing time.

Path.resolve() exposes symbolic links and .. components before the directory check. A time-of-check to time-of-use race can still exist. In hostile environments, use controlled directories, restrictive permissions, and operating-system APIs designed for safer relative file access.

Avoid remote includes

A loader can fetch URLs, but that introduces SSRF, redirects, DNS rebinding, huge responses, and availability problems. For third-party XML, prefer banning HTTP and HTTPS includes.

When network access is unavoidable, allowlist hosts and protocols, validate resolved IP addresses, limit redirects, enforce timeouts, and cap response bytes. The guide to Python urllib.request explains bounded downloads.

Temporary files and caching

When remote resources are downloaded before expansion, use safe temporary storage and integrity-aware caching. See Python tempfile and Python hashlib.

Never use an untrusted href directly as a local filename. Generate an internal name and store the mapping separately.

Feature limitations

Python provides limited XInclude support and does not implement full XPointer syntax. Do not assume compatibility with every advanced XInclude document or XML toolchain. Test against real documents used by the integration.

For schemas, full XPath, advanced validation, or complex resolution policies, a specialized XML library may be more appropriate.

Validate after expansion

An included fragment can introduce unexpected elements, namespaces, or values that violate business rules. Validate the final expanded tree, not only individual source files.

items = tree.getroot().findall(".//item")
if len(items) > 10_000:
    raise ValueError("Too many items")

for item in items:
    if not item.get("id"):
        raise ValueError("Item is missing an ID")

Atomic output

When saving the expanded document, write to a temporary file in the target directory and replace the final file only after successful serialization. This prevents partial XML after an error.

Logging and diagnostics

Record the main document, normalized include path, size, depth, and outcome. Do not log sensitive contents. Errors should identify the failing include without exposing unnecessary internal paths.

Test XML and text includes, invalid encoding, missing files, absolute paths, ../, symbolic links, direct and indirect cycles, exceeded depth, oversized files, invalid parse modes, namespaces, and validation of the expanded tree.

Common mistakes

Common failures include using the default loader with external XML, disabling max_depth, allowing arbitrary URLs, missing cycles, checking a path before resolving it, trusting href as a local filename, and failing to validate the expanded tree.

Conclusion

xml.etree.ElementInclude lets applications split XML into reusable files and expand XInclude inside ElementTree structures. The feature is simple, but resource resolution must be treated as a sensitive operation.

Use a custom loader, an allowed root directory, strict depth and size limits, and final-tree validation. Consult the official ElementTree XInclude documentation and the W3C XInclude recommendation.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Developer configuring a Python HTTPS server and TLS certificate
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    HTTPSServer: Build a Local HTTPS Server in Python

    Learn Python HTTPSServer for local HTTPS services, TLS certificates, threaded handling, testing, and practical security limits.

    Ler mais

    Tempo de leitura: 4 minutos
    08/10/2026
    Protected files representing secure TAR extraction with Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    tarfile extraction_filter: Extract TAR Safely

    Learn Python tarfile extraction_filter for safer TAR extraction, path validation, links, permissions, and resource limits.

    Ler mais

    Tempo de leitura: 5 minutos
    08/10/2026
    Python code showing warnings controlled with catch_warnings
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    catch_warnings: Capture Python Warnings in Tests

    Learn Python catch_warnings to capture, test, and control warnings with focused filters and safe temporary scopes.

    Ler mais

    Tempo de leitura: 5 minutos
    07/10/2026
    Python code representing persistent pickle references
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    pickle persistent_id: Serialize External References

    Learn Python pickle persistent_id for stable external references, validation, security, performance, and long-term compatibility.

    Ler mais

    Tempo de leitura: 5 minutos
    07/10/2026
    Binary code representing Python buffers and memory views
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    memoryview.count: Count Values Without Buffer Copies

    Learn Python memoryview.count to count bytes and values in buffers without copies, with formats, limits, and practical safety.

    Ler mais

    Tempo de leitura: 5 minutos
    06/10/2026
    Python code and type annotations
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    annotationlib: Avoid Circular Imports in Annotations

    Learn Python annotationlib to inspect deferred annotations, avoid circular imports, and build safer runtime tooling.

    Ler mais

    Tempo de leitura: 5 minutos
    06/10/2026