Skip to content

Commit db7379f

Browse files
committed
Strip illegal XML chars from rendered output
Add XML 1.0 illegal-character sanitization to template rendering and core property rendering so control/noncharacter code points injected via context values no longer break DOCX XML parsing. The change uses a regex-based fast path and preserves valid XML whitespace controls. Also adds a regression test template/script covering body text, escaped content, header/footer, and core properties.
1 parent 47ca344 commit db7379f

3 files changed

Lines changed: 57 additions & 0 deletions

File tree

docxtpl/template.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,33 @@ def _get_cached_env(autoescape=False):
6969
from .subdoc import Subdoc
7070

7171

72+
# Characters that are illegal in XML 1.0 (Char production, XML spec §2.2).
73+
# These must be stripped from rendered output because a single illegal character
74+
# injected through a context value (e.g. a C0 control like "\x01") makes the whole
75+
# part XML fail strict parsing. The legal XML whitespace controls \t (0x09),
76+
# \n (0x0A) and \r (0x0D) are intentionally preserved.
77+
# This mirrors the well-known openpyxl ILLEGAL_CHARACTERS_RE approach.
78+
_ILLEGAL_XML_CHARS_RE = re.compile(
79+
"["
80+
"\x00-\x08\x0b\x0c\x0e-\x1f" # C0 controls except \t \n \r
81+
"\x7f-\x84\x86-\x9f" # DEL + C1 controls (illegal in XML 1.0)
82+
"\ufdd0-\ufdef" # noncharacters
83+
"\ufffe\uffff" # BMP noncharacters
84+
"]"
85+
)
86+
87+
88+
def _strip_illegal_xml_chars(xml):
89+
"""Remove XML-1.0-illegal characters from a rendered XML string.
90+
91+
Uses a search-guarded fast path so clean input (the common case) is returned
92+
unchanged without allocating a new string.
93+
"""
94+
if _ILLEGAL_XML_CHARS_RE.search(xml):
95+
return _ILLEGAL_XML_CHARS_RE.sub("", xml)
96+
return xml
97+
98+
7299
class DocxTemplate(object):
73100
"""Class for managing docx files as they were jinja2 templates"""
74101

@@ -474,6 +501,9 @@ def render_xml_part(self, src_xml, part, context, jinja_env=None):
474501
)
475502

476503
raise exc
504+
# Strip XML-1.0-illegal characters injected through context values before
505+
# the rendered string is parsed, so a single bad char can't corrupt output.
506+
dst_xml = _strip_illegal_xml_chars(dst_xml)
477507
dst_xml = self._RE_PARAGRAPH_REMOVE_NEWLINE.sub(r"<w:p\1", dst_xml)
478508
dst_xml = (
479509
dst_xml.replace("{_{", "{{")
@@ -509,6 +539,7 @@ def render_properties(
509539
initial = getattr(self.docx.core_properties, prop)
510540
template = jinja_env.from_string(initial)
511541
rendered = template.render(context)
542+
rendered = _strip_illegal_xml_chars(rendered)
512543
setattr(self.docx.core_properties, prop, rendered)
513544

514545
def render_footnotes(

tests/illegal_xml_chars.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Created : 2026-07-07
4+
5+
@author: Jack Byrne
6+
7+
Checks that XML-1.0-illegal characters injected through context values (such as
8+
the C0 control "\\x01") are stripped from rendered output, so docxtpl never emits
9+
a part XML that fails strict parsing. Illegal chars are placed in the body, an
10+
escaped run, the header, the footer and a core property.
11+
"""
12+
13+
from docxtpl import DocxTemplate
14+
15+
tpl = DocxTemplate("templates/illegal_xml_chars.docx")
16+
17+
context = {
18+
"myvar": "a\x01b",
19+
"escaped": "<c>\x01",
20+
"hdr": "header\x01",
21+
"ftr": "footer\x01",
22+
"prop": "property\x01",
23+
}
24+
25+
tpl.render(context, autoescape=True)
26+
tpl.save("output/illegal_xml_chars.docx")
16.8 KB
Binary file not shown.

0 commit comments

Comments
 (0)