mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valera V Harseko
yesterday 18fd136fa155befe4e43f294c5629c178d5e6414
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2026 3A Systems, LLC.
 */
package org.opends.dsml.protocol;
 
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;
 
import javax.xml.XMLConstants;
 
import org.forgerock.testng.ForgeRockTestCase;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
 
import jakarta.xml.bind.JAXBElement;
 
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
 
/**
 * Tests the XML parsing the gateway configures itself, as opposed to the
 * parser SAAJ builds internally (which {@link SoapEntityExpansionTestCase}
 * covers).
 * <p>
 * {@code DSMLServlet.createSafeXMLReader()} hardens its reader through
 * {@code safeSetFeature}, which logs and carries on when a feature is not
 * supported by the parser in use - so a parser change, or a feature name
 * change, would disarm it without failing anything. That reader parses
 * untrusted input: when SAAJ refuses a request, the gateway hands the very same
 * body to it a second time to recover the requestID
 * ({@code createXMLParsingErrorResponse}).
 * <p>
 * The tests only ever reference {@code file:} URIs under a temporary
 * directory, so they perform no network or DNS I/O.
 */
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "dsml" })
public class DSMLXmlParsingTestCase extends ForgeRockTestCase
{
  private static final String CANARY = "s3cr3t-canary-value";
  private static final String DISALLOW_DOCTYPE = "http://apache.org/xml/features/disallow-doctype-decl";
  private static final String EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities";
  private static final String EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities";
 
  private Path tempDir;
  private String canaryUri;
  private String externalDtdUri;
 
  @BeforeClass
  public void createPayloadFiles() throws IOException
  {
    tempDir = Files.createTempDirectory("dsml-parser-test");
    Path canary = Files.write(tempDir.resolve("canary.txt"), CANARY.getBytes(StandardCharsets.UTF_8));
    canaryUri = canary.toUri().toASCIIString();
    Path dtd = Files.write(tempDir.resolve("canary.dtd"),
        ("<!ENTITY xxe SYSTEM \"" + canaryUri + "\">").getBytes(StandardCharsets.UTF_8));
    externalDtdUri = dtd.toUri().toASCIIString();
  }
 
  @AfterClass
  public void deletePayloadFiles() throws IOException
  {
    if (tempDir != null)
    {
      try (Stream<Path> paths = Files.walk(tempDir))
      {
        paths.sorted(Comparator.reverseOrder()).forEach(path -> path.toFile().delete());
      }
    }
  }
 
  /**
   * The features the reader is hardened with must actually be in effect, not
   * merely have been requested.
   */
  @Test
  public void testSafeXmlReaderIsHardened() throws Exception
  {
    XMLReader xmlReader = new DSMLServlet().createSafeXMLReader();
 
    assertTrue(xmlReader.getFeature(XMLConstants.FEATURE_SECURE_PROCESSING),
        "secure processing is not in effect");
    assertTrue(xmlReader.getFeature(DISALLOW_DOCTYPE),
        "a DOCTYPE declaration would be accepted");
    assertFalse(xmlReader.getFeature(EXTERNAL_GENERAL_ENTITIES),
        "external general entities would be resolved");
    assertFalse(xmlReader.getFeature(EXTERNAL_PARAMETER_ENTITIES),
        "external parameter entities would be resolved");
  }
 
  @DataProvider
  public Object[][] entityPayloads()
  {
    return new Object[][] {
      { "external general entity",
        "<?xml version=\"1.0\"?>"
        + "<!DOCTYPE batchRequest [<!ENTITY xxe SYSTEM \"" + canaryUri + "\">]>"
        + batchRequest("&xxe;") },
      { "external DTD subset",
        "<?xml version=\"1.0\"?>"
        + "<!DOCTYPE batchRequest SYSTEM \"" + externalDtdUri + "\">"
        + batchRequest("&xxe;") },
    };
  }
 
