Skip to content

Commit d01ddff

Browse files
committed
WnLmfReader: adopt LexicalKnowledgeBase and safe-skip WN-LMF DOCTYPE declarations
Completes the LexicalKnowledgeBase rename in this file, and switches DOCTYPE handling from outright rejection to the OWASP-documented alternative for formats that require DOCTYPE support: the DOCTYPE event is skipped rather than resolved, while SUPPORT_DTD stays off (so no custom entity, internal or external, is ever declared), IS_SUPPORTING_EXTERNAL_ENTITIES and ACCESS_EXTERNAL_DTD stay off, and the XMLResolver still throws on any resolution attempt. This closes the same attack surface as outright rejection while letting the reader parse Open English WordNet releases unmodified, since real OEWN files ship a DOCTYPE line referencing the schema DTD. Test changes: testRejectsDoctype becomes testSkipsDoctypeDeclaration and now asserts a DOCTYPE-bearing document parses correctly instead of throwing. Added testInternalSubsetEntityIsNeverExpanded, which proves a classic XXE payload (a DOCTYPE-declared internal-subset entity pointing at a local file) still fails loud rather than expanding into the parsed output.
1 parent a2dda04 commit d01ddff

2 files changed

Lines changed: 61 additions & 29 deletions

File tree

opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfReader.java

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,24 @@
2828
import java.util.LinkedHashSet;
2929
import java.util.List;
3030
import java.util.Map;
31+
import javax.xml.XMLConstants;
3132
import javax.xml.stream.Location;
3233
import javax.xml.stream.XMLInputFactory;
3334
import javax.xml.stream.XMLStreamConstants;
3435
import javax.xml.stream.XMLStreamException;
3536
import javax.xml.stream.XMLStreamReader;
3637

38+
import opennlp.tools.wordnet.LexicalKnowledgeBase;
3739
import opennlp.tools.wordnet.Synset;
38-
import opennlp.tools.wordnet.WordNetLexicon;
3940
import opennlp.tools.wordnet.WordNetPos;
4041
import opennlp.tools.wordnet.WordNetRelation;
4142

