diff --git a/addOns/openapi/CHANGELOG.md b/addOns/openapi/CHANGELOG.md index fbc37fe66c2..8efdf8b5353 100644 --- a/addOns/openapi/CHANGELOG.md +++ b/addOns/openapi/CHANGELOG.md @@ -4,7 +4,13 @@ All notable changes to this add-on will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added +- Support for generating XML request bodies for OpenAPI definitions and associated unit/integration tests (BodyGeneratorXmlUnitTest, OpenApiIntegrationXmlTest). +- Test resource for XML integration: `v3/openapi_xml_integration.yaml`. + ### Changed +- Wire XML body generation into the request model conversion and body generator. +- Update help content to indicate XML generation is available (may fail for complex or invalid schemas). - Dependency updates. ## [46] - 2025-09-10 diff --git a/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/converter/swagger/RequestModelConverter.java b/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/converter/swagger/RequestModelConverter.java index ef8d2c31a43..468b0e30941 100644 --- a/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/converter/swagger/RequestModelConverter.java +++ b/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/converter/swagger/RequestModelConverter.java @@ -26,7 +26,6 @@ import io.swagger.v3.oas.models.parameters.RequestBody; import java.util.List; import java.util.Map; -import org.parosproxy.paros.Constant; import org.parosproxy.paros.network.HttpHeaderField; import org.zaproxy.zap.extension.openapi.generators.Generators; import org.zaproxy.zap.extension.openapi.generators.HeadersGenerator; @@ -85,12 +84,34 @@ private String generateBody() { return generators.getBodyGenerator().generateMultiPart(schema, encoding); } - if (content.containsKey(CONTENT_APPLICATION_XML)) { - generators.addErrorMessage( - Constant.messages.getString( - "openapi.unsupportedcontent", - operation.getOperationId(), - CONTENT_APPLICATION_XML)); + // handle XML media types (application/xml, text/xml, application/*+xml) + if (content.containsKey(CONTENT_APPLICATION_XML) + || content.containsKey("text/xml") + || content.keySet().stream().anyMatch(k -> k != null && k.contains("+xml"))) { + // prefer exact application/xml entry if present + io.swagger.v3.oas.models.media.MediaType mediaType = null; + if (content.containsKey(CONTENT_APPLICATION_XML)) { + mediaType = content.get(CONTENT_APPLICATION_XML); + } else if (content.containsKey("text/xml")) { + mediaType = content.get("text/xml"); + } else { + // pick the first +xml media type + String key = + content.keySet().stream() + .filter(k -> k != null && k.contains("+xml")) + .findFirst() + .orElse(null); + if (key != null) { + mediaType = content.get(key); + } + } + if (mediaType != null) { + String xml = generators.getBodyGenerator().generateXml(mediaType); + if (xml == null) { + return ""; + } + return xml; + } return ""; } diff --git a/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/generators/BodyGenerator.java b/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/generators/BodyGenerator.java index 10c4f85a71d..40af380a16a 100644 --- a/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/generators/BodyGenerator.java +++ b/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/generators/BodyGenerator.java @@ -30,6 +30,7 @@ import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.XML; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -42,6 +43,16 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +// Use fully-qualified org.w3c.dom types to avoid name collisions with local Element class import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -64,7 +75,373 @@ public BodyGenerator(Generators generators) { this.dataGenerator = generators.getDataGenerator(); } - private enum Element { + public String generateXml(MediaType mediaType) { + String exampleBody = extractExampleBody(mediaType); + if (exampleBody != null) { + return exampleBody; + } + if (mediaType == null || mediaType.getSchema() == null) { + return ""; + } + return generateXml(mediaType.getSchema()); + } + + public String generateXml(Schema schema) { + if (schema == null) { + return ""; + } + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = db.newDocument(); + + String rootName = Optional.ofNullable(schema.getName()).orElse("root"); + // If schema has xml.name use it + io.swagger.v3.oas.models.media.XML rootXml = schema.getXml(); + String rootNamespace = null; + if (rootXml != null && rootXml.getName() != null) { + rootName = rootXml.getName(); + } + + // Create root element, honoring namespace/prefix if present + org.w3c.dom.Element root = createElementWithXml(doc, rootName, rootXml, null); + doc.appendChild(root); + + // Track namespace declarations on root when creating descendants + buildElementForSchema(doc, root, schema); + + // transform to string + TransformerFactory tf = TransformerFactory.newInstance(); + Transformer transformer = tf.newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + transformer.setOutputProperty(OutputKeys.INDENT, "no"); + StreamResult result = new StreamResult(new java.io.StringWriter()); + DOMSource source = new DOMSource(doc); + transformer.transform(source, result); + return result.getWriter().toString(); + } catch (ParserConfigurationException | TransformerException e) { + LOGGER.warn("Failed to generate XML body: {}", e.getMessage()); + if (this.generators != null) { + this.generators.addErrorMessage("Failed to generate XML body: " + e.getMessage()); + } + // Return null to indicate failure; callers will handle it + return null; + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void buildElementForSchema( + org.w3c.dom.Document doc, org.w3c.dom.Element parent, Schema schema) { + if (schema == null) { + return; + } + // Handle composed schemas (oneOf/anyOf/allOf) by resolving/merging where + // possible + if (schema instanceof ComposedSchema) { + ComposedSchema cs = (ComposedSchema) schema; + // Prefer oneOf/anyOf resolution (pick first), otherwise try to merge allOf + // components + if (cs.getOneOf() != null && !cs.getOneOf().isEmpty()) { + buildElementForSchema(doc, parent, cs.getOneOf().get(0)); + return; + } else if (cs.getAnyOf() != null && !cs.getAnyOf().isEmpty()) { + buildElementForSchema(doc, parent, cs.getAnyOf().get(0)); + return; + } else if (cs.getAllOf() != null && !cs.getAllOf().isEmpty()) { + // Merge properties from allOf into a temporary map and continue as object + Map merged = new HashMap<>(); + Schema additional = null; + for (Schema s : cs.getAllOf()) { + if (s.getProperties() != null) { + merged.putAll(s.getProperties()); + } + if (s.getAdditionalProperties() instanceof Schema) { + additional = (Schema) s.getAdditionalProperties(); + } + } + // process merged properties + for (Map.Entry property : merged.entrySet()) { + String propName = property.getKey(); + Schema propSchema = property.getValue(); + XML propXml = propSchema.getXml(); + if (propXml != null + && propXml.getAttribute() != null + && propXml.getAttribute()) { + String value = dataGenerator.generateBodyValue(propName, propSchema); + if (value != null + && value.length() >= 2 + && value.startsWith("\"") + && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + String attrName = propXml.getName() != null ? propXml.getName() : propName; + if (propXml.getNamespace() != null) { + String prefix = propXml.getPrefix(); + String qname = (prefix != null ? prefix + ":" + attrName : attrName); + parent.setAttributeNS( + propXml.getNamespace(), qname, value == null ? "" : value); + ensureNamespaceDeclarationOnRoot(parent, propXml); + } else { + parent.setAttribute(attrName, value == null ? "" : value); + } + } else { + String childName = propName; + if (propXml != null && propXml.getName() != null) { + childName = propXml.getName(); + } + org.w3c.dom.Element child = + createElementWithXml(doc, childName, propXml, parent); + parent.appendChild(child); + buildElementForSchema(doc, child, propSchema); + } + } + if (additional != null) { + // generate two entries for additionalProperties map + for (int i = 0; i < 2; i++) { + org.w3c.dom.Element entry = doc.createElement("entry"); + parent.appendChild(entry); + org.w3c.dom.Element key = doc.createElement("key"); + key.setTextContent("k" + i); + entry.appendChild(key); + org.w3c.dom.Element value = doc.createElement("value"); + entry.appendChild(value); + buildElementForSchema(doc, value, additional); + } + } + return; + } + } + // Binary schema handling + if (schema instanceof BinarySchema) { + String content = generateFromBinarySchema((BinarySchema) schema, false); + parent.setTextContent(content == null ? "" : content); + return; + } + + // Primitive types (non-array, non-object) + if (!(schema instanceof ArraySchema) && !(schema instanceof ObjectSchema)) { + String value = dataGenerator.generateBodyValue(parent.getNodeName(), schema); + // strip surrounding quotes if present + if (value != null + && value.length() >= 2 + && value.startsWith("\"") + && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + parent.setTextContent(value == null ? "" : value); + return; + } + + if (schema instanceof ArraySchema) { + Schema items = ((ArraySchema) schema).getItems(); + if (items == null) { + return; + } + // Determine item element name + String itemName = Optional.ofNullable(items.getName()).orElse(parent.getNodeName()); + XML xml = items.getXml(); + if (xml != null && xml.getName() != null) { + itemName = xml.getName(); + } + // produce two items to mirror JSON array behaviour + // If this array is configured as not wrapped (xml.wrapped == false) then + // append item elements to the parent of 'parent' instead of using the + // container represented by 'parent'. This handles cases where callers + // created a property element for the array but the XML schema expects + // repeated item elements without a wrapper. + boolean wrapped = true; + XML parentXml = schema.getXml(); + if (parentXml != null && parentXml.getWrapped() != null) { + wrapped = parentXml.getWrapped(); + } + + org.w3c.dom.Node insertionParent = parent; + if (!wrapped && parent.getParentNode() instanceof org.w3c.dom.Element) { + // use the parent's parent as the insertion point and remove the container + // element + insertionParent = parent.getParentNode(); + insertionParent.removeChild(parent); + } + + for (int i = 0; i < 2; i++) { + org.w3c.dom.Element itemEl = createElementWithXml(doc, itemName, xml, parent); + insertionParent.appendChild(itemEl); + buildElementForSchema(doc, itemEl, items); + } + return; + } + + // ObjectSchema or schema with properties + Map properties = schema.getProperties(); + if (properties != null) { + for (Map.Entry property : properties.entrySet()) { + String propName = property.getKey(); + Schema propSchema = property.getValue(); + XML propXml = propSchema.getXml(); + if (propXml != null && propXml.getAttribute() != null && propXml.getAttribute()) { + // attribute on parent + String value = dataGenerator.generateBodyValue(propName, propSchema); + if (value != null + && value.length() >= 2 + && value.startsWith("\"") + && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + String attrName = propXml.getName() != null ? propXml.getName() : propName; + // If attribute has namespace/prefix, use setAttributeNS + if (propXml.getNamespace() != null) { + String prefix = propXml.getPrefix(); + String qname = (prefix != null ? prefix + ":" + attrName : attrName); + parent.setAttributeNS( + propXml.getNamespace(), qname, value == null ? "" : value); + // ensure xmlns declaration exists on root + ensureNamespaceDeclarationOnRoot(parent, propXml); + } else { + parent.setAttribute(attrName, value == null ? "" : value); + } + } else { + String childName = propName; + if (propXml != null && propXml.getName() != null) { + childName = propXml.getName(); + } + // If this property is an array and is marked as unwrapped, we + // should not create the property container element and instead + // append the item elements directly under the current parent. + if (propSchema instanceof ArraySchema + && propXml != null + && propXml.getWrapped() != null + && !propXml.getWrapped()) { + ArraySchema arr = (ArraySchema) propSchema; + Schema items = arr.getItems(); + if (items != null) { + String itemName = + Optional.ofNullable(items.getName()).orElse(childName); + XML itemXml = items.getXml(); + if (itemXml != null && itemXml.getName() != null) { + itemName = itemXml.getName(); + } + // append two item elements directly under parent + for (int i = 0; i < 2; i++) { + org.w3c.dom.Element itemEl = + createElementWithXml(doc, itemName, itemXml, parent); + parent.appendChild(itemEl); + buildElementForSchema(doc, itemEl, items); + } + continue; + } + } + + org.w3c.dom.Element child = + createElementWithXml(doc, childName, propXml, parent); + parent.appendChild(child); + buildElementForSchema(doc, child, propSchema); + } + } + // handle additionalProperties if present (map values) + if (schema.getAdditionalProperties() instanceof Schema) { + Schema add = (Schema) schema.getAdditionalProperties(); + for (int i = 0; i < 2; i++) { + org.w3c.dom.Element entry = doc.createElement("entry"); + parent.appendChild(entry); + org.w3c.dom.Element key = doc.createElement("key"); + key.setTextContent("k" + i); + entry.appendChild(key); + org.w3c.dom.Element value = doc.createElement("value"); + entry.appendChild(value); + buildElementForSchema(doc, value, add); + } + } + } else if (schema.getAdditionalProperties() instanceof Schema) { + // No named properties, but additionalProperties defines the value type -> emit + // map entries + Schema add = (Schema) schema.getAdditionalProperties(); + for (int i = 0; i < 2; i++) { + org.w3c.dom.Element entry = doc.createElement("entry"); + parent.appendChild(entry); + org.w3c.dom.Element key = doc.createElement("key"); + key.setTextContent("k" + i); + entry.appendChild(key); + org.w3c.dom.Element value = doc.createElement("value"); + entry.appendChild(value); + buildElementForSchema(doc, value, add); + } + } + } + + /** + * Create an element honoring the given XML metadata (namespace and prefix) and ensure any + * namespace declarations are added to the root element. + * + * @param doc the document + * @param name the local name for the element + * @param xml the XML metadata (may be null) + * @param contextParent a nearby element whose root will receive namespace declarations (may be + * null) + * @return the created Element + */ + private org.w3c.dom.Element createElementWithXml( + org.w3c.dom.Document doc, String name, XML xml, org.w3c.dom.Element contextParent) { + if (xml != null && xml.getNamespace() != null) { + String ns = xml.getNamespace(); + String prefix = xml.getPrefix(); + String qname = (prefix != null) ? (prefix + ":" + name) : name; + org.w3c.dom.Element el = doc.createElementNS(ns, qname); + // ensure namespace declaration on root + ensureNamespaceDeclarationOnRoot((contextParent != null) ? contextParent : el, xml); + return el; + } + return doc.createElement(name); + } + + /** + * Ensure that the namespace declaration for the provided XML metadata exists on the root + * element. + */ + private void ensureNamespaceDeclarationOnRoot(org.w3c.dom.Element anyChild, XML xml) { + if (xml == null || xml.getNamespace() == null) { + return; + } + org.w3c.dom.Node node = anyChild; + // find the document root element + while (node != null && !(node instanceof org.w3c.dom.Document)) { + if (node.getParentNode() == null) { + break; + } + node = node.getParentNode(); + } + org.w3c.dom.Element root = null; + if (node instanceof org.w3c.dom.Document) { + root = ((org.w3c.dom.Document) node).getDocumentElement(); + } else { + // walk up parents from anyChild to find root element + node = anyChild; + while (node != null && !(node instanceof org.w3c.dom.Document)) { + if (node instanceof org.w3c.dom.Element + && node.getParentNode() instanceof org.w3c.dom.Document) { + root = (org.w3c.dom.Element) node; + break; + } + node = node.getParentNode(); + } + } + if (root == null) { + return; + } + String ns = xml.getNamespace(); + String prefix = xml.getPrefix(); + if (prefix != null) { + String attr = "xmlns:" + prefix; + if (!root.hasAttribute(attr)) { + root.setAttribute(attr, ns); + } + } else { + if (!root.hasAttribute("xmlns")) { + root.setAttribute("xmlns", ns); + } + } + } + + private enum JsonElement { OBJECT_BEGIN, OBJECT_END, ARRAY_BEGIN, @@ -75,17 +452,17 @@ private enum Element { } @SuppressWarnings("serial") - private static final Map SYNTAX = + private static final Map SYNTAX = Collections.unmodifiableMap( - new HashMap() { + new HashMap() { { - put(Element.OBJECT_BEGIN, "{"); - put(Element.OBJECT_END, "}"); - put(Element.ARRAY_BEGIN, "["); - put(Element.ARRAY_END, "]"); - put(Element.PROPERTY_CONTAINER, "\""); - put(Element.INNER_SEPARATOR, ":"); - put(Element.OUTER_SEPARATOR, ","); + put(JsonElement.OBJECT_BEGIN, "{"); + put(JsonElement.OBJECT_END, "}"); + put(JsonElement.ARRAY_BEGIN, "["); + put(JsonElement.ARRAY_END, "]"); + put(JsonElement.PROPERTY_CONTAINER, "\""); + put(JsonElement.INNER_SEPARATOR, ":"); + put(JsonElement.OUTER_SEPARATOR, ","); } }); @@ -158,17 +535,17 @@ private static String generateFromBinarySchema(BinarySchema schema, boolean imag private String generateFromObjectSchema(Map properties) { StringBuilder json = new StringBuilder(); boolean isFirst = true; - json.append(SYNTAX.get(Element.OBJECT_BEGIN)); + json.append(SYNTAX.get(JsonElement.OBJECT_BEGIN)); for (Map.Entry property : properties.entrySet()) { if (isFirst) { isFirst = false; } else { - json.append(SYNTAX.get(Element.OUTER_SEPARATOR)); + json.append(SYNTAX.get(JsonElement.OUTER_SEPARATOR)); } - json.append(SYNTAX.get(Element.PROPERTY_CONTAINER)); + json.append(SYNTAX.get(JsonElement.PROPERTY_CONTAINER)); json.append(property.getKey()); - json.append(SYNTAX.get(Element.PROPERTY_CONTAINER)); - json.append(SYNTAX.get(Element.INNER_SEPARATOR)); + json.append(SYNTAX.get(JsonElement.PROPERTY_CONTAINER)); + json.append(SYNTAX.get(JsonElement.INNER_SEPARATOR)); String value; if (dataGenerator.isSupported(property.getValue())) { value = dataGenerator.generateBodyValue(property.getKey(), property.getValue()); @@ -187,16 +564,16 @@ private String generateFromObjectSchema(Map properties) { } json.append(value); } - json.append(SYNTAX.get(Element.OBJECT_END)); + json.append(SYNTAX.get(JsonElement.OBJECT_END)); return json.toString(); } private static String createJsonArrayWith(String jsonStr) { - return SYNTAX.get(Element.ARRAY_BEGIN) + return SYNTAX.get(JsonElement.ARRAY_BEGIN) + jsonStr - + SYNTAX.get(Element.OUTER_SEPARATOR) + + SYNTAX.get(JsonElement.OUTER_SEPARATOR) + jsonStr - + SYNTAX.get(Element.ARRAY_END); + + SYNTAX.get(JsonElement.ARRAY_END); } private String generateJsonPrimitiveValue(Schema schema) { @@ -224,12 +601,12 @@ private static void resolveNotSchema(Schema schema) { } @SuppressWarnings("serial") - private static final Map FORMSYNTAX = + private static final Map FORMSYNTAX = Collections.unmodifiableMap( - new HashMap() { + new HashMap() { { - put(Element.INNER_SEPARATOR, "="); - put(Element.OUTER_SEPARATOR, "&"); + put(JsonElement.INNER_SEPARATOR, "="); + put(JsonElement.OUTER_SEPARATOR, "&"); } }); @@ -243,12 +620,12 @@ public String generateForm(Schema schema) { StringBuilder formData = new StringBuilder(); for (Map.Entry property : properties.entrySet()) { formData.append(urlEncode(property.getKey())); - formData.append(FORMSYNTAX.get(Element.INNER_SEPARATOR)); + formData.append(FORMSYNTAX.get(JsonElement.INNER_SEPARATOR)); formData.append( urlEncode( dataGenerator.generateValue( property.getKey(), property.getValue(), true))); - formData.append(FORMSYNTAX.get(Element.OUTER_SEPARATOR)); + formData.append(FORMSYNTAX.get(JsonElement.OUTER_SEPARATOR)); } return formData.substring(0, formData.length() - 1); } diff --git a/addOns/openapi/src/main/javahelp/org/zaproxy/zap/extension/openapi/resources/help/contents/openapi.html b/addOns/openapi/src/main/javahelp/org/zaproxy/zap/extension/openapi/resources/help/contents/openapi.html index 9dce8c0354c..c0d08c75fe7 100644 --- a/addOns/openapi/src/main/javahelp/org/zaproxy/zap/extension/openapi/resources/help/contents/openapi.html +++ b/addOns/openapi/src/main/javahelp/org/zaproxy/zap/extension/openapi/resources/help/contents/openapi.html @@ -10,7 +10,7 @@

