diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideComponent.java b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideComponent.java index a994256cd74..43781f9000a 100644 --- a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideComponent.java +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideComponent.java @@ -149,7 +149,21 @@ public static Type getTypeForKey(String key) { public ClientSideComponent(JSONObject json) { data = new HashMap<>(); for (Object key : json.keySet()) { - data.put(key.toString(), json.get(key).toString()); + String keyStr = key.toString(); + Object value = json.get(key); + + if ("ariaIdentification".equals(keyStr)) { + if (value instanceof JSONObject) { + JSONObject ariaObj = (JSONObject) value; + Map ariaMap = new HashMap<>(); + for (Object ariaKey : ariaObj.keySet()) { + ariaMap.put(ariaKey.toString(), ariaObj.getString(ariaKey.toString())); + } + data.put("ariaIdentification", ariaMapToString(ariaMap)); + } + } else { + data.put(keyStr, value.toString()); + } } this.tagName = json.getString("tagName"); @@ -279,4 +293,12 @@ private static int nullCompare(Object here, Object other) { } return 1; } + + private static String ariaMapToString(Map ariaMap) { + JSONObject json = new JSONObject(); + for (Map.Entry entry : ariaMap.entrySet()) { + json.put(entry.getKey(), entry.getValue()); + } + return json.toString(); + } } diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ReportedElement.java b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ReportedElement.java index 91c172b17b2..db7289c5a43 100644 --- a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ReportedElement.java +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ReportedElement.java @@ -19,19 +19,35 @@ */ package org.zaproxy.addon.client.internal; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import net.sf.json.JSONObject; public class ReportedElement extends ReportedObject { private String tagType; private int formId = -1; + private String role; + private Map ariaIdentification; public ReportedElement(JSONObject json) { super(json); this.tagType = getParam(json, "tagType"); + this.role = getParam(json, "role"); if (json.containsKey("formId")) { this.formId = json.getInt("formId"); } + + if (json.containsKey("ariaIdentification") + && !json.get("ariaIdentification").equals(null)) { + JSONObject ariaObj = json.getJSONObject("ariaIdentification"); + this.ariaIdentification = new HashMap<>(); + for (Object key : ariaObj.keySet()) { + String keyStr = (String) key; + this.ariaIdentification.put(keyStr, ariaObj.getString(keyStr)); + } + } } public String getTagType() { @@ -41,4 +57,12 @@ public String getTagType() { public int getFormId() { return formId; } + + public String getRole() { + return role; + } + + public Map getAriaIdentification() { + return ariaIdentification != null ? Collections.unmodifiableMap(ariaIdentification) : null; + } } diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/spider/actions/ClickElement.java b/addOns/client/src/main/java/org/zaproxy/addon/client/spider/actions/ClickElement.java index 423bb6d6b05..b3a5677526b 100644 --- a/addOns/client/src/main/java/org/zaproxy/addon/client/spider/actions/ClickElement.java +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/spider/actions/ClickElement.java @@ -19,9 +19,13 @@ */ package org.zaproxy.addon.client.spider.actions; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Predicate; +import net.sf.json.JSONObject; import org.apache.commons.httpclient.URI; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; @@ -38,6 +42,26 @@ public class ClickElement extends BaseElementAction { private static final String STATS_PREFIX = "stats.client.spider.action.click"; + private static final List INTERACTIVE_ARIA_ROLES = + Arrays.asList( + "button", + "link", + "checkbox", + "radio", + "switch", + "tab", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "option", + "treeitem", + "combobox", + "listbox", + "slider", + "spinbutton", + "searchbox", + "textbox"); + private final Map elementData; private final String tagName; @@ -78,18 +102,61 @@ private static By getBy(Map data) { } String tag = getTagName(data); + String ariaString = data.get("ariaIdentification"); + if (StringUtils.isNotBlank(ariaString)) { + Map ariaAttrs = parseAriaIdentification(ariaString); + + if (!ariaAttrs.isEmpty()) { + String role = data.get("role"); + StringBuilder xpathBuilder = + new StringBuilder("//").append(StringUtils.isNotBlank(tag) ? tag : "*"); + appendXpathAttribute(xpathBuilder, "role", role); + ariaAttrs.forEach((key, value) -> appendXpathAttribute(xpathBuilder, key, value)); + return By.xpath(xpathBuilder.toString()); + } + } + String text = data.get("text"); if ("INPUT".equalsIgnoreCase(tag)) { - return By.xpath("//" + tag + "[@value='" + text + "']"); + return By.xpath("//" + tag + "[@value=" + escapeXpathValue(text) + "]"); } if (StringUtils.isNotBlank(text)) { - return By.xpath("//" + tag + "[contains(text(), '" + text + "')]"); + return By.xpath("//" + tag + "[contains(text(), " + escapeXpathValue(text) + ")]"); } return By.tagName(tag); } + private static void appendXpathAttribute(StringBuilder builder, String name, String value) { + if (StringUtils.isNotBlank(value)) { + builder.append("[@") + .append(name) + .append("=") + .append(escapeXpathValue(value)) + .append("]"); + } + } + + private static String escapeXpathValue(String value) { + if (!value.contains("'")) { + return "'" + value + "'"; + } + if (!value.contains("\"")) { + return "\"" + value + "\""; + } + StringBuilder result = new StringBuilder("concat("); + String[] parts = value.split("'", -1); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + result.append(", \"'\", "); + } + result.append("'").append(parts[i]).append("'"); + } + result.append(")"); + return result.toString(); + } + public static boolean isSupported(Predicate scopeChecker, Map data) { String tag = getTagName(data); if (tag == null) { @@ -110,7 +177,25 @@ public static boolean isSupported(Predicate scopeChecker, Map parseAriaIdentification(String ariaString) { + Map result = new HashMap<>(); + if (ariaString == null || ariaString.isEmpty()) { + return result; + } + try { + JSONObject json = JSONObject.fromObject(ariaString); + for (Object key : json.keySet()) { + result.put(key.toString(), json.getString(key.toString())); + } + } catch (Exception e) { + LOGGER.debug("Failed to parse ariaIdentification: {}", ariaString, e); } + return result; } } diff --git a/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponentUnitTest.java b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponentUnitTest.java index 30171d990b3..3dd61b1e077 100644 --- a/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponentUnitTest.java +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponentUnitTest.java @@ -31,6 +31,7 @@ import java.util.SortedSet; import java.util.TreeSet; import java.util.stream.Stream; +import net.sf.json.JSONObject; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -460,4 +461,47 @@ void shouldCompareFormIdAsExpected(int first, int second, int expected) { // Then assertThat(actual, is(equalTo(expected))); } + + @Test + void shouldSerializeAriaIdentificationToJsonString() { + // Given + JSONObject json = new JSONObject(); + json.put("tagName", "DIV"); + json.put("id", ""); + json.put("url", EXAMPLE_URL); + json.put("type", "nodeAdded"); + + JSONObject ariaObj = new JSONObject(); + ariaObj.put("role", "button"); + ariaObj.put("aria-label", "Submit"); + ariaObj.put("aria-pressed", "false"); + json.put("ariaIdentification", ariaObj); + + // When + ClientSideComponent component = new ClientSideComponent(json); + + // Then + String ariaString = component.getData().get("ariaIdentification"); + assertThat(ariaString.contains("role"), is(true)); + assertThat(ariaString.contains("button"), is(true)); + assertThat(ariaString.contains("aria-label"), is(true)); + assertThat(ariaString.contains("Submit"), is(true)); + } + + @Test + void shouldSkipNullAriaIdentification() { + // Given + JSONObject json = new JSONObject(); + json.put("tagName", "DIV"); + json.put("id", "test-id"); + json.put("url", EXAMPLE_URL); + json.put("type", "nodeAdded"); + // No ariaIdentification + + // When + ClientSideComponent component = new ClientSideComponent(json); + + // Then - ariaIdentification should not be in data map + assertThat(component.getData().containsKey("ariaIdentification"), is(false)); + } } diff --git a/addOns/client/src/test/java/org/zaproxy/addon/client/spider/actions/ClickElementUnitTest.java b/addOns/client/src/test/java/org/zaproxy/addon/client/spider/actions/ClickElementUnitTest.java new file mode 100644 index 00000000000..8d77c7368d7 --- /dev/null +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/spider/actions/ClickElementUnitTest.java @@ -0,0 +1,140 @@ +/* + * 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.addon.client.spider.actions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import java.util.HashMap; +import java.util.Map; +import net.sf.json.JSONObject; +import org.junit.jupiter.api.Test; + +/** Unit Tests for {@code ClickElement} */ +class ClickElementUnitTest { + + @Test + void shouldSupportElementWithInteractiveAriaRole() { + // Given + Map data = new HashMap<>(); + data.put("tagName", "DIV"); + data.put("id", ""); + data.put("role", "button"); + JSONObject ariaObj = new JSONObject(); + ariaObj.put("aria-label", "Submit"); + data.put("ariaIdentification", ariaObj.toString()); + + // When + boolean supported = ClickElement.isSupported(href -> true, data); + + // Then + assertThat(supported, is(true)); + } + + @Test + void shouldSupportElementWithIdAndRole() { + // Given + Map data = new HashMap<>(); + data.put("tagName", "DIV"); + data.put("id", "my-aria-button"); + data.put("role", "button"); + + // When + boolean supported = ClickElement.isSupported(href -> true, data); + + // Then + assertThat(supported, is(true)); + } + + @Test + void shouldNotSupportElementWithOnlyAriaAttribute() { + // Given + Map data = new HashMap<>(); + data.put("tagName", "DIV"); + data.put("id", ""); + JSONObject ariaObj = new JSONObject(); + ariaObj.put("aria-pressed", "false"); + data.put("ariaIdentification", ariaObj.toString()); + + // When + boolean supported = ClickElement.isSupported(href -> true, data); + + // Then + assertThat(supported, is(false)); + } + + @Test + void shouldNotSupportElementWithoutAriaRoleOrAttribute() { + // Given + Map data = new HashMap<>(); + data.put("tagName", "DIV"); + data.put("id", "test-id"); + + // When + boolean supported = ClickElement.isSupported(href -> true, data); + + // Then + assertThat(supported, is(false)); + } + + @Test + void shouldSupportStandardButton() { + // Given + Map data = new HashMap<>(); + data.put("tagName", "BUTTON"); + data.put("id", "btn-id"); + + // When + boolean supported = ClickElement.isSupported(href -> true, data); + + // Then + assertThat(supported, is(true)); + } + + @Test + void shouldSupportStandardLink() { + // Given + Map data = new HashMap<>(); + data.put("tagName", "A"); + data.put("id", "link-id"); + data.put("href", "https://example.com"); + + // When + boolean supported = ClickElement.isSupported(href -> true, data); + + // Then + assertThat(supported, is(true)); + } + + @Test + void shouldNotSupportLinkOutOfScope() { + // Given + Map data = new HashMap<>(); + data.put("tagName", "A"); + data.put("id", "link-id"); + data.put("href", "https://example.com"); + + // When + boolean supported = ClickElement.isSupported(href -> false, data); + + // Then + assertThat(supported, is(false)); + } +} diff --git a/addOns/dev/src/main/java/org/zaproxy/addon/dev/TestProxyServer.java b/addOns/dev/src/main/java/org/zaproxy/addon/dev/TestProxyServer.java index 045e6d1e03e..39dbba0d423 100644 --- a/addOns/dev/src/main/java/org/zaproxy/addon/dev/TestProxyServer.java +++ b/addOns/dev/src/main/java/org/zaproxy/addon/dev/TestProxyServer.java @@ -119,9 +119,11 @@ public TestProxyServer(ExtensionDev extension, ExtensionNetwork extensionNetwork TestDirectory elStoreDir = new TestDirectory(this, "elements"); TestDirectory locStoreDir = new TestDirectory(this, "localStorage"); TestDirectory sessStoreDir = new TestDirectory(this, "sessionStorage"); + TestDirectory ariaDir = new TestDirectory(this, "aria"); htmlDir.addDirectory(elStoreDir); htmlDir.addDirectory(locStoreDir); htmlDir.addDirectory(sessStoreDir); + htmlDir.addDirectory(ariaDir); TestDirectory seqDir = new TestDirectory(this, "seq"); seqDir.addDirectory(new PerformanceDir(this, "performance")); diff --git a/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/index.html b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/index.html new file mode 100644 index 00000000000..bce8157ecd3 --- /dev/null +++ b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/index.html @@ -0,0 +1,23 @@ + + + + ZAP Test Server - ARIA Elements + + + +
+

ZAP Test Server

+

ARIA Elements Tests

+ + + + +
+ + + diff --git a/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/navigation.html b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/navigation.html new file mode 100644 index 00000000000..683e750f18b --- /dev/null +++ b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/navigation.html @@ -0,0 +1,62 @@ + + + + ZAP Test Server - ARIA Navigation + + + + +
+

ZAP Test Server

+

ARIA Navigation Test

+

This page contains navigation elements that use ARIA roles instead of standard HTML links and buttons. + The client spider cannot crawl to the target pages without ARIA detection support.

+ + + + +
+ + + diff --git a/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page1.html b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page1.html new file mode 100644 index 00000000000..d6359f8732a --- /dev/null +++ b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page1.html @@ -0,0 +1,20 @@ + + + + ZAP Test Server - ARIA Page 1 + + + +
+

Page 1 - Accessed via ARIA Link

+

Congratulations! You reached this page through an ARIA role="link" element.

+

This page is only accessible if the client spider properly detects and interacts with + elements that have the ARIA role="link" attribute.

+ + +
+ + + diff --git a/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page2.html b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page2.html new file mode 100644 index 00000000000..10f8e3f395c --- /dev/null +++ b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page2.html @@ -0,0 +1,19 @@ + + + + ZAP Test Server - ARIA Page 2 + + + +
+

Page 2 - Accessed via ARIA Link

+

Congratulations! You reached this page through an ARIA role="link" element.

+

This page demonstrates that div elements with role="link" are properly crawled.

+ + +
+ + + diff --git a/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page3.html b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page3.html new file mode 100644 index 00000000000..fc3e5b9a28b --- /dev/null +++ b/addOns/dev/src/main/zapHomeFiles/dev-add-on/html/aria/page3.html @@ -0,0 +1,20 @@ + + + + ZAP Test Server - ARIA Page 3 + + + +
+

Page 3 - Accessed via ARIA Button

+

Congratulations! You reached this page through an ARIA role="button" element.

+

This page is only accessible if the client spider properly detects and clicks + elements that have the ARIA role="button" attribute.

+ + +
+ + +