4243
/**
4344
* Reads a WN-LMF XML document (the Global WordNet Association interchange format, used by Open
44-
* English WordNet and many other language wordnets) into a {@link WordNetLexicon}.
45+
* English WordNet and many other language wordnets) into a {@link LexicalKnowledgeBase}.
4546
*
4647
* <p>The reader is clean-room, built from the published format documentation, and uses only the
47-
* JDK's StAX parser. It reads the subset of the format the {@link WordNetLexicon} contract
48+
* JDK's StAX parser. It reads the subset of the format the {@link LexicalKnowledgeBase} contract
4849
* serves: lexical entries (lemma, part of speech, senses), synsets (definition and typed
4950
* relations), and sense relations, which are lifted to the synset level as documented on
5051
* {@link WordNetRelation}. Elements outside that subset ({@code Pronunciation}, {@code Form},
@@ -53,12 +54,14 @@
5354
* metadata, are also skipped: the contract has no untyped relation slot. Every other unknown
5455
* relation type fails loud.</p>
5556
*
56-
* <p><b>Security.</b> The parser is hardened against XXE: DTD processing and external entity
57-
* resolution are disabled, nothing is ever fetched from the network, and a document containing
58-
* a DOCTYPE declaration is rejected outright. Note that Open English WordNet releases ship with
59-
* a DOCTYPE line referencing the schema DTD; to read such a file with this v1 reader, delete
60-
* that one line first. Rejecting DTDs wholesale rather than trusting parser-specific partial
61-
* DTD support is a deliberate v1 posture.</p>
57+
* <p><b>Security.</b> The parser is hardened against XXE per the OWASP-documented posture for
58+
* formats that carry a DOCTYPE: a DOCTYPE declaration is tokenized and skipped, but nothing it
59+
* names is ever resolved. External entities and the external DTD subset are both disabled
60+
* ({@code IS_SUPPORTING_EXTERNAL_ENTITIES} and {@code ACCESS_EXTERNAL_DTD} are off), and the
61+
* {@link javax.xml.stream.XMLResolver} throws on any resolution attempt regardless, so no
62+
* network or filesystem access can be triggered by a DOCTYPE, internal or external. Open English
63+
* WordNet releases ship with a DOCTYPE line referencing the schema DTD; this reader parses such
64+
* a file unmodified.</p>
6265
*
6366
* <p><b>Errors.</b> Malformed structure fails loud with an {@link IllegalArgumentException}
6467
* naming the resource and, where the parser provides one, the line: missing required
@@ -92,7 +95,7 @@ private WnLmfReader() {
9295
* document is malformed; the message names the file and, where available, the line.
9396
* @throws UncheckedIOException Thrown if reading the file fails.
9497
*/
95-
public static WordNetLexicon read(Path file) {
98+
public static LexicalKnowledgeBase read(Path file) {
9699
if (file == null) {
97100
throw new IllegalArgumentException("File must not be null");
98101
}
@@ -115,7 +118,7 @@ public static WordNetLexicon read(Path file) {
115118
* @throws IllegalArgumentException Thrown if an argument is {@code null} or the document is
116119
* malformed; the message names the resource and, where available, the line.
117120
*/
118-
public static WordNetLexicon read(InputStream in, String resourceName) {
121+
public static LexicalKnowledgeBase read(InputStream in, String resourceName) {
119122
if (in == null) {
120123
throw new IllegalArgumentException("In must not be null");
121124
}
@@ -138,9 +141,13 @@ public static WordNetLexicon read(InputStream in, String resourceName) {
138141

139142
private static XMLInputFactory hardenedFactory() {
140143
final XMLInputFactory factory = XMLInputFactory.newFactory();
141-
// XXE hardening: no DTD processing, no external entities, nothing fetched from anywhere.
144+
// XXE hardening, the OWASP-documented posture for DOCTYPE-bearing formats: the internal
145+
// subset is not processed (so no custom entity gets declared at all), and both the
146+
// external DTD subset and external entities are denied, so a DOCTYPE is tokenized and
147+
// skipped but nothing it names is ever fetched.
142148
factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE);
143149
factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE);
150+
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
144151
factory.setProperty(XMLInputFactory.IS_COALESCING, Boolean.TRUE);
145152
factory.setXMLResolver((publicId, systemId, baseUri, namespace) -> {
146153
throw new XMLStreamException("External entity resolution is disabled, refusing " + systemId);
@@ -178,10 +185,10 @@ private static final class Parser {
178185
void parse(XMLStreamReader reader) throws XMLStreamException {
179186
while (reader.hasNext()) {
180187
final int event = reader.next();
181-
if (event == XMLStreamConstants.DTD) {
182-
throw malformed(reader.getLocation(),
183-
"Document contains a DOCTYPE declaration; the hardened reader rejects DTDs", null);
184-
}
188+
// A DTD event (the DOCTYPE declaration) is intentionally not handled here: with
189+
// external entities and the external subset denied in the factory, it carries nothing
190+
// that can affect parsing, so it is skipped exactly like a comment or an ignored
191+
// element.
185192
if (event == XMLStreamConstants.START_ELEMENT) {
186193
startElement(reader);
187194
} else if (event == XMLStreamConstants.END_ELEMENT) {
@@ -277,7 +284,7 @@ private void endElement(String name) {
277284
}
278285
}
279286

280-
WordNetLexicon build() {
287+
LexicalKnowledgeBase build() {
281288
// Every sense must point to a declared synset, with a consistent part of speech.
282289
for (final Map.Entry<String, String> sense : synsetBySenseId.entrySet()) {
283290
final RawSynset target = rawSynsets.get(sense.getValue());

opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfReaderTest.java

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
import org.junit.jupiter.api.Test;
2828
import org.junit.jupiter.api.io.TempDir;
2929

30+
import opennlp.tools.wordnet.LexicalKnowledgeBase;
3031
import opennlp.tools.wordnet.Synset;
31-
import opennlp.tools.wordnet.WordNetLexicon;
3232
import opennlp.tools.wordnet.WordNetPos;
3333
import opennlp.tools.wordnet.WordNetRelation;
3434

@@ -40,7 +40,7 @@
4040

4141
public class WnLmfReaderTest {
4242

43-
static WordNetLexicon fixture() {
43+
static LexicalKnowledgeBase fixture() {
4444
try (InputStream in = WnLmfReaderTest.class.getResourceAsStream("mini-wn-lmf.xml")) {
4545
assertNotNull(in, "Fixture mini-wn-lmf.xml must be on the test classpath");
4646
return WnLmfReader.read(in, "mini-wn-lmf.xml");
@@ -49,7 +49,7 @@ static WordNetLexicon fixture() {
4949
}
5050
}
5151

52-
private static WordNetLexicon parse(String document) {
52+
private static LexicalKnowledgeBase parse(String document) {
5353
return WnLmfReader.read(
5454
new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8)), "inline.xml");
5555
}
@@ -74,7 +74,7 @@ void testLookupReturnsSynsetWithAllComponents() {
7474

7575
@Test
7676
void testLookupFoldsCaseAndUnderscore() {
77-
final WordNetLexicon lexicon = fixture();
77+
final LexicalKnowledgeBase lexicon = fixture();
7878
assertEquals("mini-n1", lexicon.lookup("Domestic_Dog", WordNetPos.NOUN).get(0).id());
7979
assertEquals("mini-n1", lexicon.lookup("DOG", WordNetPos.NOUN).get(0).id());
8080
}
@@ -88,7 +88,7 @@ void testLookupKeepsSenseOrder() {
8888

8989
@Test
9090
void testLookupIsPosScoped() {
91-
final WordNetLexicon lexicon = fixture();
91+
final LexicalKnowledgeBase lexicon = fixture();
9292
assertEquals(1, lexicon.lookup("run", WordNetPos.VERB).size());
9393
assertTrue(lexicon.lookup("dog", WordNetPos.VERB).isEmpty());
9494
assertFalse(lexicon.contains("walk", WordNetPos.NOUN));
@@ -97,7 +97,7 @@ void testLookupIsPosScoped() {
9797

9898
@Test
9999
void testRelationNavigation() {
100-
final WordNetLexicon lexicon = fixture();
100+
final LexicalKnowledgeBase lexicon = fixture();
101101
assertEquals(List.of("mini-n1"), lexicon.related("mini-n2", WordNetRelation.HYPONYM));
102102
assertEquals(List.of("mini-v1", "mini-v2"),
103103
lexicon.related("mini-v4", WordNetRelation.HYPONYM));
@@ -106,7 +106,7 @@ void testRelationNavigation() {
106106

107107
@Test
108108
void testSenseRelationsAreLiftedToSynsetLevel() {
109-
final WordNetLexicon lexicon = fixture();
109+
final LexicalKnowledgeBase lexicon = fixture();
110110
assertEquals(List.of("mini-a2"), lexicon.related("mini-a1", WordNetRelation.ANTONYM));
111111
assertEquals(List.of("mini-a1"), lexicon.related("mini-a2", WordNetRelation.ANTONYM));
112112
assertEquals(List.of("mini-v1"),
@@ -126,7 +126,7 @@ void testSatelliteNormalizesToAdjective() {
126126

127127
@Test
128128
void testUnknownLemmaOrSynsetIsEmpty() {
129-
final WordNetLexicon lexicon = fixture();
129+
final LexicalKnowledgeBase lexicon = fixture();
130130
assertTrue(lexicon.lookup("zebra", WordNetPos.NOUN).isEmpty());
131131
assertTrue(lexicon.synset("mini-n99").isEmpty());
132132
}
@@ -138,7 +138,7 @@ void testReadPath(@TempDir Path tempDir) throws IOException {
138138
"<LexicalEntry id=\"t-cat-n\"><Lemma writtenForm=\"cat\" partOfSpeech=\"n\"/>"
139139
+ "<Sense id=\"t-cat-n-1\" synset=\"t-1\"/></LexicalEntry>"
140140
+ "<Synset id=\"t-1\" partOfSpeech=\"n\"><Definition>a feline</Definition></Synset>"));
141-
final WordNetLexicon lexicon = WnLmfReader.read(file);
141+
final LexicalKnowledgeBase lexicon = WnLmfReader.read(file);
142142
assertEquals("a feline", lexicon.lookup("cat", WordNetPos.NOUN).get(0).gloss());
143143
}
144144

@@ -157,14 +157,39 @@ void testReadStreamRejectsNulls() {
157157
}
158158

159159
@Test
160-
void testRejectsDoctype() {
160+
void testSkipsDoctypeDeclaration() {
161+
// Real Open English WordNet releases ship exactly this shape: a DOCTYPE naming the schema
162+
// DTD by an unreachable SYSTEM identifier (example.invalid is the RFC 2606 reserved domain
163+
// that must never resolve). The reader must parse past it without attempting to fetch it.
161164
final String document = "<?xml version=\"1.0\"?>\n"
162165
+ "<!DOCTYPE LexicalResource SYSTEM \"http://example.invalid/WN-LMF-1.3.dtd\">\n"
163166
+ "<LexicalResource><Lexicon id=\"t\" label=\"t\" language=\"en\" version=\"1\">"
167+
+ "<LexicalEntry id=\"t-cat-n\"><Lemma writtenForm=\"cat\" partOfSpeech=\"n\"/>"
168+
+ "<Sense id=\"t-cat-n-1\" synset=\"t-1\"/></LexicalEntry>"
169+
+ "<Synset id=\"t-1\" partOfSpeech=\"n\"><Definition>a feline</Definition></Synset>"
170+
+ "</Lexicon></LexicalResource>";
171+
final LexicalKnowledgeBase lexicon = parse(document);
172+
assertEquals("a feline", lexicon.lookup("cat", WordNetPos.NOUN).get(0).gloss());
173+
}
174+
175+
@Test
176+
void testInternalSubsetEntityIsNeverExpanded(@TempDir Path tempDir) throws IOException {
177+
// A DOCTYPE-declared internal-subset entity is the classic XXE payload: if the parser ever
178+
// honored it, the entity reference below would be replaced by the target file's content.
179+
// With SUPPORT_DTD disabled the declaration itself is never registered, so the reference is
180+
// undefined and parsing must fail loud rather than silently expand it.
181+
final Path secret = tempDir.resolve("secret.txt");
182+
Files.writeString(secret, "xxe-marker-should-never-appear");
183+
final String document = "<?xml version=\"1.0\"?>\n"
184+
+ "<!DOCTYPE LexicalResource [<!ENTITY xxe SYSTEM \"" + secret.toUri() + "\">]>\n"
185+
+ "<LexicalResource><Lexicon id=\"t\" label=\"t\" language=\"en\" version=\"1\">"
186+
+ "<LexicalEntry id=\"t-cat-n\"><Lemma writtenForm=\"&xxe;\" partOfSpeech=\"n\"/>"
187+
+ "<Sense id=\"t-cat-n-1\" synset=\"t-1\"/></LexicalEntry>"
188+
+ "<Synset id=\"t-1\" partOfSpeech=\"n\"><Definition>a feline</Definition></Synset>"
164189
+ "</Lexicon></LexicalResource>";
165190
final IllegalArgumentException e =
166191
assertThrows(IllegalArgumentException.class, () -> parse(document));
167-
assertTrue(e.getMessage().contains("inline.xml"));
192+
assertFalse(e.getMessage().contains("xxe-marker-should-never-appear"));
168193
}
169194

170195
@Test
@@ -212,7 +237,7 @@ void testRejectsUnknownRelationType() {
212237

213238
@Test
214239
void testSkipsOtherRelationType() {
215-
final WordNetLexicon lexicon = parse(
240+
final LexicalKnowledgeBase lexicon = parse(
216241
wrap("<LexicalEntry id=\"t-cat-n\"><Lemma writtenForm=\"cat\" partOfSpeech=\"n\"/>"
217242
+ "<Sense id=\"t-cat-n-1\" synset=\"t-1\">"
218243
+ "<SenseRelation relType=\"other\" target=\"t-cat-n-1\"/></Sense></LexicalEntry>"

0 commit comments

Comments
 (0)