OpenAPI Support

This add-on allows you to spider and import OpenAPI (Swagger) definitions, versions 1.2, 2.0, and 3.0.
-Note: Generation of XML content is currently not supported. +Note: The add-on can generate XML request bodies for XML media types (for example, application/xml, text/xml, and application/*+xml). If an explicit example is provided in the OpenAPI media type that example will be used; otherwise the add-on will attempt to generate a best-effort XML example from the schema. The generator attempts to honour XML-specific metadata (such as xml.name, xml.namespace, xml.prefix, xml.attribute, and xml.wrapped). Generation can fail for complex or invalid schemas. If generation fails an error is logged and surfaced, and an empty body may be returned — you can also supply a hand-crafted example in the OpenAPI spec or edit the request body manually.

The add-on will automatically detect any OpenAPI definitions and spider them as long as they are in scope.

diff --git a/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/automation/OpenApiIntegrationXmlTest.java b/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/automation/OpenApiIntegrationXmlTest.java new file mode 100644 index 00000000000..c3038c2cc27 --- /dev/null +++ b/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/automation/OpenApiIntegrationXmlTest.java @@ -0,0 +1,92 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2025 The ZAP Development Team + * + * Licensed 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 org.zaproxy.zap.extension.openapi.automation; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.parosproxy.paros.Constant; +import org.zaproxy.zap.extension.openapi.ExtensionOpenApi; +import org.zaproxy.zap.extension.openapi.converter.swagger.OperationModel; +import org.zaproxy.zap.extension.openapi.converter.swagger.RequestModelConverter; +import org.zaproxy.zap.extension.openapi.generators.Generators; +import org.zaproxy.zap.testutils.TestUtils; + +class OpenApiIntegrationXmlTest extends TestUtils { + + @BeforeEach + void setUp() { + mockMessages(new ExtensionOpenApi()); + Constant.messages = null; // leave default initialized by TestUtils/mockMessages + } + + @Test + void shouldGenerateXmlRequestBodiesAndNoUnsupportedMessage() throws Exception { + String defn = + IOUtils.toString( + this.getClass() + .getResourceAsStream( + "/org/zaproxy/zap/extension/openapi/v3/openapi_xml_integration.yaml"), + StandardCharsets.UTF_8); + + ParseOptions options = new ParseOptions(); + options.setResolveFully(true); + OpenAPI openAPI = + new OpenAPIV3Parser().readContents(defn, new ArrayList<>(), options).getOpenAPI(); + + Generators generators = new Generators(null); + OperationModel operationModel = + new OperationModel("/xml", openAPI.getPaths().get("/xml").getPost(), null); + + RequestModelConverter converter = new RequestModelConverter(); + String body = converter.convert(operationModel, generators).getBody(); + + // Body should be non-empty and should look like XML + org.junit.jupiter.api.Assertions.assertNotNull(body); + org.junit.jupiter.api.Assertions.assertFalse(body.isEmpty()); + // Quick sanity parse + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + db.parse(new java.io.ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + + // There should be no unsupported-content error message for application/xml + assertThat( + generators.getErrorMessages().stream() + .filter( + s -> + s.contains( + "the content type application/xml is not supported")) + .toList(), + empty()); + + // The overall error messages list may be empty; we've already asserted the + // specific + // unsupported-content message is not present above. + } +} diff --git a/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/generators/BodyGeneratorXmlUnitTest.java b/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/generators/BodyGeneratorXmlUnitTest.java new file mode 100644 index 00000000000..778802961a1 --- /dev/null +++ b/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/generators/BodyGeneratorXmlUnitTest.java @@ -0,0 +1,266 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2025 The ZAP Development Team + * + * Licensed 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 org.zaproxy.zap.extension.openapi.generators; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.zaproxy.zap.extension.openapi.ExtensionOpenApi; +import org.zaproxy.zap.testutils.TestUtils; + +class BodyGeneratorXmlUnitTest extends TestUtils { + + Generators generators; + + @BeforeAll + static void setUp() { + mockMessages(new ExtensionOpenApi()); + } + + @BeforeEach + void init() { + generators = new Generators(null); + } + + @Test + void shouldGenerateXmlForSimpleObject() throws Exception { + ObjectSchema schema = new ObjectSchema(); + schema.setName("person"); + Schema name = new Schema<>(); + name.setType("string"); + schema.setProperties(Map.of("name", name)); + + String xml = generators.getBodyGenerator().generateXml(schema); + assertFalse(xml.isEmpty()); + // Parse and assert structure + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = + db.parse( + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.w3c.dom.Element root = doc.getDocumentElement(); + org.junit.jupiter.api.Assertions.assertEquals("person", root.getNodeName()); + org.w3c.dom.NodeList names = doc.getElementsByTagName("name"); + org.junit.jupiter.api.Assertions.assertTrue(names.getLength() >= 1); + } + + @Test + void shouldGenerateXmlForArray() throws Exception { + ArraySchema schema = new ArraySchema(); + Schema items = new Schema<>(); + items.setType("string"); + items.setName("tag"); + schema.setItems(items); + schema.setName("tags"); + + String xml = generators.getBodyGenerator().generateXml(schema); + assertFalse(xml.isEmpty()); + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = + db.parse( + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.w3c.dom.Element root = doc.getDocumentElement(); + org.junit.jupiter.api.Assertions.assertEquals("tags", root.getNodeName()); + org.w3c.dom.NodeList tagNodes = doc.getElementsByTagName("tag"); + org.junit.jupiter.api.Assertions.assertEquals(2, tagNodes.getLength()); + } + + @Test + void shouldGenerateXmlWithAttribute() throws Exception { + ObjectSchema schema = new ObjectSchema(); + schema.setName("person"); + Schema id = new Schema<>(); + id.setType("integer"); + io.swagger.v3.oas.models.media.XML idXml = new io.swagger.v3.oas.models.media.XML(); + idXml.setAttribute(true); + idXml.setName("id"); + id.setXml(idXml); + Schema name = new Schema<>(); + name.setType("string"); + schema.setProperties(Map.of("id", id, "name", name)); + + String xml = generators.getBodyGenerator().generateXml(schema); + assertFalse(xml.isEmpty()); + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = + db.parse( + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.w3c.dom.Element root = doc.getDocumentElement(); + org.junit.jupiter.api.Assertions.assertEquals("person", root.getNodeName()); + org.junit.jupiter.api.Assertions.assertTrue(root.hasAttribute("id")); + org.w3c.dom.NodeList names = doc.getElementsByTagName("name"); + org.junit.jupiter.api.Assertions.assertTrue(names.getLength() >= 1); + } + + @Test + void shouldGenerateXmlForUnwrappedArray() throws Exception { + ObjectSchema schema = new ObjectSchema(); + schema.setName("tagsContainer"); + ArraySchema tags = new ArraySchema(); + Schema items = new Schema<>(); + items.setType("string"); + items.setName("tag"); + io.swagger.v3.oas.models.media.XML itemXml = new io.swagger.v3.oas.models.media.XML(); + itemXml.setName("tag"); + items.setXml(itemXml); + io.swagger.v3.oas.models.media.XML tagsXml = new io.swagger.v3.oas.models.media.XML(); + tagsXml.setWrapped(false); + tags.setXml(tagsXml); + tags.setItems(items); + schema.setProperties(Map.of("tags", tags)); + + String xml = generators.getBodyGenerator().generateXml(schema); + assertFalse(xml.isEmpty()); + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = + db.parse( + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.w3c.dom.Element root = doc.getDocumentElement(); + org.junit.jupiter.api.Assertions.assertEquals("tagsContainer", root.getNodeName()); + // ensure no wrapper exists + org.w3c.dom.NodeList wrappers = doc.getElementsByTagName("tags"); + org.junit.jupiter.api.Assertions.assertEquals(0, wrappers.getLength()); + org.w3c.dom.NodeList tagNodes = doc.getElementsByTagName("tag"); + org.junit.jupiter.api.Assertions.assertEquals(2, tagNodes.getLength()); + } + + @Test + void shouldGenerateXmlWithNamespaceAndPrefix() throws Exception { + ObjectSchema schema = new ObjectSchema(); + schema.setName("person"); + Schema name = new Schema<>(); + name.setType("string"); + schema.setProperties(Map.of("name", name)); + io.swagger.v3.oas.models.media.XML rootXml = new io.swagger.v3.oas.models.media.XML(); + rootXml.setNamespace("http://example.com/ns"); + rootXml.setPrefix("ex"); + rootXml.setName("person"); + schema.setXml(rootXml); + + String xml = generators.getBodyGenerator().generateXml(schema); + assertFalse(xml.isEmpty()); + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = + db.parse( + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.w3c.dom.Element root = doc.getDocumentElement(); + org.junit.jupiter.api.Assertions.assertEquals("person", root.getLocalName()); + org.junit.jupiter.api.Assertions.assertEquals("ex", root.getPrefix()); + org.junit.jupiter.api.Assertions.assertEquals( + "http://example.com/ns", root.getNamespaceURI()); + } + + @org.junit.jupiter.api.Test + void shouldGenerateXmlForBinary() throws Exception { + ObjectSchema schema = new ObjectSchema(); + schema.setName("fileContainer"); + io.swagger.v3.oas.models.media.BinarySchema file = + new io.swagger.v3.oas.models.media.BinarySchema(); + schema.setProperties(Map.of("file", file)); + + String xml = generators.getBodyGenerator().generateXml(schema); + assertFalse(xml.isEmpty()); + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = + db.parse( + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.w3c.dom.NodeList files = doc.getElementsByTagName("file"); + org.junit.jupiter.api.Assertions.assertEquals(1, files.getLength()); + } + + @org.junit.jupiter.api.Test + void shouldGenerateXmlForAdditionalProperties() throws Exception { + ObjectSchema schema = new ObjectSchema(); + schema.setName("mapContainer"); + Schema add = new Schema<>(); + add.setType("string"); + schema.setAdditionalProperties(add); + + String xml = generators.getBodyGenerator().generateXml(schema); + assertFalse(xml.isEmpty()); + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = + db.parse( + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.w3c.dom.NodeList entries = doc.getElementsByTagName("entry"); + org.junit.jupiter.api.Assertions.assertEquals(2, entries.getLength()); + org.w3c.dom.NodeList keys = doc.getElementsByTagName("key"); + org.junit.jupiter.api.Assertions.assertEquals(2, keys.getLength()); + org.w3c.dom.NodeList values = doc.getElementsByTagName("value"); + org.junit.jupiter.api.Assertions.assertEquals(2, values.getLength()); + } + + @org.junit.jupiter.api.Test + void shouldGenerateXmlForComposedSchemaAllOf() throws Exception { + ComposedSchema cs = new ComposedSchema(); + Schema s1 = new ObjectSchema(); + Schema prop1 = new Schema<>(); + prop1.setType("string"); + ((ObjectSchema) s1).setProperties(Map.of("a", prop1)); + Schema s2 = new ObjectSchema(); + Schema prop2 = new Schema<>(); + prop2.setType("integer"); + ((ObjectSchema) s2).setProperties(Map.of("b", prop2)); + cs.setAllOf(List.of(s1, s2)); + + String xml = generators.getBodyGenerator().generateXml(cs); + assertFalse(xml.isEmpty()); + javax.xml.parsers.DocumentBuilderFactory dbf = + javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + org.w3c.dom.Document doc = + db.parse( + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.w3c.dom.NodeList a = doc.getElementsByTagName("a"); + org.w3c.dom.NodeList b = doc.getElementsByTagName("b"); + org.junit.jupiter.api.Assertions.assertEquals(1, a.getLength()); + org.junit.jupiter.api.Assertions.assertEquals(1, b.getLength()); + } +} diff --git a/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/v3/BodyGeneratorUnitTest.java b/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/v3/BodyGeneratorUnitTest.java index c16301d20c3..b87a006bc84 100644 --- a/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/v3/BodyGeneratorUnitTest.java +++ b/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/v3/BodyGeneratorUnitTest.java @@ -19,10 +19,6 @@ */ package org.zaproxy.zap.extension.openapi.v3; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.emptyString; -import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -723,12 +719,16 @@ void shouldNotGenerateContentForApplicationXml() throws IOException { new OperationModel("/xml", definition.getPaths().get("/xml").getPost(), null); // When String content = new RequestModelConverter().convert(operationModel, generators).getBody(); - // Then - assertThat(content, is(emptyString())); - assertThat( - generators.getErrorMessages(), - contains( - "Not generating request body for operation xml, the content type application/xml is not supported.")); + // Then - XML bodies should now be generated for application/xml and the + // unsupported + // content message should no longer be produced for this operation. + org.junit.jupiter.api.Assertions.assertFalse(content.isEmpty()); + org.junit.jupiter.api.Assertions.assertFalse( + generators.getErrorMessages().stream() + .anyMatch( + s -> + s.contains( + "Not generating request body for operation xml, the content type application/xml is not supported."))); } @Test diff --git a/addOns/openapi/src/test/resources/org/zaproxy/zap/extension/openapi/v3/openapi_xml_integration.yaml b/addOns/openapi/src/test/resources/org/zaproxy/zap/extension/openapi/v3/openapi_xml_integration.yaml new file mode 100644 index 00000000000..ea78cf5fb51 --- /dev/null +++ b/addOns/openapi/src/test/resources/org/zaproxy/zap/extension/openapi/v3/openapi_xml_integration.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.3 +info: + title: XML Integration Test + version: 1.0.0 +paths: + /xml: + post: + requestBody: + content: + application/xml: + schema: + type: object + xml: + name: person + namespace: "http://example.com/ns" + prefix: ex + properties: + id: + type: integer + xml: + attribute: true + name: id + tags: + type: array + xml: + name: tags + wrapped: false + items: + type: string + xml: + name: tag + responses: + "200": + description: OK