Skip to content

Commit b9346ee

Browse files
committed
fix(xml,xml-linq): reject an undeclared entity reference and an undeclared namespace prefix (#2082, #2083)
Both deferrals were correct -- "narrowing parser acceptance needs reference evidence this container lacks" -- and the reference now supplies it. .NET throws XmlException for each: "Reference to undeclared entity '{0}'." (XmlTextReaderImpl.cs:3829) and "'{0}' is an undeclared prefix." (:7787). THE ENTITY CASE WAS WORSE THAN ACCEPTANCE. "<r>&nope;</r>" was accepted AND round-tripped as "<r>&amp;nope;</r>": the reference was reinterpreted as literal text and RE-ESCAPED, so a caller who loaded and saved a document silently rewrote it. The entity check must run on the RAW text, and that is not a preference. tinyxml2 decodes the five predefined entities during parsing, so once the tree exists "&amp;nope;" (legal) and "&nope;" (undeclared) are the same five characters in the same text node. "Undeclared" is not "not predefined", and the first cut got that wrong: it rejected anything outside the predefined five, and the repository's own billion-laughs pin caught it immediately -- that document DECLARES two entities and then references one. The check now reads the DOCTYPE internal subset. A declared entity is accepted and, as before, not expanded, which is what keeps this port free of the billion-laughs exposure. For the prefix, XML Names 1.0 section 3 is transcribed rather than approximated: a declaration is in scope for the start-tag it appears on as well as for descendants, it does not escape to siblings, and the reserved xml and xmlns prefixes need no declaration -- xml:lang and xml:space are the everyday cases. Mutations cover all three. It reaches System.Xml.Linq, and that side's pins said it should. XElement::Parse shares the loader, so two XLinqNamespaceTests cases are inverted. Those pins recorded exactly this: "#2083 already owns [it] at the DOM layer; answering it from the Linq side would settle it by accident. Pinned so that a later change to it is a DECISION rather than a side effect." One limit recorded rather than hidden: XmlDocument::Load(filename) hands the path straight to tinyxml2 and never holds the raw bytes, so the entity check does not run there. That is ticket #2361. The prefix check runs at both doors. +6 net tests. Five mutations, all caught. Gate: 17,262 run, 0 failed, 38 executables -- green. Downstream, measured: zero System::Xml sites in either consumer. Record: docs/Migration-XmlUndeclaredEntityAndPrefix.md.
1 parent 5341e9f commit b9346ee

6 files changed

Lines changed: 390 additions & 35 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — an undeclared entity or namespace prefix is rejected (tickets #2082, #2083)
5+
6+
*2026-08-17.* `XmlDocument::LoadXml` accepted two shapes of malformed XML that .NET rejects. It
7+
rejects them now, and so does `XDocument::Parse`, which shares the loader.
8+
9+
Landed under `docs/StandingApprovals.md` SA-5. No public signature, layout, vtable or `noexcept`
10+
change.
11+
12+
---
13+
14+
## 1. What changed
15+
16+
| Input | Was | Is |
17+
|---|---|---|
18+
| `<r>&nope;</r>` | accepted, **round-tripped as `<r>&amp;nope;</r>`** | `XmlException` |
19+
| `<r a='&nope;'/>` | accepted | `XmlException` |
20+
| `<p:r/>` | accepted, prefix never resolved | `XmlException` |
21+
| `<r><p:child/></r>`, `<r p:a='1'/>` | accepted | `XmlException` |
22+
| `<p:r xmlns:p='urn:x'/>` and any declared prefix | accepted | **unchanged** |
23+
| `<r xml:lang='en'/>`, `<r xml:space='preserve'/>` | accepted | **unchanged** |
24+
| the five predefined entities, `&#65;`, `&#x41;` | accepted | **unchanged** |
25+
| a **declared** entity (`<!DOCTYPE r [<!ENTITY g "hi">]><r>&g;</r>`) | accepted, inert | **unchanged** |
26+
| `&` inside a comment, CDATA section or processing instruction | accepted | **unchanged** |
27+
28+
## 2. Why
29+
30+
.NET throws for both:
31+
32+
* `XmlException("Reference to undeclared entity '{0}'.")``XmlTextReaderImpl.cs:3829`;
33+
* `XmlException("'{0}' is an undeclared prefix.")``XmlTextReaderImpl.cs:7787`.
34+
35+
The entity case is the worse of the two, and not because of the acceptance: the reference was
36+
reinterpreted as literal text **and re-escaped**, so a caller who loaded and saved a document
37+
silently rewrote it. `&nope;` became `&amp;nope;`.
38+
39+
## 3. Two implementation notes worth knowing
40+
41+
**The entity check runs on the raw text, and it has to.** tinyxml2 *decodes* the five predefined
42+
entities during parsing, so once the tree exists, `&amp;nope;` (legal — the text `&nope;`) and
43+
`&nope;` (undeclared) are the same five characters in the same text node. A post-parse walk
44+
cannot tell them apart.
45+
46+
**"Undeclared" is not "not predefined".** The first cut of the check rejected anything outside
47+
the predefined five, and the repository's own billion-laughs pin caught it immediately — that
48+
document *declares* two entities and then references one. The check now reads the DOCTYPE
49+
internal subset for `<!ENTITY name …>` declarations. A declared entity is accepted and, as
50+
before, **not expanded**; that parity gap is pre-existing and is what keeps this port free of the
51+
billion-laughs exposure.
52+
53+
## 4. Two limits, recorded rather than hidden
54+
55+
* **`XmlDocument::Load(filename)` does not run the entity check.** It hands the path straight to
56+
tinyxml2 and never holds the raw bytes. The *prefix* check runs at both doors, because it works
57+
on the parsed tree. That asymmetry is ticket **#2361**.
58+
* **A DTD-declared entity is still not expanded.** Unchanged, pre-existing, and out of scope
59+
here.
60+
61+
## 5. `System.Xml.Linq` follows, and its pins said it should
62+
63+
`XElement::Parse` and `XDocument::Parse` share the loader, so an undeclared prefix now throws
64+
there too. The Linq pins that recorded the old behaviour said exactly why they existed: *"#2083
65+
already owns [this] at the DOM layer; answering it from the Linq side would settle it by
66+
accident. Pinned so that a later change to it is a decision rather than a side effect."* The
67+
decision was made at the DOM layer, and this change updates them.
68+
69+
## 6. To migrate
70+
71+
A document with an undeclared entity or prefix is not well-formed XML and .NET never accepted
72+
it. If you were loading one, you were also silently rewriting it on save.
73+
74+
If you need the prefix, declare it: `<p:r xmlns:p="urn:x"/>`.
75+
76+
## 7. Downstream, measured
77+
78+
Neither `cna` nor `mobile-eggbert` references `XmlDocument` or `System::Xml`**zero sites in
79+
both**. Neither repository was modified.

modules/xml-linq/tests/System/Xml/Linq/XLinqNamespaceTests.cpp

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -153,22 +153,28 @@ TEST(XLinqNamespaceTests, Parse_DefaultDeclaration_IsRecognisedAsANamespaceDecla
153153
EXPECT_TRUE(decl->getIsNamespaceDeclarationProperty());
154154
}
155155

156-
// --- Parse: what is deliberately left unchanged ----------------------------------------------
157-
158-
TEST(XLinqNamespaceTests, Parse_UndeclaredPrefix_IsStillAcceptedAndStillUnresolved) {
159-
// Deliberately NOT narrowed by #2197. .NET's reader rejects an undeclared prefix, but
160-
// narrowing what this runtime accepts is the open question #2083 already owns at the DOM
161-
// layer; answering it from the Linq side would settle it by accident. Pinned so that a
162-
// later change to it is a decision rather than a side effect.
163-
auto e = XElement::Parse("<p:root/>");
164-
EXPECT_EQ(e->getNameProperty().getLocalNameProperty(), "p:root");
165-
EXPECT_EQ(e->getNameProperty().getNamespaceNameProperty(), "");
166-
}
167-
168-
TEST(XLinqNamespaceTests, Parse_UndeclaredAttributePrefix_IsStillAcceptedAndStillUnresolved) {
169-
auto e = XElement::Parse("<r p:x=\"1\"/>");
170-
ASSERT_EQ(e->getAttributesProperty().size(), 1u);
171-
EXPECT_EQ(e->getAttributesProperty()[0]->getNameProperty(), XName("p:x"));
156+
// --- Parse: the DOM-layer decision reaches here too -------------------------------------------
157+
158+
TEST(XLinqNamespaceTests, Fix2083_ParseRejectsAnUndeclaredPrefix) {
159+
// These two cases used to pin the OPPOSITE, and said exactly why: ".NET's reader rejects an
160+
// undeclared prefix, but narrowing what this runtime accepts is the open question #2083
161+
// already owns at the DOM layer; answering it from the Linq side would settle it by
162+
// accident. Pinned so that a later change to it is a DECISION rather than a side effect."
163+
//
164+
// #2083 has now been answered at the DOM layer, from the reference
165+
// (XmlTextReaderImpl.cs:7787, Strings.resx Xml_UnknownNs). XElement::Parse goes through
166+
// XDocument::Parse and the shared loader, so the decision reaches here -- which is the
167+
// consistent outcome those pins were protecting, and this is the change that updates them.
168+
EXPECT_THROW((void)XElement::Parse("<p:root/>"), System::Xml::XmlException);
169+
EXPECT_THROW((void)XElement::Parse("<r p:x=\"1\"/>"), System::Xml::XmlException);
170+
}
171+
172+
TEST(XLinqNamespaceTests, Fix2083_ParseStillAcceptsADeclaredPrefix) {
173+
// The invariance row: only the UNDECLARED case moved.
174+
auto e = XElement::Parse("<p:root xmlns:p=\"urn:x\"/>");
175+
ASSERT_NE(e, nullptr);
176+
auto withAttribute = XElement::Parse("<r xmlns:p=\"urn:x\" p:x=\"1\"/>");
177+
ASSERT_EQ(withAttribute->getAttributesProperty().size(), 2u);
172178
}
173179

174180
// --- Serialize: qualified names and their declarations ----------------------------------------

modules/xml/src/System/Xml/XmlDocument.cpp

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
#include "System/Xml/XmlDocument.hpp"
55

66
#include <cctype>
7+
#include <vector>
8+
#include <string>
79
#include <tinyxml2/tinyxml2.h>
810

911
#include "System/ArgumentException.hpp"
@@ -15,6 +17,206 @@
1517
namespace System::Xml {
1618

1719
namespace {
20+
21+
// ===================================================================================
22+
// Ticket #2082 -- an undeclared entity reference.
23+
//
24+
// Measured by #2073's public-input sweep: LoadXml("<r>&nope;</r>") was ACCEPTED and
25+
// round-tripped as "<r>&amp;nope;</r>", so the DOCUMENT'S OWN TEXT CHANGED. That is
26+
// worse than mere acceptance: a caller who loads and saves a document silently rewrites
27+
// it. .NET throws XmlException("Reference to undeclared entity '{0}'.")
28+
// (XmlTextReaderImpl.cs:3829, Strings.resx Xml_UndeclaredEntity).
29+
//
30+
// THE CHECK MUST RUN ON THE RAW TEXT, and that is not a preference. tinyxml2 DECODES
31+
// the five predefined entities during parsing, so by the time the tree exists,
32+
// "&amp;nope;" (legal -- the text `&nope;`) and "&nope;" (undeclared) are the same five
33+
// characters in the same text node. A post-parse walk cannot tell them apart.
34+
// ===================================================================================
35+
36+
/** @brief XML Names 1.0 NameStartChar, as far as an entity name needs it. */
37+
bool IsEntityNameStart(unsigned char c) {
38+
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c == ':' ||
39+
c >= 0x80; // any non-ASCII byte: this scan does not decode UTF-8
40+
}
41+
42+
/** @brief XML Names 1.0 NameChar. */
43+
bool IsEntityNameChar(unsigned char c) {
44+
return IsEntityNameStart(c) || (c >= '0' && c <= '9') || c == '-' || c == '.';
45+
}
46+
47+
/**
48+
* @brief Collects the general-entity names declared in a DOCTYPE internal subset.
49+
*
50+
* Ticket #2082. The check below must reject an **undeclared** entity, not merely a
51+
* non-predefined one, and those are different sets: a document may declare its own with
52+
* `<!ENTITY name "...">`. Getting this wrong was caught immediately -- the first cut
53+
* rejected the repository's own billion-laughs pin, which declares two entities and
54+
* then references one.
55+
*
56+
* Parameter entities (`<!ENTITY % name ...>`) are skipped: they are referenced with `%`
57+
* inside the DTD, never with `&` in content, so they cannot declare a content entity.
58+
*/
59+
std::vector<std::string> DeclaredEntityNames(const std::string& xml) {
60+
std::vector<std::string> names;
61+
const std::size_t doctype = xml.find("<!DOCTYPE");
62+
if (doctype == std::string::npos) return names;
63+
const std::size_t open = xml.find('[', doctype);
64+
if (open == std::string::npos) return names;
65+
const std::size_t close = xml.find(']', open);
66+
if (close == std::string::npos) return names;
67+
68+
const std::string subset = xml.substr(open + 1, close - open - 1);
69+
for (std::size_t i = subset.find("<!ENTITY"); i != std::string::npos;
70+
i = subset.find("<!ENTITY", i + 1)) {
71+
std::size_t j = i + 8;
72+
while (j < subset.size() && std::isspace(static_cast<unsigned char>(subset[j]))) ++j;
73+
if (j < subset.size() && subset[j] == '%') continue; // a parameter entity
74+
const std::size_t nameStart = j;
75+
while (j < subset.size() && IsEntityNameChar(static_cast<unsigned char>(subset[j]))) ++j;
76+
if (j > nameStart) names.push_back(subset.substr(nameStart, j - nameStart));
77+
}
78+
return names;
79+
}
80+
81+
/**
82+
* @brief Rejects any entity reference that is neither predefined, nor a character
83+
* reference, nor declared by the document's own DOCTYPE internal subset.
84+
*
85+
* Regions where `&` is ordinary data are skipped: comments, CDATA sections, processing
86+
* instructions, and the DOCTYPE internal subset itself, whose ENTITY declarations
87+
* legitimately contain references.
88+
*
89+
* A bare `&` that begins no complete reference is left alone. That is a different
90+
* well-formedness rule and a different finding; widening this check to cover it would
91+
* reject input on a premise this ticket did not establish.
92+
*
93+
* @note A **declared** entity is accepted and, as before, is **not expanded** -- it
94+
* stays inert literal text. That parity gap is pre-existing, is deliberately not
95+
* touched here, and is what keeps this port free of the billion-laughs exposure
96+
* (`XmlContractPinTests.InternalEntitiesAreNeverExpanded_NoBillionLaughsExposure`).
97+
* This ticket is about the **undeclared** case, where .NET throws and this port
98+
* silently rewrote the document's own text.
99+
*/
100+
void ThrowIfUndeclaredEntityReference(const std::string& xml) {
101+
static const char* const predefined[] = {"amp", "lt", "gt", "quot", "apos"};
102+
const std::vector<std::string> declaredNames = DeclaredEntityNames(xml);
103+
104+
for (std::size_t i = 0; i < xml.size();) {
105+
if (xml.compare(i, 4, "<!--") == 0) {
106+
const std::size_t end = xml.find("-->", i + 4);
107+
i = (end == std::string::npos) ? xml.size() : end + 3;
108+
continue;
109+
}
110+
if (xml.compare(i, 9, "<![CDATA[") == 0) {
111+
const std::size_t end = xml.find("]]>", i + 9);
112+
i = (end == std::string::npos) ? xml.size() : end + 3;
113+
continue;
114+
}
115+
if (xml.compare(i, 2, "<?") == 0) {
116+
const std::size_t end = xml.find("?>", i + 2);
117+
i = (end == std::string::npos) ? xml.size() : end + 2;
118+
continue;
119+
}
120+
if (xml.compare(i, 9, "<!DOCTYPE") == 0) {
121+
const std::size_t open = xml.find('[', i);
122+
const std::size_t close = xml.find('>', i);
123+
if (open != std::string::npos && (close == std::string::npos || open < close)) {
124+
const std::size_t end = xml.find(']', open);
125+
i = (end == std::string::npos) ? xml.size() : end + 1;
126+
} else {
127+
i = (close == std::string::npos) ? xml.size() : close + 1;
128+
}
129+
continue;
130+
}
131+
132+
if (xml[i] != '&') { ++i; continue; }
133+
134+
std::size_t j = i + 1;
135+
if (j < xml.size() && xml[j] == '#') { ++i; continue; } // character reference
136+
if (j >= xml.size() || !IsEntityNameStart(static_cast<unsigned char>(xml[j]))) {
137+
++i; // a bare '&' -- see the doc-comment
138+
continue;
139+
}
140+
while (j < xml.size() && IsEntityNameChar(static_cast<unsigned char>(xml[j]))) ++j;
141+
if (j >= xml.size() || xml[j] != ';') { ++i; continue; } // incomplete
142+
143+
const std::string name = xml.substr(i + 1, j - i - 1);
144+
bool known = false;
145+
for (const char* candidate : predefined) {
146+
if (name == candidate) { known = true; break; }
147+
}
148+
for (const std::string& candidate : declaredNames) {
149+
if (known) break;
150+
if (name == candidate) known = true;
151+
}
152+
if (!known) throw XmlException("Reference to undeclared entity '" + name + "'.");
153+
i = j + 1;
154+
}
155+
}
156+
157+
// ===================================================================================
158+
// Ticket #2083 -- an undeclared namespace prefix.
159+
//
160+
// Measured by #2073's public-input sweep: LoadXml("<p:r/>") was ACCEPTED with no
161+
// namespace resolution and round-tripped unchanged, so a document naming a namespace it
162+
// never declares looked well-formed. .NET throws
163+
// XmlException("'{0}' is an undeclared prefix.") (XmlTextReaderImpl.cs:7787,
164+
// Strings.resx Xml_UnknownNs).
165+
//
166+
// Unlike #2082 this CAN be checked on the parsed tree, because a prefix survives parsing
167+
// intact.
168+
// ===================================================================================
169+
170+
/** @brief The prefix of a qualified name, or "" when it has none. */
171+
std::string PrefixOf(const char* qualifiedName) {
172+
if (!qualifiedName) return "";
173+
const std::string name(qualifiedName);
174+
const std::size_t colon = name.find(':');
175+
return colon == std::string::npos ? std::string() : name.substr(0, colon);
176+
}
177+
178+
void ThrowIfUndeclaredPrefix(const tinyxml2::XMLElement* element,
179+
std::vector<std::string> inScope) {
180+
if (!element) return;
181+
182+
// Declarations on THIS element are in scope for it, including for its own name --
183+
// XML Names 1.0 3: the scope of a declaration includes the start-tag it appears on.
184+
for (const tinyxml2::XMLAttribute* a = element->FirstAttribute(); a; a = a->Next()) {
185+
const std::string attributeName(a->Name() ? a->Name() : "");
186+
if (attributeName.rfind("xmlns:", 0) == 0 && attributeName.size() > 6) {
187+
inScope.push_back(attributeName.substr(6));
188+
}
189+
}
190+
191+
const auto declared = [&inScope](const std::string& prefix) {
192+
// "xml" is bound by the specification itself and is never declared; "xmlns" is
193+
// reserved and never appears as a name's prefix outside a declaration.
194+
if (prefix.empty() || prefix == "xml" || prefix == "xmlns") return true;
195+
for (const std::string& candidate : inScope) {
196+
if (candidate == prefix) return true;
197+
}
198+
return false;
199+
};
200+
201+
const std::string elementPrefix = PrefixOf(element->Name());
202+
if (!declared(elementPrefix)) {
203+
throw XmlException("'" + elementPrefix + "' is an undeclared prefix.");
204+
}
205+
for (const tinyxml2::XMLAttribute* a = element->FirstAttribute(); a; a = a->Next()) {
206+
const std::string attributeName(a->Name() ? a->Name() : "");
207+
if (attributeName.rfind("xmlns:", 0) == 0 || attributeName == "xmlns") continue;
208+
const std::string attributePrefix = PrefixOf(a->Name());
209+
if (!declared(attributePrefix)) {
210+
throw XmlException("'" + attributePrefix + "' is an undeclared prefix.");
211+
}
212+
}
213+
214+
for (const tinyxml2::XMLElement* child = element->FirstChildElement(); child;
215+
child = child->NextSiblingElement()) {
216+
ThrowIfUndeclaredPrefix(child, inScope);
217+
}
218+
}
219+
18220
// VersionNum ::= '1.' [0-9]+ (XmlDeclaration.cs IsValidXmlVersion)
19221
bool IsValidXmlVersion(const std::string& ver) {
20222
if (ver.size() < 3 || ver[0] != '1' || ver[1] != '.') return false;
@@ -349,6 +551,10 @@ namespace System::Xml {
349551
if (doc_.LoadFile(filename.c_str()) != tinyxml2::XML_SUCCESS)
350552
throw XmlException("XmlDocument::Load: failed to load '" + filename + "': " +
351553
(doc_.ErrorStr() ? doc_.ErrorStr() : "unknown error"));
554+
// #2083. The entity check (#2082) cannot run here: it needs the RAW text, and this door
555+
// hands the file straight to tinyxml2 without ever holding it. That asymmetry between
556+
// the two doors is recorded rather than hidden -- see ticket #2361.
557+
ThrowIfUndeclaredPrefix(doc_.RootElement(), {});
352558
// tinyxml2::XMLDocument::LoadFile() clears and frees every previously-allocated node
353559
// (including detachedHolder_ from the constructor, or a prior Load/LoadXml call) before
354560
// parsing; recreate it now, or IsDetached()/getParentNodeProperty() etc. would compare
@@ -357,10 +563,14 @@ namespace System::Xml {
357563
}
358564

359565
void XmlDocument::LoadXml(const std::string& xml) {
566+
// #2082: BEFORE the parse, because tinyxml2 decodes the predefined entities and the
567+
// distinction is gone afterwards.
568+
ThrowIfUndeclaredEntityReference(xml);
360569
nodeCache_.clear();
361570
if (doc_.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS)
362571
throw XmlException(std::string("XmlDocument::LoadXml: parse error: ") +
363572
(doc_.ErrorStr() ? doc_.ErrorStr() : "unknown error"));
573+
ThrowIfUndeclaredPrefix(doc_.RootElement(), {}); // #2083
364574
// See the comment in Load() above: Parse() also clears and frees prior nodes.
365575
detachedHolder_ = doc_.NewElement("#detached-holder");
366576
}

0 commit comments

Comments
 (0)