From 0eeaa0e7b02d8cfe3c8abfda335ae3823835b03d Mon Sep 17 00:00:00 2001 From: Simon Bennetts Date: Thu, 11 Sep 2025 11:22:26 +0100 Subject: [PATCH 1/2] Schedule Chrome Docker test Signed-off-by: Simon Bennetts --- .github/workflows/test-chrome-docker.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-chrome-docker.yml b/.github/workflows/test-chrome-docker.yml index 005040cdfed..13e93a18270 100644 --- a/.github/workflows/test-chrome-docker.yml +++ b/.github/workflows/test-chrome-docker.yml @@ -1,8 +1,9 @@ name: Test Chrome in nightly Docker on: workflow_dispatch: - #schedule: - # TODO schedule + schedule: + # Every Monday at 5am + - cron: '0 5 * * 1' jobs: publish: From 2eb501df478f62fae16c2f02c1ad07f7d3e52bfb Mon Sep 17 00:00:00 2001 From: Simon Bennetts Date: Wed, 3 Sep 2025 10:31:36 +0100 Subject: [PATCH 2/2] Fixed structured POST data node names Signed-off-by: Simon Bennetts --- .../zaproxy/zap/model/SessionStructure.java | 240 ++++++++++----- .../java/org/zaproxy/zap/utils/JsonUtil.java | 73 +++++ .../java/org/zaproxy/zap/utils/XmlUtils.java | 67 +++++ .../zap/model/SessionStructureUnitTest.java | 161 +++++++++- .../zaproxy/zap/utils/JsonUtilUnitTest.java | 88 +++++- .../zaproxy/zap/utils/XmlUtilsUnitTest.java | 275 ++++++++++++++++++ 6 files changed, 813 insertions(+), 91 deletions(-) create mode 100644 zap/src/test/java/org/zaproxy/zap/utils/XmlUtilsUnitTest.java diff --git a/zap/src/main/java/org/zaproxy/zap/model/SessionStructure.java b/zap/src/main/java/org/zaproxy/zap/model/SessionStructure.java index 6a2f21bbf83..944c987dbdf 100644 --- a/zap/src/main/java/org/zaproxy/zap/model/SessionStructure.java +++ b/zap/src/main/java/org/zaproxy/zap/model/SessionStructure.java @@ -23,12 +23,16 @@ import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.stream.Collectors; +import net.sf.json.JSONException; import org.apache.commons.httpclient.URI; import org.apache.commons.httpclient.URIException; +import org.apache.commons.lang3.Strings; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.parosproxy.paros.Constant; import org.parosproxy.paros.core.scanner.Variant; +import org.parosproxy.paros.core.scanner.VariantMultipartFormParameters; import org.parosproxy.paros.db.DatabaseException; import org.parosproxy.paros.db.RecordStructure; import org.parosproxy.paros.model.HistoryReference; @@ -40,16 +44,18 @@ import org.parosproxy.paros.network.HttpMalformedHeaderException; import org.parosproxy.paros.network.HttpMessage; import org.parosproxy.paros.network.HttpRequestHeader; +import org.zaproxy.zap.utils.JsonUtil; +import org.zaproxy.zap.utils.XmlUtils; public class SessionStructure { public static final String ROOT = "Root"; + public static final int MAX_NODE_NAME_SIZE = 512; + public static final String DATA_DRIVEN_NODE_PREFIX = "\u00AB"; public static final String DATA_DRIVEN_NODE_POSTFIX = "\u00BB"; public static final String DATA_DRIVEN_NODE_REGEX = "(.+?)"; - private static final String MULTIPART_FORM_DATA_DISPLAY = - "(" + HttpHeader.FORM_MULTIPART_CONTENT_TYPE + ")"; private static final Logger LOGGER = LogManager.getLogger(SessionStructure.class); @@ -200,10 +206,16 @@ private static String getNodeName( String host = getHostName(uri); String nodeUrl = pathsToUrl(host, paths, paths.size()); - String params = getParams(session, uri, postData, contentType); - if (params.length() > 0) { - nodeUrl += " " + params; + try { + HttpMessage msg = getMsg(uri, method, postData, contentType); + String params = getParams(session, msg); + if (!params.isEmpty()) { + nodeUrl += " " + params; + } + } catch (HttpMalformedHeaderException e) { + LOGGER.error(e.getMessage(), e); } + return nodeUrl; } @@ -214,7 +226,7 @@ private static String getNodeName( if (msg != null) { String params = getParams(session, msg); - if (params.length() > 0) { + if (!params.isEmpty()) { nodeUrl = nodeUrl + " " + params; } } @@ -231,12 +243,16 @@ private static String getNodeName( * @since 2.10.0 */ public static String getNodeName(Model model, HttpMessage msg) throws URIException { - return getNodeName( - model, - msg.getRequestHeader().getURI(), - msg.getRequestHeader().getMethod(), - msg.getRequestBody().toString(), - msg.getRequestHeader().getHeader(HttpHeader.CONTENT_TYPE)); + Session session = model.getSession(); + URI uri = msg.getRequestHeader().getURI(); + List paths = getTreePath(model, uri); + String host = getHostName(uri); + String nodeUrl = pathsToUrl(host, paths, paths.size()); + String params = getParams(session, msg); + if (!params.isEmpty()) { + nodeUrl += " " + params; + } + return nodeUrl; } public static String getLeafName(Model model, String nodeName, HttpMessage msg) { @@ -256,7 +272,8 @@ public static String getLeafName(Model model, String nodeName, HttpMessage msg) convertNVP( model.getSession().getParameters(msg, Type.url), org.parosproxy.paros.core.scanner.NameValuePair.TYPE_QUERY_STRING); - if (msg.getRequestHeader().getMethod().equalsIgnoreCase(HttpRequestHeader.POST)) { + + if (msg.getRequestBody().length() > 0) { params.addAll( convertNVP( model.getSession().getParameters(msg, Type.form), @@ -284,13 +301,7 @@ public static String getLeafName( throws HttpMalformedHeaderException { Objects.requireNonNull(uri); Objects.requireNonNull(method); - HttpMessage msg = new HttpMessage(uri); - msg.getRequestHeader().setMethod(method); - if (method.equalsIgnoreCase(HttpRequestHeader.POST)) { - msg.getRequestBody().setBody(postData); - msg.getRequestHeader().setContentLength(msg.getRequestBody().length()); - } - return getLeafName(model, nodeName, msg); + return getLeafName(model, nodeName, getMsg(uri, method, postData, null)); } public static String getLeafName( @@ -303,43 +314,18 @@ public static String getLeafName( sb.append(":"); sb.append(nodeName); - if (method.equalsIgnoreCase(HttpRequestHeader.POST)) { - sb.append( - getQueryParamString( - convertParosNVP( - params, - org.parosproxy.paros.core.scanner.NameValuePair - .TYPE_QUERY_STRING), - true)); - - String contentType = message.getRequestHeader().getHeader(HttpHeader.CONTENT_TYPE); - if (contentType != null - && contentType.startsWith(HttpHeader.FORM_MULTIPART_CONTENT_TYPE)) { - sb.append(MULTIPART_FORM_DATA_DISPLAY); - } else { - sb.append( - getQueryParamString( - convertParosNVP( - params, - org.parosproxy.paros.core.scanner.NameValuePair - .TYPE_POST_DATA), - false)); - } - } else { - sb.append( - getQueryParamString( - convertParosNVP( - params, - org.parosproxy.paros.core.scanner.NameValuePair - .TYPE_QUERY_STRING), - false)); - sb.append( - getQueryParamString( - convertParosNVP( - params, - org.parosproxy.paros.core.scanner.NameValuePair.TYPE_POST_DATA), - false)); - } + List postParams = + convertParosNVP( + params, org.parosproxy.paros.core.scanner.NameValuePair.TYPE_POST_DATA); + + sb.append( + getQueryParamString( + convertParosNVP( + params, + org.parosproxy.paros.core.scanner.NameValuePair.TYPE_QUERY_STRING), + !postParams.isEmpty())); + + sb.append(getPostParamString(message, getQueryParamString(postParams, false))); return sb.toString(); } @@ -499,7 +485,6 @@ private static RecordStructure addStructure( int historyId, boolean newOnly) throws DatabaseException, URIException { - // String nodeUrl = pathsToUrl(host, paths, size); Session session = model.getSession(); String nodeName = getNodeName(session, host, msg, paths, size); String parentName = pathsToUrl(host, paths, size - 1); @@ -508,7 +493,7 @@ private static RecordStructure addStructure( if (msg != null) { url = msg.getRequestHeader().getURI().toString(); String params = getParams(session, msg); - if (params.length() > 0) { + if (!params.isEmpty()) { nodeName = nodeName + " " + params; } } @@ -649,27 +634,125 @@ public static StructuralNode getRootNode(Model model) { } private static String getParams(Session session, HttpMessage msg) throws URIException { - return getParams( - session, - msg.getRequestHeader().getURI(), - msg.getRequestBody().toString(), - msg.getRequestHeader().getHeader(HttpHeader.CONTENT_TYPE)); - } + String contentType = msg.getRequestHeader().getHeader(HttpHeader.CONTENT_TYPE); + String reqBody = msg.getRequestBody().toString(); + boolean hasReqBody = contentType != null && !reqBody.isEmpty(); - private static String getParams( - Session session, URI uri, String requestBody, String contentType) throws URIException { - boolean hasReqBody = contentType != null && requestBody != null && !requestBody.isEmpty(); - String leafParams = getQueryParamString(session.getUrlParameters(uri), hasReqBody); + String leafParams = + getQueryParamString( + session.getUrlParameters(msg.getRequestHeader().getURI()), hasReqBody); if (!hasReqBody) { return leafParams; } + return leafParams + + getPostParamString( + msg, + getQueryParamString( + session.getFormParameters(msg.getRequestHeader().getURI(), reqBody), + false)); + } + + private static String getPostParamString(HttpMessage msg, String fallback) { + String contentType = msg.getRequestHeader().getHeader(HttpHeader.CONTENT_TYPE); + String reqBody = msg.getRequestBody().toString(); + if (!reqBody.isEmpty()) { + String str; + if (contentType != null) { + str = getPostParamStringForContentType(msg, contentType, reqBody); + } else { + str = guessPostParamString(msg, reqBody); + } + if (str != null) { + return "(" + str + ")"; + } + } + return fallback; + } + + private static String getPostParamStringForContentType( + HttpMessage msg, String contentType, String body) { if (contentType.startsWith(HttpHeader.FORM_MULTIPART_CONTENT_TYPE)) { - leafParams += MULTIPART_FORM_DATA_DISPLAY; - } else if (contentType.startsWith("application/x-www-form-urlencoded")) { - leafParams += getQueryParamString(session.getFormParameters(uri, requestBody), false); + VariantMultipartFormParameters mfp = new VariantMultipartFormParameters(); + mfp.setMessage(msg); + return "multipart:" + + getNameList( + mfp.getParamList().stream() + .filter(p -> isRelevantMultipartParam(p.getType())) + .toList()); + } + if (msg.getRequestHeader().hasContentType("json")) { + try { + return JsonUtil.getJsonKeyString(body); + } catch (JSONException e) { + return body.substring(0, Math.min(body.length(), MAX_NODE_NAME_SIZE)); + } + } + if (msg.getRequestHeader().hasContentType("xml")) { + try { + return XmlUtils.getXmlKeyString(body); + } catch (Exception e) { + return body.substring(0, Math.min(body.length(), MAX_NODE_NAME_SIZE)); + } } - return leafParams; + return null; + } + + private static boolean isRelevantMultipartParam(int type) { + return type == org.parosproxy.paros.core.scanner.NameValuePair.TYPE_MULTIPART_DATA_FILE_NAME + || type + == org.parosproxy.paros.core.scanner.NameValuePair + .TYPE_MULTIPART_DATA_PARAM; + } + + private static String getNameList(List nvp) { + return nvp.stream() + .map(org.parosproxy.paros.core.scanner.NameValuePair::getName) + .collect(Collectors.joining(",")); + } + + /** Try to work out the post data param string where we have no content type. */ + private static String guessPostParamString(HttpMessage msg, String body) { + try { + String str = JsonUtil.getJsonKeyString(body); + if (!str.isEmpty()) { + return str; + } + } catch (Exception e) { + // Ignore + } + try { + String str = XmlUtils.getXmlKeyString(msg.getRequestBody().toString()); + if (!str.isEmpty()) { + return str; + } + } catch (Exception e) { + // Ignore + } + try { + if (Strings.CI.contains(body, "Content-Disposition")) { + String[] bodyLines = body.split(HttpHeader.CRLF); + if (bodyLines.length > 2 && bodyLines[0].startsWith("--")) { + // Looking likely, we need to reform the content type + msg.getRequestHeader() + .setHeader( + HttpHeader.CONTENT_TYPE, + HttpHeader.FORM_MULTIPART_CONTENT_TYPE + + "; boundary=" + + bodyLines[0].substring(2)); + VariantMultipartFormParameters mfp = new VariantMultipartFormParameters(); + + mfp.setMessage(msg); + String str = getNameList(mfp.getParamList()); + if (!str.isEmpty()) { + return "multipart:" + str; + } + } + } + } catch (Exception e) { + // Ignore + } + return null; } private static String getQueryParamString(List list, boolean isUrlWithPostData) { @@ -697,4 +780,15 @@ private static String getQueryParamString(List list, boolean isUr return result; } + + private static HttpMessage getMsg(URI uri, String method, String postData, String contentType) + throws HttpMalformedHeaderException { + HttpMessage msg = new HttpMessage(uri); + msg.getRequestHeader().setMethod(method); + if (contentType != null) { + msg.getRequestHeader().setHeader(HttpHeader.CONTENT_TYPE, contentType); + } + msg.getRequestBody().setBody(postData); + return msg; + } } diff --git a/zap/src/main/java/org/zaproxy/zap/utils/JsonUtil.java b/zap/src/main/java/org/zaproxy/zap/utils/JsonUtil.java index 8cd3e60dec3..158e15c4f9c 100644 --- a/zap/src/main/java/org/zaproxy/zap/utils/JsonUtil.java +++ b/zap/src/main/java/org/zaproxy/zap/utils/JsonUtil.java @@ -23,10 +23,12 @@ import java.util.Iterator; import java.util.List; import net.sf.json.JSONArray; +import net.sf.json.JSONException; import net.sf.json.JSONObject; import net.sf.json.regexp.RegexpMatcher; import net.sf.json.regexp.RegexpUtils; import net.sf.json.util.JSONUtils; +import org.zaproxy.zap.model.SessionStructure; /** * Utilities to workaround "quirks" of {@link JSONObject} and related classes. @@ -76,4 +78,75 @@ public static List toStringList(JSONArray array) { } return list; } + + public static String getJsonKeyString(Object obj) throws JSONException { + StringBuilder sb = new StringBuilder(); + appendJsonKeyString(obj, sb); + if (sb.length() > SessionStructure.MAX_NODE_NAME_SIZE) { + return sb.substring(0, SessionStructure.MAX_NODE_NAME_SIZE - 3) + "..."; + } + + return sb.toString(); + } + + private static void appendJsonKeyString(Object obj, StringBuilder sb) throws JSONException { + try { + appendJsonObjectKeyString(JSONObject.fromObject(obj), sb); + } catch (JSONException e) { + appendJsonArrayKeyString(JSONArray.fromObject(obj), sb); + } + } + + private static void appendJsonObjectKeyString(JSONObject jsonObject, StringBuilder sb) { + sb.append('{'); + String prefix = ""; + for (Object key : jsonObject.keySet()) { + if (sb.length() > SessionStructure.MAX_NODE_NAME_SIZE) { + break; + } + sb.append(prefix); + prefix = ","; + Object obj = jsonObject.get(key); + sb.append(key); + if (obj instanceof JSONObject jObj) { + sb.append(":"); + appendJsonKeyString(jObj, sb); + } else if (obj instanceof JSONArray jArr) { + sb.append(":"); + appendJsonKeyString(jArr, sb); + } + } + sb.append('}'); + } + + private static void appendJsonArrayKeyString(JSONArray jsonArray, StringBuilder sb) { + sb.append('['); + String postfix = ".."; + Object[] oa = jsonArray.toArray(); + String lastChild = null; + for (int i = 0; i < oa.length; i++) { + if (sb.length() > SessionStructure.MAX_NODE_NAME_SIZE) { + break; + } + if (oa[i].getClass().isPrimitive() || oa[i] instanceof String) { + continue; + } + StringBuilder sb2 = new StringBuilder(); + appendJsonKeyString(oa[i], sb2); + + if (lastChild == null) { + lastChild = sb2.toString(); + sb.append(lastChild); + } else if (lastChild.equals(sb2.toString())) { + sb.append(postfix); + postfix = ""; + } else { + lastChild = sb2.toString(); + sb.append(','); + sb.append(lastChild); + postfix = ".."; + } + } + sb.append(']'); + } } diff --git a/zap/src/main/java/org/zaproxy/zap/utils/XmlUtils.java b/zap/src/main/java/org/zaproxy/zap/utils/XmlUtils.java index 23ec0e51a22..8b51a85ba85 100644 --- a/zap/src/main/java/org/zaproxy/zap/utils/XmlUtils.java +++ b/zap/src/main/java/org/zaproxy/zap/utils/XmlUtils.java @@ -19,8 +19,16 @@ */ package org.zaproxy.zap.utils; +import java.io.IOException; +import java.io.StringReader; +import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.zaproxy.zap.model.SessionStructure; /** A class with utility methods related to XML parsing. */ public final class XmlUtils { @@ -46,4 +54,63 @@ public static DocumentBuilderFactory newXxeDisabledDocumentBuilderFactory() factory.setExpandEntityReferences(false); return factory; } + + public static String getXmlKeyString(String xmlString) + throws ParserConfigurationException, SAXException, IOException { + StringBuilder sb = new StringBuilder(); + + DocumentBuilderFactory factory = newXxeDisabledDocumentBuilderFactory(); + DocumentBuilder builder = factory.newDocumentBuilder(); + InputSource inputSource = new InputSource(new StringReader(xmlString)); + Document document = builder.parse(inputSource); + + appendXmlKeyString(document.getFirstChild(), sb); + + if (sb.length() > SessionStructure.MAX_NODE_NAME_SIZE) { + return sb.substring(0, SessionStructure.MAX_NODE_NAME_SIZE - 3) + "..."; + } + + return sb.toString(); + } + + private static boolean ignoreChildNodes(Node node) { + return node == null + || !node.hasChildNodes() + || (node.getChildNodes().getLength() == 1 + && node.getFirstChild().getNodeType() == Node.TEXT_NODE); + } + + private static void appendXmlKeyString(Node node, StringBuilder sb) { + sb.append('<'); + sb.append(node.getNodeName()); + + if (!ignoreChildNodes(node)) { + Node ch = node.getFirstChild(); + String lastChild = null; + String postfix = ".."; + while (ch != null && sb.length() < SessionStructure.MAX_NODE_NAME_SIZE) { + if (ch.getNodeType() != Node.TEXT_NODE) { + StringBuilder sb2 = new StringBuilder(); + appendXmlKeyString(ch, sb2); + + if (lastChild == null) { + lastChild = sb2.toString(); + sb.append(':'); + sb.append(lastChild); + } else if (lastChild.equals(sb2.toString())) { + sb.append(postfix); + postfix = ""; + } else { + lastChild = sb2.toString(); + sb.append(','); + sb.append(lastChild); + postfix = ".."; + } + } + ch = ch.getNextSibling(); + } + } + + sb.append('>'); + } } diff --git a/zap/src/test/java/org/zaproxy/zap/model/SessionStructureUnitTest.java b/zap/src/test/java/org/zaproxy/zap/model/SessionStructureUnitTest.java index a64cae3cf78..c2e52d90c7e 100644 --- a/zap/src/test/java/org/zaproxy/zap/model/SessionStructureUnitTest.java +++ b/zap/src/test/java/org/zaproxy/zap/model/SessionStructureUnitTest.java @@ -310,6 +310,51 @@ void shouldReturnCorrectNameForPostWithPathWithSlashWithSameUrlAndPostParams() assertThat(nodeName, is(equalTo(uri + " (a,c)(a,c)"))); } + @Test + void shouldReturnCorrectNameForJsonPost() throws Exception { + // Given + String uri = "https://www.example.com/path/"; + msg.getRequestHeader().setMethod(HttpRequestHeader.POST); + msg.getRequestHeader().setURI(new URI(uri + "?aa=bb&cc=dd&ee=ff", true)); + msg.getRequestHeader().setHeader(HttpHeader.CONTENT_TYPE, "application/json"); + msg.setRequestBody("{\"aaa\":\"bbb\", \"ccc\":\"ddd\", \"eee\":\"fff\"}"); + // When + String nodeName = SessionStructure.getNodeName(model, msg); + // Then + assertThat(nodeName, is(equalTo(uri + " (aa,cc,ee)({aaa,ccc,eee})"))); + } + + @Test + void shouldReturnCorrectNameForXmlPost() throws Exception { + // Given + String uri = "https://www.example.com/path/"; + msg.getRequestHeader().setMethod(HttpRequestHeader.POST); + msg.getRequestHeader().setURI(new URI(uri + "?aa=bb&cc=dd&ee=ff", true)); + msg.getRequestHeader().setHeader(HttpHeader.CONTENT_TYPE, "text/xml"); + msg.setRequestBody("BBBCCCDDD"); + // When + String nodeName = SessionStructure.getNodeName(model, msg); + // Then + assertThat(nodeName, is(equalTo(uri + " (aa,cc,ee)(,,>)"))); + } + + @Test + void shouldReturnCorrectNameForMultipartPost() throws Exception { + // Given + String uri = "https://www.example.com/path/"; + msg.getRequestHeader().setMethod(HttpRequestHeader.POST); + msg.getRequestHeader().setURI(new URI(uri + "?aa=bb&cc=dd&ee=ff", true)); + String boundry = "----zaptestboundry6345896464398764398"; + msg.getRequestHeader() + .setHeader(HttpHeader.CONTENT_TYPE, "multipart/form-data; boundary=" + boundry); + msg.setRequestBody(getTestMultipartData(boundry)); + // When + String nodeName = SessionStructure.getNodeName(model, msg); + // Then + assertThat( + nodeName, is(equalTo(uri + " (aa,cc,ee)(multipart:username,file1,file2,file3)"))); + } + @Nested static class RegexGenerationTests { @@ -480,7 +525,9 @@ static class NodeNameTests { private VariantFactory factory; HttpMessage getParams; + HttpMessage getNoParams; HttpMessage postParamsFormData; + HttpMessage postNoParamsFormData; HttpMessage postParamsJsonData; HttpMessage postParamsXmlData; HttpMessage postMultipartData; @@ -495,9 +542,12 @@ void setup() throws Exception { given(model.getVariantFactory()).willReturn(factory); getParams = new HttpMessage(new URI("https://www.example.com/aaa/bbb?aa=bb&cc=dd", false)); + getNoParams = new HttpMessage(new URI("https://www.example.com/aaa/bbb", false)); postParamsFormData = getPostMsgWithFormParams( - "https://www.example.com/ccc", "aa=bb&cc=dd", "ee=ff&gg=ee"); + "https://www.example.com/ccc", "cc=dd&aa=bb", "gg=hh&ee=ff"); + postNoParamsFormData = + getPostMsgWithFormParams("https://www.example.com/ccc", "", "ee=ff&gg=ee"); postParamsJsonData = getPostMsg( "https://www.example.com/ccc", @@ -510,6 +560,16 @@ void setup() throws Exception { "aa=bb&cc=dd", "BBBCCCDDD", "text/xml"); + + String boundry = "----zaptestboundry6345896464398764398"; + + postMultipartData = + getPostMsg( + "https://www.example.com/ddd/", + "aa=bb&cc=dd", + getTestMultipartData(boundry), + "multipart/form-data; boundary=" + boundry); + Control.initSingletonForTesting(model); } @@ -523,17 +583,26 @@ void shouldGetNodeName() throws URIException { assertThat( SessionStructure.getNodeName(model, getParams), is(equalTo("https://www.example.com/aaa/bbb (aa,cc)"))); + assertThat( + SessionStructure.getNodeName(model, getNoParams), + is(equalTo("https://www.example.com/aaa/bbb"))); assertThat( SessionStructure.getNodeName(model, postParamsFormData), is(equalTo("https://www.example.com/ccc (aa,cc)(ee,gg)"))); - // FIXME should have the JSON key names + assertThat( + SessionStructure.getNodeName(model, postNoParamsFormData), + is(equalTo("https://www.example.com/ccc ()(ee,gg)"))); assertThat( SessionStructure.getNodeName(model, postParamsJsonData), - is(equalTo("https://www.example.com/ccc (aa,cc)"))); - // FIXME should have the XML key names + is(equalTo("https://www.example.com/ccc (aa,cc)({aaa,ccc,eee})"))); assertThat( SessionStructure.getNodeName(model, postParamsXmlData), - is(equalTo("https://www.example.com/ccc (aa,cc)"))); + is(equalTo("https://www.example.com/ccc (aa,cc)(,,>)"))); + assertThat( + SessionStructure.getNodeName(model, postMultipartData), + is( + equalTo( + "https://www.example.com/ddd/ (aa,cc)(multipart:username,file1,file2,file3)"))); } @Test @@ -541,35 +610,67 @@ void shouldGetLeafName1() throws URIException { assertThat( SessionStructure.getLeafName(model, "test", getParams), is(equalTo("GET:test(aa,cc)"))); + assertThat( + SessionStructure.getLeafName(model, "test", getNoParams), + is(equalTo("GET:test"))); assertThat( SessionStructure.getLeafName(model, "test", postParamsFormData), is(equalTo("POST:test(aa,cc)(ee,gg)"))); - // FIXME should have the JSON key names + assertThat( + SessionStructure.getLeafName(model, "test", postNoParamsFormData), + is(equalTo("POST:test()(ee,gg)"))); assertThat( SessionStructure.getLeafName(model, "test", postParamsJsonData), - is( - equalTo( - "POST:test(aa,cc)({\"aaa\":\"bbb\", \"ccc\":\"ddd\", \"eee\":\"fff\"})"))); - // FIXME should have the XML key names + is(equalTo("POST:test(aa,cc)({aaa,ccc,eee})"))); assertThat( SessionStructure.getLeafName(model, "test", postParamsXmlData), - is(equalTo("POST:test(aa,cc)(BBBCCCDD...)"))); + is(equalTo("POST:test(aa,cc)(,,>)"))); + assertThat( + SessionStructure.getLeafName(model, "test", postMultipartData), + is(equalTo("POST:test(aa,cc)(multipart:username,file1,file2,file3)"))); } @Test void shouldGetLeafName2() throws Exception { assertThat(getLeafName2(getParams), is(equalTo("GET:test(aa,cc)"))); + assertThat(getLeafName2(getNoParams), is(equalTo("GET:test"))); assertThat(getLeafName2(postParamsFormData), is(equalTo("POST:test(aa,cc)(ee,gg)"))); - // FIXME should have the JSON key names + assertThat(getLeafName2(postNoParamsFormData), is(equalTo("POST:test()(ee,gg)"))); assertThat( getLeafName2(postParamsJsonData), + is(equalTo("POST:test(aa,cc)({aaa,ccc,eee})"))); + assertThat( + getLeafName2(postParamsXmlData), + is(equalTo("POST:test(aa,cc)(,,>)"))); + // FIXME: Should not get the duplicated fileX fields + assertThat( + getLeafName2(postMultipartData), is( equalTo( - "POST:test(aa,cc)({\"aaa\":\"bbb\", \"ccc\":\"ddd\", \"eee\":\"fff\"})"))); - // FIXME should have the XML key names + "POST:test(aa,cc)(multipart:username,file1,file1,file1,file2,file2,file2,file3,file3,file3)"))); + } + + @Test + void shouldHandleInvalidXmlName() throws URIException { + // Given / When + HttpMessage msg = + getPostMsg("https://www.example.com/ccc", "aa=bb&cc=dd", "BBBCCCDD...)"))); + SessionStructure.getNodeName(model, msg), + is(equalTo("https://www.example.com/ccc (aa,cc)(HTML file content.", + "", + "--" + boundary, + "Content-Disposition: form-data; name=\"file3\"; filename=\"a.json\"", + "Content-Type: application/json", + "", + "{\"age\":30,\"location\":\"New York\"}", + "--" + boundary + "--"); + } + public static final class PathTreeVariant implements Variant { private final List expectedTreePath; diff --git a/zap/src/test/java/org/zaproxy/zap/utils/JsonUtilUnitTest.java b/zap/src/test/java/org/zaproxy/zap/utils/JsonUtilUnitTest.java index d9d164f113a..05dc6e22214 100644 --- a/zap/src/test/java/org/zaproxy/zap/utils/JsonUtilUnitTest.java +++ b/zap/src/test/java/org/zaproxy/zap/utils/JsonUtilUnitTest.java @@ -19,12 +19,16 @@ */ package org.zaproxy.zap.utils; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.Collections; +import net.sf.json.JSONException; import net.sf.json.JSONObject; import org.junit.jupiter.api.Test; +import org.zaproxy.zap.model.SessionStructure; /** Unit test for {@link JsonUtil}. */ class JsonUtilUnitTest { @@ -139,6 +143,86 @@ void shouldNotQuoteFunctionHeader() { assertValueAfterJsonObject(processedValue, value); } + @Test + void shouldReturnKeyStrForJsonObject() { + // Given + String json = + """ + {"aaa":"bbb", "ccc":"ddd", "eee":"fff"} + """; + + // When + String res = JsonUtil.getJsonKeyString(json); + + // Then + assertThat(res, is(equalTo("{aaa,ccc,eee}"))); + } + + @Test + void shouldReturnKeyStrForJsonArray() { + // Given + String json = """ + ["aaa", "bbb", "ccc"] + """; + + // When + String res = JsonUtil.getJsonKeyString(json); + + // Then + assertThat(res, is(equalTo("[]"))); + } + + @Test + void shouldReturnKeyStrForDeepJsonObject() { + // Given + String json = + """ + {"aaa":{"bbb": "ccc", "ddd": "eee"}, fff: ["kkk", {"ggg":"hhh"}, {"iii":"jjj"}]} + """; + + // When + String res = JsonUtil.getJsonKeyString(json); + + // Then + assertThat(res, is(equalTo("{aaa:{bbb,ddd},fff:[{ggg},{iii}]}"))); + } + + @Test + void shouldReturnStrForMultipleMatchingChildNodesInJson() { + // given + String substr = "{\"aaa\":{\"bbb\": \"ccc\", \"ddd\": \"eee\"}}"; + String json = "[" + String.join(",", Collections.nCopies(5, substr)) + "]"; + + // When + String res = JsonUtil.getJsonKeyString(json); + + // Then + assertThat(res, is(equalTo("[{aaa:{bbb,ddd}}..]"))); + } + + @Test + void shouldReturnTrimmedStrForVeryLongJson() { + // given + String aa = "{\"aaaaaaaaaa\": \"a\"}"; + String bb = "{\"bbbbbbbbbb\": \"b\"}"; + String aabbbbaa = "[" + aa + "," + bb + "],[" + bb + "," + aa + "]"; + String json = "{\"cccc\": [" + String.join(",", Collections.nCopies(10, aabbbbaa)) + "]}"; + String expectedStr = "[{aaaaaaaaaa},{bbbbbbbbbb}],[{bbbbbbbbbb},{aaaaaaaaaa}]"; + String expectedName = "{cccc:[" + String.join(",", Collections.nCopies(10, expectedStr)); + // when + String res = JsonUtil.getJsonKeyString(json); + // then + assertThat( + res, + is(expectedName.substring(0, SessionStructure.MAX_NODE_NAME_SIZE - 3) + "...")); + } + + @Test + void shouldThrowExceptionForInvalidJson() throws Exception { + // given / when / then + assertThrows(JSONException.class, () -> JsonUtil.getJsonKeyString("{")); + } + private static void assertValueAfterJsonObject(String processedValue, String value) { // Given processedValue String key = "key"; diff --git a/zap/src/test/java/org/zaproxy/zap/utils/XmlUtilsUnitTest.java b/zap/src/test/java/org/zaproxy/zap/utils/XmlUtilsUnitTest.java new file mode 100644 index 00000000000..ac66122bc69 --- /dev/null +++ b/zap/src/test/java/org/zaproxy/zap/utils/XmlUtilsUnitTest.java @@ -0,0 +1,275 @@ +/* + * 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.utils; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Collections; +import org.junit.jupiter.api.Test; +import org.xml.sax.SAXParseException; +import org.zaproxy.zap.model.SessionStructure; + +class XmlUtilsUnitTest { + + @Test + void shouldReturnStrForEmptyXml() throws Exception { + // given + String xml = ""; + // when + String result = XmlUtils.getXmlKeyString(xml); + // then + assertThat(result, is("")); + } + + @Test + void shouldReturnStrForSimpleXml() throws Exception { + // given + String xml = "BBBCCCDDD"; + // when + String result = XmlUtils.getXmlKeyString(xml); + // then + assertThat(result, is(",,>")); + } + + @Test + void shouldReturnStrForComplexElementXml() throws Exception { + // given + String xml = + "It happened on 03.03.99 etc "; + // when + String result = XmlUtils.getXmlKeyString(xml); + // then + assertThat(result, is(">")); + } + + @Test + void shouldReturnStrForDeeperDuplicatedXml() throws Exception { + // given + String xml = + """ + + + Belgian Waffles + $5.95 + + Two of our famous Belgian Waffles with plenty of real maple syrup + + 650 + + + Strawberry Belgian Waffles + $7.95 + + Light Belgian waffles covered with strawberries and whipped cream + + 900 + + + Berry-Berry Belgian Waffles + $8.95 + + Light Belgian waffles covered with an assortment of fresh berries and whipped cream + + 900 + + + French Toast + $4.50 + + Thick slices made from our homemade sourdough bread + + 600 + + + Homestyle Breakfast + $6.95 + + Two eggs, bacon or sausage, toast, and our ever-popular hash browns + + 950 + + + """; + // when + String result = XmlUtils.getXmlKeyString(xml); + // then + assertThat(result, is(",,,>..>")); + } + + @Test + void shouldReturnStrForEvenDeeperDuplicatedXml() throws Exception { + // given + // c/o + // https://learn.microsoft.com/en-us/dotnet/standard/linq/sample-xml-file-multiple-purchase-orders + String xml = + """ + + + +
+ Ellen Adams + 123 Maple Street + Mill Valley + CA + 10999 + USA +
+
+ Tai Yee + 8 Oak Avenue + Old Town + PA + 95819 + USA +
+ Please leave packages in shed by driveway. + + + Lawnmower + 1 + 148.95 + Confirm this is electric + + + Baby Monitor + 2 + 39.98 + 1999-05-21 + + +
+ +
+ Cristian Osorio + 456 Main Street + Buffalo + NY + 98112 + USA +
+
+ Cristian Osorio + 456 Main Street + Buffalo + NY + 98112 + USA +
+ Please notify me before shipping. + + + Power Supply + 1 + 45.99 + + +
+ +
+ Jessica Arnold + 4055 Madison Ave + Seattle + WA + 98112 + USA +
+
+ Jessica Arnold + 4055 Madison Ave + Buffalo + NY + 98112 + USA +
+ + + Computer Keyboard + 1 + 29.99 + + + Wireless Mouse + 1 + 14.99 + + +
+
+ """; + // when + String result = XmlUtils.getXmlKeyString(xml); + // then + assertThat( + result, + is( + ",,,,,>..," + + ",,,,>," + + ",,,>>>," + + ",,,,,>..," + + ",,,>>>," + + ",,,,,>..," + + ",,>..>>>")); + } + + @Test + void shouldReturnStrForMultipleMatchingChildNodesInXml() throws Exception { + // given + String xml = + "" + + "BBBAA" + + "CCCC1" + + "DDDC2" + + "DDDC3" + + "EEEB1" + + "FFFB2" + + ""; + // when + String result = XmlUtils.getXmlKeyString(xml); + // then + assertThat(result, is(">,>..,>..>")); + } + + @Test + void shouldReturnTrimmedStrForVeryLongXml() throws Exception { + // given + String cp = + "" + + ""; + String xml = "" + String.join("", Collections.nCopies(10, cp)) + ""; + String expectedCp = ">,>,"; + String expectedXml = " XmlUtils.getXmlKeyString("