  /**
   * A DOCTYPE is refused outright; should a future parser accept one, the file
   * contents must still not reach the parsed document.
   */
  @Test(dataProvider = "entityPayloads")
  public void testSafeXmlReaderNeverExpandsAnExternalEntity(String label, String request) throws Exception
  {
    XMLReader xmlReader = new DSMLServlet().createSafeXMLReader();
    TextCollector collector = new TextCollector();
    xmlReader.setContentHandler(collector);
    try
    {
      xmlReader.parse(new InputSource(asStream(request)));
    }
    catch (SAXException e)
    {
      // Refused, which is what disallow-doctype-decl is there for.
      return;
    }
    assertFalse(collector.text.toString().contains(CANARY),
        label + ": the external entity was expanded: " + collector.text);
  }
 
  /** A well-formed request must still parse, otherwise the test above is empty. */
  @Test
  public void testSafeXmlReaderParsesAPlainRequest() throws Exception
  {
    XMLReader xmlReader = new DSMLServlet().createSafeXMLReader();
    TextCollector collector = new TextCollector();
    xmlReader.setContentHandler(collector);
    xmlReader.parse(new InputSource(asStream(batchRequest("a plain attribute value"))));
 
    assertEquals(collector.requestID, "42");
    assertTrue(collector.text.toString().contains("a plain attribute value"),
        "unexpected content: " + collector.text);
  }
 
  /**
   * The recovery path the servlet takes when SAAJ has refused the request: the
   * body is parsed a second time, and that second parse must go through the
   * hardened reader rather than reintroduce the entity expansion.
   */
  @Test(dataProvider = "entityPayloads")
  public void testXmlParsingErrorResponseDoesNotExpandEntities(String label, String request) throws Exception
  {
    ObjectFactory objFactory = new ObjectFactory();
    BatchResponse batchResponse = objFactory.createBatchResponse();
    InputStream is = asStream(request);
 
    JAXBElement<ErrorResponse> response = new DSMLServlet()
        .createXMLParsingErrorResponse(is, objFactory, batchResponse, "parser error");
 
    assertFalse(String.valueOf(response.getValue().getMessage()).contains(CANARY),
        label + ": the error response carries the file contents");
    assertFalse(String.valueOf(batchResponse.getRequestID()).contains(CANARY),
        label + ": the requestID carries the file contents");
  }
 
  /** The same path, on a request the parser accepts: the requestID is recovered. */
  @Test
  public void testXmlParsingErrorResponseRecoversTheRequestID() throws Exception
  {
    ObjectFactory objFactory = new ObjectFactory();
    BatchResponse batchResponse = objFactory.createBatchResponse();
 
    JAXBElement<ErrorResponse> response = new DSMLServlet().createXMLParsingErrorResponse(
        asStream(batchRequest("a plain attribute value")), objFactory, batchResponse, "parser error");
 
    assertEquals(batchResponse.getRequestID(), "42");
    assertEquals(response.getValue().getMessage(), "parser error");
  }
 
  /** {@code createXMLParsingErrorResponse} rewinds the stream, so it must support it. */
  private InputStream asStream(String request)
  {
    return new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8));
  }
 
  private static String batchRequest(String value)
  {
    return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"42\">"
        + "<addRequest dn=\"cn=probe,dc=example,dc=com\">"
        + "<attr name=\"description\"><value>" + value + "</value></attr>"
        + "</addRequest></batchRequest>";
  }
 
  /** Collects the character data of a parsed document, and the requestID. */
  private static final class TextCollector extends DefaultHandler
  {
    private final StringBuilder text = new StringBuilder();
    private String requestID;
 
    @Override
    public void startElement(String uri, String localName, String qName, Attributes attributes)
    {
      if (requestID == null && "batchRequest".equals(localName))
      {
        requestID = attributes.getValue("requestID");
      }
    }
 
    @Override
    public void characters(char[] ch, int start, int length)
    {
      text.append(ch, start, length);
    }
  }
}