blob: c72e1a40489b4cf36043c9dcaa3d5e38dcda53f7 [file]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package tests;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.StringReader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import org.apache.ws.commons.schema.XmlSchema;
import org.apache.ws.commons.schema.XmlSchemaCollection;
import org.apache.ws.commons.schema.XmlSchemaComplexType;
import org.apache.ws.commons.schema.XmlSchemaDocumentation;
import org.apache.ws.commons.schema.XmlSchemaException;
import org.apache.ws.commons.schema.resolver.URIResolver;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
/**
* The internal parser must not fetch external DTD or external entity content, but it must
* still accept the DOCTYPE declarations that real-world schema documents carry.
*/
public class InternalParserHardeningTest extends Assert {
private static final String PLAIN_SCHEMA =
"<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
+ " targetNamespace=\"urn:hardening\">"
+ "<xs:element name=\"e\" type=\"xs:string\"/>"
+ "</xs:schema>";
/**
* The shape used by the W3C's own normative schemas (XML Signature, XML Encryption,
* XKMS): a DOCTYPE whose internal subset declares the target namespace as an entity,
* alongside an external subset that must not be fetched.
*/
private static final String W3C_STYLE_SCHEMA =
"<!DOCTYPE xs:schema PUBLIC \"-//W3C//DTD XMLSCHEMA 200102//EN\""
+ " \"http://www.w3.org/2001/XMLSchema.dtd\" ["
+ "<!ENTITY ns 'urn:hardening'>"
+ "]>"
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" targetNamespace=\"&ns;\">"
+ "<xs:element name=\"e\" type=\"xs:string\"/>"
+ "<xs:complexType name=\"t\">"
+ "<xs:annotation><xs:documentation>ns is &ns;</xs:documentation></xs:annotation>"
+ "<xs:sequence/>"
+ "</xs:complexType>"
+ "</xs:schema>";
private static final String IMPORTING_SCHEMA =
"<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
+ " targetNamespace=\"urn:importer\">"
+ "<xs:import namespace=\"urn:hardening\" schemaLocation=\"imported.xsd\"/>"
+ "</xs:schema>";
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();
@Test
public void testSchemaWithoutDoctypeStillParses() {
XmlSchemaCollection collection = new XmlSchemaCollection();
XmlSchema schema = collection.read(new StringReader(PLAIN_SCHEMA));
assertNotNull(schema);
assertEquals("urn:hardening", schema.getTargetNamespace());
}
@Test
public void testSchemaWithInternalDtdSubsetIsAccepted() {
XmlSchemaCollection collection = new XmlSchemaCollection();
XmlSchema schema = collection.read(new StringReader(W3C_STYLE_SCHEMA));
assertNotNull(schema);
assertEquals("urn:hardening", schema.getTargetNamespace());
}
/**
* Internal entities must be expanded, not left in the model as entity references.
*/
@Test
public void testInternalEntitiesAreExpanded() {
XmlSchemaCollection collection = new XmlSchemaCollection();
XmlSchema schema = collection.read(new StringReader(W3C_STYLE_SCHEMA));
assertEquals("ns is urn:hardening", markupOf(schema).trim());
}
@Test
public void testExternalGeneralEntityIsNotResolved() throws Exception {
File secret = writeSecretFile();
String schemaText =
"<!DOCTYPE xs:schema [<!ENTITY xxe SYSTEM \"" + secret.toURI() + "\">]>"
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" targetNamespace=\"urn:hardening\">"
+ "<xs:complexType name=\"t\">"
+ "<xs:annotation><xs:documentation>&xxe;</xs:documentation></xs:annotation>"
+ "<xs:sequence/>"
+ "</xs:complexType>"
+ "</xs:schema>";
XmlSchemaCollection collection = new XmlSchemaCollection();
XmlSchema schema = collection.read(new StringReader(schemaText));
assertNotNull(schema);
assertFalse("external entity content leaked into the schema model",
markupOf(schema).contains("TOP-SECRET"));
}
@Test
public void testExternalDtdSubsetIsNotResolved() throws Exception {
File secret = writeSecretFile();
File dtd = tempFolder.newFile("evil.dtd");
write(dtd, "<!ENTITY xxe SYSTEM \"" + secret.toURI() + "\">");
String schemaText =
"<!DOCTYPE xs:schema SYSTEM \"" + dtd.toURI() + "\">"
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" targetNamespace=\"urn:hardening\">"
+ "<xs:complexType name=\"t\">"
+ "<xs:annotation><xs:documentation>text</xs:documentation></xs:annotation>"
+ "<xs:sequence/>"
+ "</xs:complexType>"
+ "</xs:schema>";
XmlSchemaCollection collection = new XmlSchemaCollection();
XmlSchema schema = collection.read(new StringReader(schemaText));
assertNotNull(schema);
assertFalse("external DTD content leaked into the schema model",
markupOf(schema).contains("TOP-SECRET"));
}
/**
* The import re-parse is the path a caller cannot harden themselves: fetched bytes
* re-enter this collection's own parser, and a DTD-level fetch never consults the
* URIResolver. External entity content must not be resolved there either.
*/
@Test
public void testExternalGeneralEntityIsNotResolvedOnImportReparse() throws Exception {
File secret = writeSecretFile();
final String hostileImport =
"<!DOCTYPE xs:schema [<!ENTITY xxe SYSTEM \"" + secret.toURI() + "\">]>"
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" targetNamespace=\"urn:hardening\">"
+ "<xs:complexType name=\"t\">"
+ "<xs:annotation><xs:documentation>&xxe;</xs:documentation></xs:annotation>"
+ "<xs:sequence/>"
+ "</xs:complexType>"
+ "</xs:schema>";
XmlSchemaCollection collection = new XmlSchemaCollection();
collection.setSchemaResolver(new URIResolver() {
public InputSource resolveEntity(String targetNamespace, String schemaLocation,
String baseUri) {
InputSource source = new InputSource(new StringReader(hostileImport));
source.setSystemId(schemaLocation);
return source;
}
});
collection.read(new StringReader(IMPORTING_SCHEMA));
XmlSchema imported = collection.schemaForNamespace("urn:hardening");
assertNotNull(imported);
assertFalse("external entity content leaked through the import re-parse",
markupOf(imported).contains("TOP-SECRET"));
}
@Test
public void testImportedSchemaWithInternalDtdSubsetIsAccepted() {
XmlSchemaCollection collection = new XmlSchemaCollection();
collection.setSchemaResolver(new URIResolver() {
public InputSource resolveEntity(String targetNamespace, String schemaLocation,
String baseUri) {
InputSource source = new InputSource(new StringReader(W3C_STYLE_SCHEMA));
source.setSystemId(schemaLocation);
return source;
}
});
XmlSchema schema = collection.read(new StringReader(IMPORTING_SCHEMA));
assertNotNull(schema);
assertNotNull(collection.schemaForNamespace("urn:hardening"));
}
/**
* Accepting a DOCTYPE means accepting an internal DTD subset, so the limits that
* make that safe must stay in force: FEATURE_SECURE_PROCESSING bounds entity
* expansion by count, which stops nested "billion laughs" expansion.
*/
@Test
public void testNestedEntityExpansionIsBounded() {
StringBuilder subset = new StringBuilder("<!ENTITY a 'aaaaaaaaaa'>");
for (int i = 1; i < 9; i++) {
subset.append("<!ENTITY a").append(i).append(" '");
for (int j = 0; j < 10; j++) {
subset.append("&").append(i == 1 ? "a" : "a" + (i - 1)).append(";");
}
subset.append("'>");
}
try {
new XmlSchemaCollection().read(new StringReader(schemaWithSubset(subset.toString(), "&a8;")));
fail("expected the entity expansion limit to refuse a billion-laughs schema");
} catch (XmlSchemaException expected) {
assertTrue(expected.getMessage(), expected.getMessage().contains("entity expansions"));
}
}
/**
* ... and by accumulated size, which stops a flat entity referenced enough times to
* stay under the expansion count but still blow up the heap.
*/
@Test
public void testFlatEntityExpansionIsBoundedBySize() {
StringBuilder big = new StringBuilder();
for (int i = 0; i < 64 * 1024; i++) {
big.append('A');
}
StringBuilder refs = new StringBuilder();
for (int i = 0; i < 60000; i++) {
refs.append("&big;");
}
try {
new XmlSchemaCollection().read(new StringReader(
schemaWithSubset("<!ENTITY big '" + big + "'>", refs.toString())));
fail("expected the accumulated entity size limit to refuse the schema");
} catch (XmlSchemaException expected) {
assertTrue(expected.getMessage(), expected.getMessage().contains("accumulated size"));
}
}
private static String schemaWithSubset(String internalSubset, String documentation) {
return "<!DOCTYPE xs:schema [" + internalSubset + "]>"
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" targetNamespace=\"urn:hardening\">"
+ "<xs:complexType name=\"t\">"
+ "<xs:annotation><xs:documentation>" + documentation + "</xs:documentation></xs:annotation>"
+ "<xs:sequence/>"
+ "</xs:complexType>"
+ "</xs:schema>";
}
private File writeSecretFile() throws Exception {
File secret = tempFolder.newFile("secret.txt");
write(secret, "TOP-SECRET-CONTENT");
return secret;
}
private void write(File file, String content) throws Exception {
Writer writer = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8);
try {
writer.write(content);
} finally {
writer.close();
}
}
private String markupOf(XmlSchema schema) {
XmlSchemaComplexType type = (XmlSchemaComplexType)schema.getTypeByName("t");
if (type == null || type.getAnnotation() == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (Object item : type.getAnnotation().getItems()) {
XmlSchemaDocumentation doc = (XmlSchemaDocumentation)item;
NodeList markup = doc.getMarkup();
for (int i = 0; markup != null && i < markup.getLength(); i++) {
sb.append(markup.item(i).getTextContent());
}
}
return sb.toString();
}
}