Skip to content

Commit db5e503

Browse files
committed
Default missing weight to 1 and harden XML parser against XXE
The DTD declares weight="1" as the default for VALUE and RULE, but the loader used a non-validating parser, so a missing attribute came through as "" and crashed the entire load with NumberFormatException. Read it through a parseWeight helper that treats blank as 1, matching the DTD's stated default. Also disable external general/parameter entities, XInclude, and entity-reference expansion on the DocumentBuilderFactory. External DTD loading is left on so generator.dtd still resolves through the EntityResolver. The data files are local and trusted, but XXE hardening is cheap and stops a malformed file from silently inlining filesystem contents. Tests cover both: a VALUE without a weight attribute now loads without throwing, and an XXE payload referencing a local file does not leak the file's contents into the parsed data.
1 parent d6cf2e2 commit db5e503

2 files changed

Lines changed: 79 additions & 2 deletions

File tree

code/src/java/pcgen/core/namegen/NameGenDataLoader.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ private static DocumentBuilder newDocumentBuilder() throws IOException
103103
try
104104
{
105105
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
106+
// XXE hardening: data files are local but the parser shouldn't
107+
// fetch external entities or evaluate parameter entities. We
108+
// keep external-DTD loading on so generator.dtd still resolves
109+
// through the EntityResolver.
110+
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
111+
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
112+
factory.setXIncludeAware(false);
113+
factory.setExpandEntityReferences(false);
106114
return factory.newDocumentBuilder();
107115
}
108116
catch (ParserConfigurationException e)
@@ -129,7 +137,7 @@ private static String loadList(Element list, VariableHashMap allVars)
129137
for (Element child : childElements(list, "VALUE"))
130138
{
131139
WeightedDataValue dv = new WeightedDataValue(directText(child),
132-
Integer.parseInt(child.getAttribute("weight")));
140+
parseWeight(child));
133141
childElements(child, "SUBVALUE").forEach(sub ->
134142
dv.addSubValue(sub.getAttribute("type"), directText(sub)));
135143
dataList.add(dv);
@@ -140,7 +148,7 @@ private static String loadList(Element list, VariableHashMap allVars)
140148

141149
private static String loadRule(Element rule, String id, VariableHashMap allVars)
142150
{
143-
Rule dataRule = new Rule(allVars, id, id, Integer.parseInt(rule.getAttribute("weight")));
151+
Rule dataRule = new Rule(allVars, id, id, parseWeight(rule));
144152
for (Element child : childElements(rule))
145153
{
146154
switch (child.getTagName())
@@ -210,6 +218,22 @@ private static List<Element> childElements(Element parent)
210218
.toList();
211219
}
212220

221+
/**
222+
* Reads the {@code weight} attribute and defaults to 1 when absent or
223+
* blank, matching the DTD's {@code weight CDATA "1"} default. The
224+
* project uses a non-validating parser, so DTD-defaulted attributes
225+
* arrive here as {@code ""} rather than {@code "1"}.
226+
*/
227+
private static int parseWeight(Element element)
228+
{
229+
String raw = element.getAttribute("weight");
230+
if (raw.isBlank())
231+
{
232+
return 1;
233+
}
234+
return Integer.parseInt(raw.trim());
235+
}
236+
213237
private static List<Element> childElements(Element parent, String tagName)
214238
{
215239
NodeList nodes = parent.getChildNodes();

code/src/utest/pcgen/core/namegen/NameGenDataLoaderTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,57 @@ public void emptyDirectoryYieldsEmptyData(@TempDir Path tempDir) throws IOExcept
8383
NameGenData data = NameGenDataLoader.load(tempDir.toFile());
8484
assertTrue(data.categories().isEmpty());
8585
}
86+
87+
@Test
88+
public void valueWithoutWeightDefaultsToOne(@TempDir Path tempDir) throws IOException
89+
{
90+
// The DTD declares weight="1" as a default, but we use a
91+
// non-validating parser, so a missing weight arrives as "" — the
92+
// loader must treat that as 1 instead of throwing.
93+
Files.copy(new File(DATA_DIR, "generator.dtd").toPath(),
94+
tempDir.resolve("generator.dtd"));
95+
Files.writeString(tempDir.resolve("noweight.xml"),
96+
"<?xml version=\"1.0\"?><!DOCTYPE GENERATOR SYSTEM \"generator.dtd\">"
97+
+ "<GENERATOR>"
98+
+ "<LIST title=\"L\" id=\"L\"><VALUE>Donn</VALUE></LIST>"
99+
+ "</GENERATOR>");
100+
NameGenData data = NameGenDataLoader.load(tempDir.toFile());
101+
assertNotNull(data);
102+
}
103+
104+
@Test
105+
public void rejectsExternalEntityExpansion(@TempDir Path tempDir) throws IOException
106+
{
107+
// Classic XXE payload: declare an external entity that points to
108+
// a local secret file, then reference it. With external general
109+
// entities disabled, the parser must NOT inline the file's
110+
// contents into the resulting data — it should either refuse the
111+
// document or leave the reference unresolved.
112+
Files.copy(new File(DATA_DIR, "generator.dtd").toPath(),
113+
tempDir.resolve("generator.dtd"));
114+
Path secret = tempDir.resolve("secret.txt");
115+
String marker = "TOP-SECRET-CANARY-12345";
116+
Files.writeString(secret, marker);
117+
String secretUri = secret.toUri().toString();
118+
Files.writeString(tempDir.resolve("xxe.xml"),
119+
"<?xml version=\"1.0\"?>"
120+
+ "<!DOCTYPE GENERATOR ["
121+
+ "<!ENTITY leak SYSTEM \"" + secretUri + "\">"
122+
+ "]>"
123+
+ "<GENERATOR>"
124+
+ "<LIST title=\"L\" id=\"L\"><VALUE>prefix-&leak;-suffix</VALUE></LIST>"
125+
+ "</GENERATOR>");
126+
try
127+
{
128+
NameGenData data = NameGenDataLoader.load(tempDir.toFile());
129+
// If the loader didn't throw, the parsed data must not contain
130+
// the secret marker — otherwise XXE expanded successfully.
131+
boolean leaked = data.allVars().toString().contains(marker);
132+
assertFalse(leaked, "external entity was expanded into the parsed data");
133+
}
134+
catch (IOException ignored)
135+
{
136+
// Refusing the document is also an acceptable outcome.
137+
}
138+
}
86139
}

0 commit comments

Comments
 (0)