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"
1517namespace 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>&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+ // "&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