From 981fda9f314bf9a19dce2da7ba19f7a78a052e3d Mon Sep 17 00:00:00 2001 From: ricekot Date: Sun, 4 May 2025 00:23:27 +0530 Subject: [PATCH] graphql: Add Circular Type Reference Detection Signed-off-by: ricekot --- addOns/graphql/CHANGELOG.md | 4 +- .../addon/graphql/ExtensionGraphQl.java | 3 +- .../org/zaproxy/addon/graphql/GraphQlApi.java | 51 +- .../addon/graphql/GraphQlCycleDetector.java | 466 ++++++++++++++++++ .../addon/graphql/GraphQlFingerprinter.java | 9 +- .../addon/graphql/GraphQlGenerator.java | 18 +- .../addon/graphql/GraphQlOptionsPanel.java | 140 +++--- .../zaproxy/addon/graphql/GraphQlParam.java | 138 ++++-- .../zaproxy/addon/graphql/GraphQlParser.java | 40 +- .../graphql/GraphQlQueryMessageBuilder.java | 99 ++++ .../org/zaproxy/addon/graphql/Requestor.java | 99 +--- .../addon/graphql/automation/GraphQlJob.java | 10 +- .../graphql/automation/GraphQlJobDialog.java | 186 +++---- .../resources/help/contents/alerts.html | 8 + .../resources/help/contents/automation.html | 2 + .../graphql/resources/Messages.properties | 25 +- .../addon/graphql/resources/graphql-max.yaml | 2 + .../graphql/GraphQlCycleDetectorUnitTest.java | 160 ++++++ .../graphql/GraphQlFingerprinterUnitTest.java | 29 +- .../graphql/GraphQlGeneratorUnitTest.java | 18 +- .../addon/graphql/GraphQlParamUnitTest.java | 50 +- .../addon/graphql/GraphQlParserUnitTest.java | 11 +- .../automation/GraphQlJobUnitTest.java | 4 +- 23 files changed, 1202 insertions(+), 370 deletions(-) create mode 100644 addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlCycleDetector.java create mode 100644 addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlQueryMessageBuilder.java create mode 100644 addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlCycleDetectorUnitTest.java diff --git a/addOns/graphql/CHANGELOG.md b/addOns/graphql/CHANGELOG.md index 8c54f6093be..7965b605c6c 100644 --- a/addOns/graphql/CHANGELOG.md +++ b/addOns/graphql/CHANGELOG.md @@ -4,7 +4,9 @@ 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/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased - +### Added +- GraphQL Cycle detection: Imported schemas are processed for circular type references, and an alert is created for each unique circular relationship that is found. + The cycle detection exhaustiveness and the maximum number of alerts raised are configurable. ## [0.28.0] - 2025-03-26 ### Fixed diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/ExtensionGraphQl.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/ExtensionGraphQl.java index eb871d137ad..c44977eab6c 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/ExtensionGraphQl.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/ExtensionGraphQl.java @@ -307,6 +307,7 @@ public List getExampleAlerts() { GraphQlParser.createIntrospectionAlert().build(), GraphQlFingerprinter.createFingerprintingAlert( new DiscoveredGraphQlEngine("example", uri)) - .build()); + .build(), + GraphQlCycleDetector.getExampleAlert()); } } diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlApi.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlApi.java index c72e7238598..77aa39a215c 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlApi.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlApi.java @@ -29,6 +29,7 @@ import org.zaproxy.zap.extension.api.ApiImplementor; import org.zaproxy.zap.extension.api.ApiResponse; import org.zaproxy.zap.extension.api.ApiResponseElement; +import org.zaproxy.zap.extension.api.ApiView; public class GraphQlApi extends ApiImplementor { @@ -39,14 +40,16 @@ public class GraphQlApi extends ApiImplementor { private static final String PARAM_URL = "url"; private static final String PARAM_ENDPOINT = "endurl"; + private static final String OPTION_ARGS_TYPE = "optionArgsType"; + private static final String OPTION_CYCLE_DETECTION_MODE = "optionCycleDetectionMode"; + private static final String OPTION_QUERY_SPLIT_TYPE = "optionQuerySplitType"; + private static final String OPTION_REQUEST_METHOD = "optionRequestMethod"; + + private final GraphQlParam options; + + /** Provided only for API client generator usage. */ public GraphQlApi() { - this.addApiAction( - new ApiAction(ACTION_IMPORT_FILE, new String[] {PARAM_ENDPOINT, PARAM_FILE})); - this.addApiAction( - new ApiAction( - ACTION_IMPORT_URL, - new String[] {PARAM_ENDPOINT}, - new String[] {PARAM_URL})); + this(null); } /** @@ -55,8 +58,19 @@ public GraphQlApi() { * @param options the options that will be exposed through the API. */ public GraphQlApi(GraphQlParam options) { - this(); + this.addApiAction( + new ApiAction(ACTION_IMPORT_FILE, new String[] {PARAM_ENDPOINT, PARAM_FILE})); + this.addApiAction( + new ApiAction( + ACTION_IMPORT_URL, + new String[] {PARAM_ENDPOINT}, + new String[] {PARAM_URL})); + this.addApiView(new ApiView(OPTION_ARGS_TYPE)); + this.addApiView(new ApiView(OPTION_CYCLE_DETECTION_MODE)); + this.addApiView(new ApiView(OPTION_QUERY_SPLIT_TYPE)); + this.addApiView(new ApiView(OPTION_REQUEST_METHOD)); addApiOptions(options); + this.options = options; } @Override @@ -80,6 +94,27 @@ public ApiResponse handleApiAction(String name, JSONObject params) throws ApiExc return ApiResponseElement.OK; } + @Override + public ApiResponse handleApiOptionView(String name, JSONObject params) throws ApiException { + if (this.options == null) { + return null; + } + return switch (name) { + case OPTION_ARGS_TYPE -> + new ApiResponseElement(OPTION_ARGS_TYPE, options.getArgsType().name()); + case OPTION_CYCLE_DETECTION_MODE -> + new ApiResponseElement( + OPTION_CYCLE_DETECTION_MODE, options.getCycleDetectionMode().name()); + case OPTION_QUERY_SPLIT_TYPE -> + new ApiResponseElement( + OPTION_QUERY_SPLIT_TYPE, options.getQuerySplitType().name()); + case OPTION_REQUEST_METHOD -> + new ApiResponseElement( + OPTION_REQUEST_METHOD, options.getRequestMethod().name()); + default -> super.handleApiOptionView(name, params); + }; + } + private void importFile(JSONObject params) throws ApiException { try { GraphQlParser parser = diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlCycleDetector.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlCycleDetector.java new file mode 100644 index 00000000000..2ccae5f48d9 --- /dev/null +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlCycleDetector.java @@ -0,0 +1,466 @@ +/* + * 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.graphql; + +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLSchema; +import graphql.schema.GraphQLType; +import graphql.schema.GraphQLTypeUtil; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import net.sf.json.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.parosproxy.paros.Constant; +import org.parosproxy.paros.control.Control; +import org.parosproxy.paros.core.scanner.Alert; +import org.zaproxy.addon.commonlib.CommonAlertTag; +import org.zaproxy.zap.extension.alert.ExtensionAlert; + +public class GraphQlCycleDetector { + + private static final Logger LOGGER = LogManager.getLogger(GraphQlCycleDetector.class); + private static final String CYCLES_ALERT_REF = ExtensionGraphQl.TOOL_ALERT_ID + "-3"; + private static final Map CYCLES_ALERT_TAGS = + CommonAlertTag.mergeTags( + Map.of( + "OWASP_2023_API4", + "https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/"), + CommonAlertTag.OWASP_2021_A04_INSECURE_DESIGN, + CommonAlertTag.WSTG_V42_APIT_01_GRAPHQL); + private static final CycleDetectionCompleteException CYCLE_DETECTION_COMPLETE_EXCEPTION = + new CycleDetectionCompleteException(); + + private final GraphQLSchema schema; + private final GraphQlGenerator generator; + private final GraphQlQueryMessageBuilder queryMsgBuilder; + private final GraphQlParam param; + private final Map typeNodeMap; + + public GraphQlCycleDetector( + GraphQLSchema schema, + GraphQlGenerator generator, + GraphQlQueryMessageBuilder queryMsgBuilder, + GraphQlParam param) { + this.schema = schema; + this.generator = generator; + this.queryMsgBuilder = queryMsgBuilder; + this.param = param; + this.typeNodeMap = buildTypeGraph(schema); + } + + public void detectCycles() { + if (param.getCycleDetectionMode() == GraphQlParam.CycleDetectionModeOption.DISABLED + || param.getMaxCycleDetectionAlerts() == 0) { + return; + } + AtomicInteger cycleCount = new AtomicInteger(); + try { + detectCycles( + result -> { + raiseAlert(result); + if (cycleCount.incrementAndGet() >= param.getMaxCycleDetectionAlerts()) { + throw CYCLE_DETECTION_COMPLETE_EXCEPTION; + } + }); + } catch (CycleDetectionCompleteException ignored) { + } + } + + void detectCycles(Consumer cycleResultConsumer) { + List allNodes = + typeNodeMap.values().stream() + .sorted(Comparator.comparingInt(node -> node.neighbors.size())) + .toList(); + List currentSCCs = findStronglyConnectedComponents(allNodes); + Consumer cycleConsumer = + cycle -> buildCycleDetectionResult(cycle).ifPresent(cycleResultConsumer); + for (int i = 0; i < allNodes.size(); i++) { + Node startNode = allNodes.get(i); + + if (currentSCCs.stream() + .noneMatch(scc -> scc.nodes.contains(startNode) && scc.nodes.size() > 1)) { + continue; + } + + findCyclesInSCC(startNode, cycleConsumer); + + for (int j = i + 1; j < allNodes.size(); j++) { + Node node = allNodes.get(j); + node.index = -1; + node.lowLink = -1; + node.onStack = false; + node.neighbors.remove(startNode); + } + + currentSCCs = findStronglyConnectedComponents(allNodes.subList(i + 1, allNodes.size())); + } + } + + private static Map buildTypeGraph(GraphQLSchema schema) { + // Create a node for each object type and add it to the graph + Map graph = + schema.getAllTypesAsList().stream() + .filter(type -> !type.getName().startsWith("__")) // Ignore reserved types + .filter(GraphQLObjectType.class::isInstance) + .map(GraphQLObjectType.class::cast) + .collect(Collectors.toUnmodifiableMap(Function.identity(), Node::new)); + // Draw edges between nodes + for (Map.Entry sourceEntry : graph.entrySet()) { + for (GraphQLFieldDefinition field : sourceEntry.getKey().getFieldDefinitions()) { + GraphQLType fieldType = GraphQLTypeUtil.unwrapAll(field.getType()); + if (fieldType instanceof GraphQLObjectType targetType + && graph.containsKey(targetType)) { + sourceEntry.getValue().neighbors.add(graph.get(targetType)); + } + } + } + return graph; + } + + private List findStronglyConnectedComponents(List nodes) { + // Use Tarjan's algorithm to find strongly connected components in the graph, ref: + // https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm + List sccs = new ArrayList<>(); + Deque stack = new ArrayDeque<>(); + AtomicInteger index = new AtomicInteger(0); + for (Node node : nodes) { + if (node.index == -1) { + strongConnect(node, stack, index, sccs); + } + } + return sccs; + } + + private void strongConnect(Node node, Deque stack, AtomicInteger index, List sccs) { + node.index = index.get(); + node.lowLink = index.get(); + index.incrementAndGet(); + stack.push(node); + node.onStack = true; + + for (Node neighbor : node.neighbors) { + if (neighbor.index == -1) { + strongConnect(neighbor, stack, index, sccs); + node.lowLink = Math.min(node.lowLink, neighbor.lowLink); + } else if (neighbor.onStack) { + node.lowLink = Math.min(node.lowLink, neighbor.index); + } + } + + if (node.lowLink == node.index) { + List sccNodes = new ArrayList<>(); + Node w; + do { + w = stack.pop(); + w.onStack = false; + sccNodes.add(0, w); + } while (w != node); + + if (sccNodes.size() > 1) { + sccs.add(new SCC(sccNodes)); + } + } + } + + private void findCyclesInSCC(Node startNode, Consumer cycleConsumer) { + // Use Johnson's algorithm to find cycles in a strongly connected component, ref: + // https://github.com/mission-peace/interview/blob/master/src/com/interview/graph/AllCyclesInDirectedGraphJohnson.java + Set blockedNodes = new HashSet<>(); + Map> unblockDependencies = new HashMap<>(); + Deque stack = new ArrayDeque<>(); + findCyclesFromNode( + startNode, startNode, blockedNodes, unblockDependencies, stack, cycleConsumer); + } + + private boolean findCyclesFromNode( + Node currentNode, + Node startNode, + Set blockedNodes, + Map> unblockDependencies, + Deque stack, + Consumer cycleConsumer) { + boolean foundCycle = false; + stack.push(currentNode); + blockedNodes.add(currentNode); + for (Node neighbor : currentNode.neighbors) { + if (neighbor == startNode) { + // Found a cycle + cycleConsumer.accept(new Cycle(new ArrayList<>(stack))); + foundCycle = true; + if (param.getCycleDetectionMode() == GraphQlParam.CycleDetectionModeOption.QUICK) { + return true; + } + } else if (!blockedNodes.contains(neighbor)) { + foundCycle |= + findCyclesFromNode( + neighbor, + startNode, + blockedNodes, + unblockDependencies, + stack, + cycleConsumer); + } + } + if (foundCycle) { + unblockNodeAndDependents(currentNode, blockedNodes, unblockDependencies); + } else { + for (Node neighbor : currentNode.neighbors) { + unblockDependencies + .computeIfAbsent(neighbor, k -> new HashSet<>()) + .add(currentNode); + } + } + stack.pop(); + return foundCycle; + } + + private void unblockNodeAndDependents( + Node nodeToUnblock, Set blockedNodes, Map> unblockDependencies) { + blockedNodes.remove(nodeToUnblock); + if (unblockDependencies.containsKey(nodeToUnblock)) { + unblockDependencies + .get(nodeToUnblock) + .forEach( + node -> { + if (blockedNodes.contains(node)) { + unblockNodeAndDependents( + node, blockedNodes, unblockDependencies); + } + }); + unblockDependencies.remove(nodeToUnblock); + } + } + + private List findShortestPathToCycle(Cycle cycle) { + Set cycleNodes = new HashSet<>(cycle.nodes); + Queue queue = new LinkedList<>(); + Set visited = new HashSet<>(); + + addRootTypesToQueue(queue, visited); + + while (!queue.isEmpty()) { + PathInfo current = queue.poll(); + Node currentNode = current.path.get(current.path.size() - 1); + + if (cycleNodes.contains(currentNode)) { + return current.path; + } + + for (Node neighbor : currentNode.neighbors) { + if (!visited.contains(neighbor)) { + visited.add(neighbor); + List newPath = new ArrayList<>(current.path); + newPath.add(neighbor); + queue.offer(new PathInfo(newPath, current.distance + 1)); + } + } + } + + LOGGER.debug( + "No path found to cycle: {}", + cycle.nodes.stream().map(node -> node.type.getName()).toList()); + return List.of(); + } + + private void addRootTypesToQueue(Queue queue, Set visited) { + if (schema.getQueryType() != null) { + Node queryNode = typeNodeMap.get(schema.getQueryType()); + queue.offer(new PathInfo(List.of(queryNode), 0)); + visited.add(queryNode); + } + if (schema.getMutationType() != null) { + Node mutationNode = typeNodeMap.get(schema.getMutationType()); + queue.offer(new PathInfo(List.of(mutationNode), 0)); + visited.add(mutationNode); + } + if (schema.getSubscriptionType() != null) { + Node subscriptionNode = typeNodeMap.get(schema.getSubscriptionType()); + queue.offer(new PathInfo(List.of(subscriptionNode), 0)); + visited.add(subscriptionNode); + } + } + + private Optional buildCycleDetectionResult(Cycle cycle) { + List pathToCycle = findShortestPathToCycle(cycle); + if (pathToCycle.isEmpty()) { + LOGGER.debug("Path to cycle not found: {}", cycle); + return Optional.empty(); + } + Node intersectionNode = pathToCycle.get(pathToCycle.size() - 1); + int cycleStartIndex = cycle.nodes.indexOf(intersectionNode); + if (cycleStartIndex == -1) { + LOGGER.debug("Path doesn't properly connect to cycle: {}", cycle); + return Optional.empty(); + } + List fullPath = new ArrayList<>(pathToCycle.subList(0, pathToCycle.size() - 1)); + for (int i = 0; i < cycle.nodes.size(); i++) { + int index = (i + cycleStartIndex) % cycle.nodes.size(); + fullPath.add(cycle.nodes.get(index)); + } + fullPath.add(intersectionNode); + String typeChain = + String.join( + " -> ", + fullPath.stream() + .map(node -> node.type) + .map(GraphQLObjectType::getName) + .toList()) + .replaceFirst( + intersectionNode.type.getName(), + "(" + intersectionNode.type.getName()) + + ")"; + + var query = new StringBuilder("{ "); + var variables = new JSONObject(); + for (int i = 0; i < fullPath.size() - 1; i++) { + Node node = fullPath.get(i); + Node nextNode = fullPath.get(i + 1); + Optional field = + node.type.getFieldDefinitions().stream() + .filter( + f -> + GraphQLTypeUtil.unwrapAll(f.getType()) + .equals(nextNode.type)) + .findAny(); + if (field.isEmpty()) { + LOGGER.debug( + "{} missing in field definitions of {}", + nextNode.type.getName(), + node.type.getName()); + return Optional.empty(); + } + query.append(field.get().getName()).append(" "); + generator.addArguments(query, variables, field.get()); + query.append("{ "); + } + query.setLength(query.length() - 2); // Remove trailing "{ " + query.append(generator.getFirstLeafQuery(intersectionNode.type, variables, null)); + query.append( + StringUtils.repeat( + "} ", + StringUtils.countMatches(query, "{") + - StringUtils.countMatches(query, "}"))); + query.setLength(query.length() - 1); // Remove trailing space + GraphQLType rootType = fullPath.get(0).type; + if (rootType.equals(schema.getQueryType())) { + query.insert(0, "query "); + } else if (rootType.equals(schema.getMutationType())) { + query.insert(0, "mutation "); + } else if (rootType.equals(schema.getSubscriptionType())) { + query.insert(0, "subscription "); + } + return Optional.of( + new GraphQlCycleDetectionResult(typeChain, query.toString(), variables.toString())); + } + + private void raiseAlert(GraphQlCycleDetectionResult result) { + var extAlert = + Control.getSingleton().getExtensionLoader().getExtension(ExtensionAlert.class); + if (extAlert == null) { + return; + } + try { + Alert alert = + getBaseAlertBuilder() + .setOtherInfo(result.cycle) + .setMessage( + queryMsgBuilder.buildQueryMessage( + result.query, + result.variables, + param.getRequestMethod())) + .build(); + extAlert.alertFound(alert, null); + } catch (Exception e) { + LOGGER.error("Failed to build alert for GraphQL cycle", e); + } + } + + private static Alert.Builder getBaseAlertBuilder() { + return Alert.builder() + .setPluginId(ExtensionGraphQl.TOOL_ALERT_ID) + .setAlertRef(CYCLES_ALERT_REF) + .setName(Constant.messages.getString("graphql.cycles.alert.name")) + .setDescription(Constant.messages.getString("graphql.cycles.alert.desc")) + .setReference(Constant.messages.getString("graphql.cycles.alert.ref")) + .setSolution(Constant.messages.getString("graphql.cycles.alert.soln")) + .setConfidence(Alert.CONFIDENCE_HIGH) + .setRisk(Alert.RISK_INFO) + .setCweId(16) + .setWascId(15) + .setSource(Alert.Source.TOOL) + .setTags(CYCLES_ALERT_TAGS); + } + + static Alert getExampleAlert() { + return getBaseAlertBuilder() + .setOtherInfo( + "Query -> (Organization -> Repository -> PullRequest -> Commit -> Organization)") + .build(); + } + + record GraphQlCycleDetectionResult(String cycle, String query, String variables) {} + + @RequiredArgsConstructor + private static class Node { + final GraphQLObjectType type; + int index = -1; + int lowLink = -1; + boolean onStack = false; + Set neighbors = new HashSet<>(); + } + + private record SCC(List nodes) { + // SCC stands for "Strongly Connected Component". + // Ref: https://en.wikipedia.org/wiki/Strongly_connected_component + } + + private record PathInfo(List path, int distance) {} + + private record Cycle(List nodes) { + @Override + public String toString() { + return nodes.stream() + .map(node -> node.type.getName()) + .collect(Collectors.joining(" -> ")); + } + } + + private static class CycleDetectionCompleteException extends RuntimeException { + private static final long serialVersionUID = 1L; + } +} diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlFingerprinter.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlFingerprinter.java index 7f2feed41b0..e9989bced2c 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlFingerprinter.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlFingerprinter.java @@ -35,7 +35,6 @@ import org.parosproxy.paros.control.Control; import org.parosproxy.paros.core.scanner.Alert; import org.parosproxy.paros.network.HttpMessage; -import org.parosproxy.paros.network.HttpSender; import org.zaproxy.addon.commonlib.CommonAlertTag; import org.zaproxy.zap.extension.alert.ExtensionAlert; @@ -49,15 +48,17 @@ public class GraphQlFingerprinter { private static List handlers; + private final URI endpointUrl; private final Requestor requestor; private final Map queryCache; private HttpMessage lastQueryMsg; private String matchedString; - public GraphQlFingerprinter(URI endpointUrl) { + public GraphQlFingerprinter(URI endpointUrl, Requestor requestor) { resetHandlers(); - requestor = new Requestor(endpointUrl, HttpSender.MANUAL_REQUEST_INITIATOR); + this.endpointUrl = endpointUrl; + this.requestor = requestor; queryCache = new HashMap<>(); } @@ -201,7 +202,7 @@ void raiseFingerprintingAlert(DiscoveredGraphQlEngine discoveredGraphQlEngine) { createFingerprintingAlert(discoveredGraphQlEngine) .setEvidence(matchedString) .setMessage(lastQueryMsg) - .setUri(requestor.getEndpointUrl().toString()) + .setUri(endpointUrl.toString()) .build(); extAlert.alertFound(alert, null); } diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlGenerator.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlGenerator.java index f8d2c63477b..be38ce5d533 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlGenerator.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlGenerator.java @@ -67,10 +67,23 @@ public enum RequestType { public GraphQlGenerator( ValueProvider valueProvider, String sdl, Requestor requestor, GraphQlParam param) { + this( + valueProvider, + UnExecutableSchemaGenerator.makeUnExecutableSchema(new SchemaParser().parse(sdl)), + requestor, + param); + } + + public GraphQlGenerator( + ValueProvider valueProvider, + GraphQLSchema schema, + Requestor requestor, + GraphQlParam param) { this.valueProvider = valueProvider; - schema = UnExecutableSchemaGenerator.makeUnExecutableSchema(new SchemaParser().parse(sdl)); + this.schema = schema; this.requestor = requestor; this.param = param; + this.inlineArgsEnabled = param.getArgsType() == GraphQlParam.ArgsTypeOption.INLINE; } /** Send three requests to check which service methods are available. */ @@ -490,8 +503,7 @@ private GraphQLFieldDefinition getFirstLeafField(GraphQLType type) { return null; } - private void addArguments( - StringBuilder query, JSONObject variables, GraphQLFieldDefinition field) { + void addArguments(StringBuilder query, JSONObject variables, GraphQLFieldDefinition field) { addArguments(query, variables, null, field); } diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlOptionsPanel.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlOptionsPanel.java index b8297ff72f7..f688a073a76 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlOptionsPanel.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlOptionsPanel.java @@ -19,24 +19,19 @@ */ package org.zaproxy.addon.graphql; -import java.awt.Component; import java.awt.GridBagLayout; import java.awt.Insets; -import java.awt.event.ItemEvent; import javax.swing.Box; import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JLabel; -import javax.swing.JList; import javax.swing.JPanel; -import javax.swing.border.Border; -import javax.swing.border.EmptyBorder; import javax.swing.border.TitledBorder; -import javax.swing.plaf.basic.BasicComboBoxRenderer; import org.parosproxy.paros.Constant; import org.parosproxy.paros.model.OptionsParam; import org.parosproxy.paros.view.AbstractParamPanel; import org.zaproxy.addon.graphql.GraphQlParam.ArgsTypeOption; +import org.zaproxy.addon.graphql.GraphQlParam.CycleDetectionModeOption; import org.zaproxy.addon.graphql.GraphQlParam.QuerySplitOption; import org.zaproxy.addon.graphql.GraphQlParam.RequestMethodOption; import org.zaproxy.zap.utils.ZapNumberSpinner; @@ -51,7 +46,9 @@ public class GraphQlOptionsPanel extends AbstractParamPanel { private static final String NAME = Constant.messages.getString("graphql.options.panelName"); private JCheckBox queryGenEnabled; + private JPanel importConfigPanel; private JPanel queryGenConfigPanel; + private JPanel cycleDetectionConfigPanel; private ZapNumberSpinner maxQueryDepthNumberSpinner; private JCheckBox lenientMaxQueryDepthEnabled = null; private ZapNumberSpinner maxAdditionalQueryDepthNumberSpinner; @@ -61,14 +58,20 @@ public class GraphQlOptionsPanel extends AbstractParamPanel { private JComboBox querySplitOptions = null; private JComboBox requestMethodOptions = null; private JLabel maxAdditionalQueryDepthLabel; + private JComboBox cycleDetectionModeOptions; + private ZapNumberSpinner maxCycleDetectionAlertsNumberSpinner; public GraphQlOptionsPanel() { super(); setName(NAME); setLayout(new GridBagLayout()); - int y = 0; - add(getQueryGenConfigPanel(), LayoutHelper.getGBC(0, y, 1, 1.0, new Insets(10, 2, 2, 2))); + int y = -1; + add(getImportConfigPanel(), LayoutHelper.getGBC(0, ++y, 1, 1.0, new Insets(10, 2, 2, 2))); + add(getQueryGenConfigPanel(), LayoutHelper.getGBC(0, ++y, 1, 1.0, new Insets(2, 2, 2, 2))); + add( + getCycleDetectionConfigPanel(), + LayoutHelper.getGBC(0, ++y, 1, 1.0, new Insets(2, 2, 2, 2))); add(Box.createGlue(), LayoutHelper.getGBC(0, ++y, 1, 1.0, 1.0)); } @@ -86,6 +89,8 @@ public void initParam(Object obj) { getArgsTypeOptions().setSelectedItem(param.getArgsType()); getQuerySplitOptions().setSelectedItem(param.getQuerySplitType()); getRequestMethodOptions().setSelectedItem(param.getRequestMethod()); + getCycleDetectionModeOptions().setSelectedItem(param.getCycleDetectionMode()); + getMaxCycleDetectionAlertsNumberSpinner().setValue(param.getMaxCycleDetectionAlerts()); } @Override @@ -102,6 +107,9 @@ public void saveParam(Object obj) throws Exception { param.setArgsType((ArgsTypeOption) getArgsTypeOptions().getSelectedItem()); param.setQuerySplitType((QuerySplitOption) getQuerySplitOptions().getSelectedItem()); param.setRequestMethod((RequestMethodOption) getRequestMethodOptions().getSelectedItem()); + param.setCycleDetectionMode( + (CycleDetectionModeOption) getCycleDetectionModeOptions().getSelectedItem()); + param.setMaxCycleDetectionAlerts(getMaxCycleDetectionAlertsNumberSpinner().getValue()); } private JCheckBox getQueryGenEnabled() { @@ -110,22 +118,25 @@ private JCheckBox getQueryGenEnabled() { new JCheckBox( Constant.messages.getString("graphql.options.label.queryGenEnabled"), true); - queryGenEnabled.addItemListener( - e -> { - boolean selected = e.getStateChange() == ItemEvent.SELECTED; - for (var c : getQueryGenConfigPanel().getComponents()) { - if (c == queryGenEnabled) { - continue; - } - c.setEnabled(selected); - } - validate(); - repaint(); - }); } return queryGenEnabled; } + private JPanel getImportConfigPanel() { + if (importConfigPanel == null) { + importConfigPanel = new JPanel(new GridBagLayout()); + importConfigPanel.setBorder( + new TitledBorder( + Constant.messages.getString( + "graphql.options.importConfigPanel.title"))); + int y = -1; + importConfigPanel.add( + getQueryGenEnabled(), + LayoutHelper.getGBC(0, ++y, 2, 1.0, new Insets(2, 2, 2, 2))); + } + return importConfigPanel; + } + private JPanel getQueryGenConfigPanel() { if (queryGenConfigPanel == null) { queryGenConfigPanel = new JPanel(new GridBagLayout()); @@ -145,10 +156,7 @@ private JPanel getQueryGenConfigPanel() { JLabel requestMethodLabel = new JLabel(Constant.messages.getString("graphql.options.label.requestMethod")); - int i = 0; - queryGenConfigPanel.add( - getQueryGenEnabled(), - LayoutHelper.getGBC(0, i, 2, 1.0, new Insets(2, 2, 2, 2))); + int i = -1; queryGenConfigPanel.add( maxQueryDepthLabel, LayoutHelper.getGBC(0, ++i, 1, 1.0, new Insets(2, 2, 2, 2))); @@ -192,6 +200,37 @@ private JPanel getQueryGenConfigPanel() { return queryGenConfigPanel; } + private JPanel getCycleDetectionConfigPanel() { + if (cycleDetectionConfigPanel == null) { + cycleDetectionConfigPanel = new JPanel(new GridBagLayout()); + cycleDetectionConfigPanel.setBorder( + new TitledBorder( + Constant.messages.getString( + "graphql.options.cycleDetectionConfigPanel.title"))); + JLabel modeLabel = + new JLabel( + Constant.messages.getString( + "graphql.options.label.cycleDetectionMode")); + JLabel maxAlertsLabel = + new JLabel( + Constant.messages.getString( + "graphql.options.label.cycleDetectionMaxAlerts")); + + int y = -1; + cycleDetectionConfigPanel.add( + modeLabel, LayoutHelper.getGBC(0, ++y, 1, 1.0, new Insets(2, 2, 2, 2))); + cycleDetectionConfigPanel.add( + getCycleDetectionModeOptions(), + LayoutHelper.getGBC(1, y, 1, 1.0, new Insets(2, 2, 2, 2))); + cycleDetectionConfigPanel.add( + maxAlertsLabel, LayoutHelper.getGBC(0, ++y, 1, 1.0, new Insets(2, 2, 2, 2))); + cycleDetectionConfigPanel.add( + getMaxCycleDetectionAlertsNumberSpinner(), + LayoutHelper.getGBC(1, y, 1, 1.0, new Insets(2, 2, 2, 2))); + } + return cycleDetectionConfigPanel; + } + private ZapNumberSpinner getMaxQueryDepthNumberSpinner() { if (maxQueryDepthNumberSpinner == null) { maxQueryDepthNumberSpinner = new ZapNumberSpinner(0, 0, Integer.MAX_VALUE); @@ -257,7 +296,6 @@ private JComboBox getArgsTypeOptions() { new ArgsTypeOption[] { ArgsTypeOption.INLINE, ArgsTypeOption.VARIABLES, ArgsTypeOption.BOTH }); - argsTypeOptions.setRenderer(new CustomComboBoxRenderer()); } return argsTypeOptions; } @@ -272,12 +310,10 @@ private JComboBox getQuerySplitOptions() { QuerySplitOption.ROOT_FIELD, QuerySplitOption.OPERATION }); - querySplitOptions.setRenderer(new CustomComboBoxRenderer()); } return querySplitOptions; } - @SuppressWarnings("unchecked") private JComboBox getRequestMethodOptions() { if (requestMethodOptions == null) { requestMethodOptions = @@ -287,40 +323,34 @@ private JComboBox getRequestMethodOptions() { RequestMethodOption.POST_GRAPHQL, RequestMethodOption.GET }); - requestMethodOptions.setRenderer(new CustomComboBoxRenderer()); } return requestMethodOptions; } - @Override - public String getHelpIndex() { - return "graphql.options"; + private JComboBox getCycleDetectionModeOptions() { + if (cycleDetectionModeOptions == null) { + cycleDetectionModeOptions = + new JComboBox<>( + new CycleDetectionModeOption[] { + CycleDetectionModeOption.DISABLED, + CycleDetectionModeOption.QUICK, + CycleDetectionModeOption.EXHAUSTIVE + }); + } + return cycleDetectionModeOptions; } - /** A renderer for properly displaying the name of options in a ComboBox. */ - private static class CustomComboBoxRenderer extends BasicComboBoxRenderer { - private static final long serialVersionUID = 1L; - private static final Border BORDER = new EmptyBorder(2, 3, 3, 3); - - @Override - @SuppressWarnings("rawtypes") - public Component getListCellRendererComponent( - JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { - super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - if (value != null) { - setBorder(BORDER); - if (value instanceof ArgsTypeOption) { - ArgsTypeOption item = (ArgsTypeOption) value; - setText(item.getName()); - } else if (value instanceof QuerySplitOption) { - QuerySplitOption item = (QuerySplitOption) value; - setText(item.getName()); - } else if (value instanceof RequestMethodOption) { - RequestMethodOption item = (RequestMethodOption) value; - setText(item.getName()); - } - } - return this; + private ZapNumberSpinner getMaxCycleDetectionAlertsNumberSpinner() { + if (maxCycleDetectionAlertsNumberSpinner == null) { + maxCycleDetectionAlertsNumberSpinner = + new ZapNumberSpinner( + 0, GraphQlParam.DEFAULT_MAX_CYCLE_DETECTION_ALERTS, Integer.MAX_VALUE); } + return maxCycleDetectionAlertsNumberSpinner; + } + + @Override + public String getHelpIndex() { + return "graphql.options"; } } diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlParam.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlParam.java index 8f9baf6fa00..83afb3d05c5 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlParam.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlParam.java @@ -25,6 +25,7 @@ import org.parosproxy.paros.Constant; import org.zaproxy.zap.common.VersionedAbstractParam; import org.zaproxy.zap.extension.api.ApiException; +import org.zaproxy.zap.extension.api.ZapApiIgnore; public class GraphQlParam extends VersionedAbstractParam { @@ -42,6 +43,9 @@ public class GraphQlParam extends VersionedAbstractParam { private static final String PARAM_ARGS_TYPE = PARAM_BASE_KEY + ".argsType"; private static final String PARAM_QUERY_SPLIT_TYPE = PARAM_BASE_KEY + ".querySplitType"; private static final String PARAM_REQUEST_METHOD = PARAM_BASE_KEY + ".requestMethod"; + private static final String PARAM_CYCLE_DETECTION_MODE = PARAM_BASE_KEY + ".cycleDetectionMode"; + private static final String PARAM_CYCLE_DETECTION_MAX_ALERTS = + PARAM_BASE_KEY + ".cycleDetectionMaxAlerts"; public static final boolean DEFAULT_QUERY_GEN_ENABLED = true; public static final int DEFAULT_MAX_QUERY_DEPTH = 5; @@ -52,6 +56,9 @@ public class GraphQlParam extends VersionedAbstractParam { public static final ArgsTypeOption DEFAULT_ARGS_TYPE = ArgsTypeOption.BOTH; public static final QuerySplitOption DEFAULT_QUERY_SPLIT_TYPE = QuerySplitOption.LEAF; public static final RequestMethodOption DEFAULT_REQUEST_METHOD = RequestMethodOption.POST_JSON; + public static final CycleDetectionModeOption DEFAULT_CYCLE_DETECTION_MODE = + CycleDetectionModeOption.QUICK; + public static final int DEFAULT_MAX_CYCLE_DETECTION_ALERTS = 100; /** * The version of the configurations. Used to keep track of configurations changes between @@ -73,7 +80,9 @@ public GraphQlParam( boolean optionalArgsEnabled, ArgsTypeOption argsType, QuerySplitOption querySplitType, - RequestMethodOption requestMethod) { + RequestMethodOption requestMethod, + CycleDetectionModeOption cycleDetectionMode, + int maxCycleDetectionAlerts) { this.queryGenEnabled = queryGenEnabled; this.maxQueryDepth = maxQueryDepth; this.lenientMaxQueryDepthEnabled = lenientMaxQueryDepthEnabled; @@ -83,6 +92,8 @@ public GraphQlParam( this.argsType = argsType; this.querySplitType = querySplitType; this.requestMethod = requestMethod; + this.cycleDetectionMode = cycleDetectionMode; + this.maxCycleDetectionAlerts = maxCycleDetectionAlerts; } /** This option is used to specify how field arguments should be included. */ @@ -94,17 +105,14 @@ public enum ArgsTypeOption { /** Each request is sent twice - once with in-line arguments and once using variables. */ BOTH; - public String getName() { - switch (this) { - case INLINE: - return Constant.messages.getString("graphql.options.value.args.inline"); - case VARIABLES: - return Constant.messages.getString("graphql.options.value.args.variables"); - case BOTH: - return Constant.messages.getString("graphql.options.value.args.both"); - default: - return null; - } + @Override + public String toString() { + return switch (this) { + case INLINE -> Constant.messages.getString("graphql.options.value.args.inline"); + case VARIABLES -> + Constant.messages.getString("graphql.options.value.args.variables"); + case BOTH -> Constant.messages.getString("graphql.options.value.args.both"); + }; } }; @@ -117,17 +125,15 @@ public enum QuerySplitOption { /** A single large request is sent. */ OPERATION; - public String getName() { - switch (this) { - case LEAF: - return Constant.messages.getString("graphql.options.value.split.leaf"); - case ROOT_FIELD: - return Constant.messages.getString("graphql.options.value.split.rootField"); - case OPERATION: - return Constant.messages.getString("graphql.options.value.split.operation"); - default: - return null; - } + @Override + public String toString() { + return switch (this) { + case LEAF -> Constant.messages.getString("graphql.options.value.split.leaf"); + case ROOT_FIELD -> + Constant.messages.getString("graphql.options.value.split.rootField"); + case OPERATION -> + Constant.messages.getString("graphql.options.value.split.operation"); + }; } }; @@ -140,19 +146,37 @@ public enum RequestMethodOption { /** The method is GET and the query is appended to the endpoint URL in a query string. */ GET; - public String getName() { - switch (this) { - case POST_JSON: - return Constant.messages.getString("graphql.options.value.request.postJson"); - case POST_GRAPHQL: - return Constant.messages.getString("graphql.options.value.split.postGraphql"); - case GET: - return Constant.messages.getString("graphql.options.value.split.get"); - default: - return null; - } + @Override + public String toString() { + return switch (this) { + case POST_JSON -> + Constant.messages.getString("graphql.options.value.request.postJson"); + case POST_GRAPHQL -> + Constant.messages.getString("graphql.options.value.split.postGraphql"); + case GET -> Constant.messages.getString("graphql.options.value.split.get"); + }; } - }; + } + + public enum CycleDetectionModeOption { + DISABLED, + QUICK, + EXHAUSTIVE; + + @Override + public String toString() { + return switch (this) { + case DISABLED -> + Constant.messages.getString( + "graphql.options.value.cycleDetection.disabled"); + case QUICK -> + Constant.messages.getString("graphql.options.value.cycleDetection.quick"); + case EXHAUSTIVE -> + Constant.messages.getString( + "graphql.options.value.cycleDetection.exhaustive"); + }; + } + } private boolean queryGenEnabled; private int maxQueryDepth; @@ -163,6 +187,8 @@ public String getName() { private ArgsTypeOption argsType; private QuerySplitOption querySplitType; private RequestMethodOption requestMethod; + private CycleDetectionModeOption cycleDetectionMode; + private int maxCycleDetectionAlerts; public int getMaxQueryDepth() { return maxQueryDepth; @@ -209,6 +235,7 @@ public void setOptionalArgsEnabled(boolean optionalArgsEnabled) { getConfig().setProperty(PARAM_OPTIONAL_ARGS, optionalArgsEnabled); } + @ZapApiIgnore public ArgsTypeOption getArgsType() { return argsType; } @@ -225,9 +252,10 @@ public void setArgsType(String argsType) throws ApiException { public void setArgsType(ArgsTypeOption argsType) { this.argsType = argsType; - getConfig().setProperty(PARAM_ARGS_TYPE, argsType.toString()); + getConfig().setProperty(PARAM_ARGS_TYPE, argsType.name()); } + @ZapApiIgnore public QuerySplitOption getQuerySplitType() { return querySplitType; } @@ -244,9 +272,10 @@ public void setQuerySplitType(String querySplitType) throws ApiException { public void setQuerySplitType(QuerySplitOption querySplitType) { this.querySplitType = querySplitType; - getConfig().setProperty(PARAM_QUERY_SPLIT_TYPE, querySplitType.toString()); + getConfig().setProperty(PARAM_QUERY_SPLIT_TYPE, querySplitType.name()); } + @ZapApiIgnore public RequestMethodOption getRequestMethod() { return requestMethod; } @@ -263,7 +292,7 @@ public void setRequestMethod(String requestMethod) throws ApiException { public void setRequestMethod(RequestMethodOption requestMethod) { this.requestMethod = requestMethod; - getConfig().setProperty(PARAM_REQUEST_METHOD, requestMethod.toString()); + getConfig().setProperty(PARAM_REQUEST_METHOD, requestMethod.name()); } public boolean getQueryGenEnabled() { @@ -275,6 +304,36 @@ public void setQueryGenEnabled(boolean queryGenEnabled) { getConfig().setProperty(PARAM_QUERY_GENERATOR_ENABLED, queryGenEnabled); } + @ZapApiIgnore + public CycleDetectionModeOption getCycleDetectionMode() { + return cycleDetectionMode; + } + + public void setCycleDetectionMode(CycleDetectionModeOption cycleDetectionMode) { + this.cycleDetectionMode = cycleDetectionMode; + getConfig().setProperty(PARAM_CYCLE_DETECTION_MODE, cycleDetectionMode.name()); + } + + // For generating an API action. + public void setCycleDetectionMode(String cycleDetectionMode) throws ApiException { + try { + setCycleDetectionMode( + CycleDetectionModeOption.valueOf(cycleDetectionMode.toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + LOGGER.debug("'{}' is not a valid Cycle Detection Mode.", cycleDetectionMode); + throw new ApiException(ApiException.Type.ILLEGAL_PARAMETER, e.getMessage()); + } + } + + public int getMaxCycleDetectionAlerts() { + return maxCycleDetectionAlerts; + } + + public void setMaxCycleDetectionAlerts(int maxCycleDetectionAlerts) { + this.maxCycleDetectionAlerts = maxCycleDetectionAlerts; + getConfig().setProperty(PARAM_CYCLE_DETECTION_MAX_ALERTS, maxCycleDetectionAlerts); + } + @Override protected String getConfigVersionKey() { return PARAM_BASE_KEY + VERSION_ATTRIBUTE; @@ -298,6 +357,9 @@ protected void parseImpl() { argsType = getEnum(PARAM_ARGS_TYPE, DEFAULT_ARGS_TYPE); querySplitType = getEnum(PARAM_QUERY_SPLIT_TYPE, DEFAULT_QUERY_SPLIT_TYPE); requestMethod = getEnum(PARAM_REQUEST_METHOD, DEFAULT_REQUEST_METHOD); + cycleDetectionMode = getEnum(PARAM_CYCLE_DETECTION_MODE, DEFAULT_CYCLE_DETECTION_MODE); + maxCycleDetectionAlerts = + getInt(PARAM_CYCLE_DETECTION_MAX_ALERTS, DEFAULT_MAX_CYCLE_DETECTION_ALERTS); } @Override diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlParser.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlParser.java index c080f229363..5e66f3e0901 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlParser.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlParser.java @@ -25,7 +25,10 @@ import graphql.introspection.IntrospectionQueryBuilder; import graphql.introspection.IntrospectionResultToSchema; import graphql.language.Document; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.SchemaParser; import graphql.schema.idl.SchemaPrinter; +import graphql.schema.idl.UnExecutableSchemaGenerator; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; @@ -64,6 +67,8 @@ public class GraphQlParser { CommonAlertTag.OWASP_2021_A05_SEC_MISCONFIG); private static final ObjectMapper MAPPER = new ObjectMapper(); + private final URI endpointUrl; + private final GraphQlQueryMessageBuilder queryMsgBuilder; private final Requestor requestor; private final ExtensionGraphQl extensionGraphQl; private final GraphQlParam param; @@ -73,9 +78,9 @@ public class GraphQlParser { protected GraphQlParser(String endpointUrlStr) throws URIException { extensionGraphQl = new ExtensionGraphQl(); param = extensionGraphQl.getParam(); - requestor = - new Requestor( - UrlBuilder.build(endpointUrlStr), HttpSender.MANUAL_REQUEST_INITIATOR); + endpointUrl = UrlBuilder.build(endpointUrlStr); + queryMsgBuilder = new GraphQlQueryMessageBuilder(endpointUrl); + requestor = new Requestor(queryMsgBuilder, HttpSender.MANUAL_REQUEST_INITIATOR); } public GraphQlParser(String endpointUrlStr, int initiator, boolean syncParse) @@ -84,7 +89,9 @@ public GraphQlParser(String endpointUrlStr, int initiator, boolean syncParse) } public GraphQlParser(URI endpointUrl, int initiator, boolean syncParse) { - requestor = new Requestor(endpointUrl, initiator); + this.endpointUrl = endpointUrl; + queryMsgBuilder = new GraphQlQueryMessageBuilder(endpointUrl); + requestor = new Requestor(queryMsgBuilder, initiator); extensionGraphQl = Control.getSingleton().getExtensionLoader().getExtension(ExtensionGraphQl.class); param = extensionGraphQl.getParam(); @@ -154,11 +161,17 @@ private static String getSchemaFromIntrospectionResponse(String response) throws } } - public void parse(String schema) { + public void parse(String sdl) { + GraphQLSchema schema = + UnExecutableSchemaGenerator.makeUnExecutableSchema(new SchemaParser().parse(sdl)); + var generator = + new GraphQlGenerator( + extensionGraphQl.getValueGenerator(), schema, requestor, param); if (syncParse) { fingerprint(); + detectCycles(schema, generator); if (param.getQueryGenEnabled()) { - generate(schema); + generate(generator); } return; } @@ -167,8 +180,9 @@ public void parse(String schema) { @Override public void run() { fingerprint(); + detectCycles(schema, generator); if (param.getQueryGenEnabled()) { - generate(schema); + generate(generator); } } }; @@ -177,15 +191,15 @@ public void run() { } private void fingerprint() { - var fingerprinter = new GraphQlFingerprinter(requestor.getEndpointUrl()); - fingerprinter.fingerprint(); + new GraphQlFingerprinter(endpointUrl, requestor).fingerprint(); } - private void generate(String schema) { + private void detectCycles(GraphQLSchema schema, GraphQlGenerator generator) { + new GraphQlCycleDetector(schema, generator, queryMsgBuilder, param).detectCycles(); + } + + private void generate(GraphQlGenerator generator) { try { - GraphQlGenerator generator = - new GraphQlGenerator( - extensionGraphQl.getValueGenerator(), schema, requestor, param); generator.checkServiceMethods(); generator.generateAndSend(); } catch (Exception e) { diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlQueryMessageBuilder.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlQueryMessageBuilder.java new file mode 100644 index 00000000000..909e748077d --- /dev/null +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlQueryMessageBuilder.java @@ -0,0 +1,99 @@ +/* + * 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.graphql; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import net.sf.json.JSONObject; +import org.apache.commons.httpclient.URI; +import org.parosproxy.paros.network.HttpHeader; +import org.parosproxy.paros.network.HttpMessage; +import org.parosproxy.paros.network.HttpRequestHeader; +import org.zaproxy.zap.network.HttpRequestBody; + +public class GraphQlQueryMessageBuilder { + + private final URI endpointUrl; + private static final String GRAPHQL_CONTENT_TYPE = "application/graphql"; + + public GraphQlQueryMessageBuilder(URI endpointUrl) { + this.endpointUrl = endpointUrl; + } + + public HttpMessage buildQueryMessage( + String query, String variables, GraphQlParam.RequestMethodOption method) + throws IOException { + return switch (method) { + case GET -> buildGetQueryMessage(query, variables); + case POST_GRAPHQL -> buildGraphQlPostQueryMessage(query, variables); + case POST_JSON -> buildJsonPostQueryMessage(query, variables); + }; + } + + private HttpMessage buildGetQueryMessage(String query, String variables) throws IOException { + String updatedEndpointUrl = + endpointUrl + + "?query=" + + URLEncoder.encode(query, StandardCharsets.UTF_8.toString()); + if (!variables.isEmpty()) { + updatedEndpointUrl += + "&variables=" + URLEncoder.encode(variables, StandardCharsets.UTF_8.toString()); + } + + URI url = UrlBuilder.build(updatedEndpointUrl); + return new HttpMessage(url); + } + + private HttpMessage buildGraphQlPostQueryMessage(String query, String variables) + throws IOException { + String updatedEndpointUrl = endpointUrl.toString(); + if (!variables.isEmpty()) { + updatedEndpointUrl += + "?variables=" + URLEncoder.encode(variables, StandardCharsets.UTF_8.toString()); + } + URI url = UrlBuilder.build(updatedEndpointUrl); + HttpRequestBody msgBody = new HttpRequestBody(query); + HttpRequestHeader msgHeader = + new HttpRequestHeader(HttpRequestHeader.POST, url, HttpHeader.HTTP11); + msgHeader.setHeader("Accept", HttpHeader.JSON_CONTENT_TYPE); + msgHeader.setHeader(HttpHeader.CONTENT_TYPE, GRAPHQL_CONTENT_TYPE); + msgHeader.setContentLength(msgBody.length()); + + return new HttpMessage(msgHeader, msgBody); + } + + private HttpMessage buildJsonPostQueryMessage(String query, String variables) + throws IOException { + JSONObject msgBodyJson = new JSONObject(); + msgBodyJson.put("query", query); + if (!variables.isEmpty()) { + msgBodyJson.put("variables", variables); + } + HttpRequestBody msgBody = new HttpRequestBody(msgBodyJson.toString()); + + HttpRequestHeader msgHeader = + new HttpRequestHeader(HttpRequestHeader.POST, endpointUrl, HttpHeader.HTTP11); + msgHeader.setHeader("Accept", HttpHeader.JSON_CONTENT_TYPE); + msgHeader.setHeader(HttpHeader.CONTENT_TYPE, HttpHeader.JSON_CONTENT_TYPE); + msgHeader.setContentLength(msgBody.length()); + return new HttpMessage(msgHeader, msgBody); + } +} diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/Requestor.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/Requestor.java index 7841f0cc1f1..cbd834f9f7f 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/Requestor.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/Requestor.java @@ -20,103 +20,41 @@ package org.zaproxy.addon.graphql; import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; -import net.sf.json.JSONObject; import org.apache.commons.httpclient.URI; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.parosproxy.paros.network.HttpHeader; import org.parosproxy.paros.network.HttpMessage; -import org.parosproxy.paros.network.HttpRequestHeader; import org.parosproxy.paros.network.HttpSender; import org.zaproxy.zap.network.HttpRedirectionValidator; -import org.zaproxy.zap.network.HttpRequestBody; import org.zaproxy.zap.network.HttpRequestConfig; public class Requestor { private final int initiator; - private final URI endpointUrl; + private final GraphQlQueryMessageBuilder queryMsgBuilder; private List listeners = new ArrayList<>(); private HttpSender sender; private final HttpRequestConfig requestConfig; private static final Logger LOGGER = LogManager.getLogger(Requestor.class); - private static final String GRAPHQL_CONTENT_TYPE = "application/graphql"; - public Requestor(URI endpointUrl, int initiator) { - this.endpointUrl = endpointUrl; + public Requestor(GraphQlQueryMessageBuilder queryMsgBuilder, int initiator) { this.initiator = initiator; + this.queryMsgBuilder = queryMsgBuilder; sender = new HttpSender(initiator); requestConfig = HttpRequestConfig.builder().setRedirectionValidator(new MessageHandler()).build(); } - private HttpMessage sendQueryByGet(String query, String variables) { - try { - String updatedEndpointUrl = - endpointUrl - + "?query=" - + URLEncoder.encode(query, StandardCharsets.UTF_8.toString()); - if (!variables.isEmpty()) { - updatedEndpointUrl += - "?variables=" - + URLEncoder.encode(variables, StandardCharsets.UTF_8.toString()); - } - - URI url = UrlBuilder.build(updatedEndpointUrl); - HttpMessage message = new HttpMessage(url); - send(message); - return message; - } catch (IOException e) { - LOGGER.warn(e.getMessage()); - } - return null; - } - - private HttpMessage sendQueryByGraphQlPost(String query, String variables) { - try { - String updatedEndpointUrl = endpointUrl.toString(); - if (!variables.isEmpty()) { - updatedEndpointUrl += - "?variables=" - + URLEncoder.encode(variables, StandardCharsets.UTF_8.toString()); - } - URI url = UrlBuilder.build(updatedEndpointUrl); - HttpRequestBody msgBody = new HttpRequestBody(query); - HttpRequestHeader msgHeader = - new HttpRequestHeader(HttpRequestHeader.POST, url, HttpHeader.HTTP11); - msgHeader.setHeader("Accept", HttpHeader.JSON_CONTENT_TYPE); - msgHeader.setHeader(HttpHeader.CONTENT_TYPE, GRAPHQL_CONTENT_TYPE); - msgHeader.setContentLength(msgBody.length()); - - HttpMessage message = new HttpMessage(msgHeader, msgBody); - send(message); - return message; - } catch (IOException e) { - LOGGER.warn(e.getMessage()); - } - return null; + public HttpMessage sendQuery(String query, GraphQlParam.RequestMethodOption method) { + return sendQuery(query, "", method); } - private HttpMessage sendQueryByJsonPost(String query, String variables) { + public HttpMessage sendQuery( + String query, String variables, GraphQlParam.RequestMethodOption method) { try { - JSONObject msgBodyJson = new JSONObject(); - msgBodyJson.put("query", query); - if (!variables.isEmpty()) { - msgBodyJson.put("variables", variables); - } - HttpRequestBody msgBody = new HttpRequestBody(msgBodyJson.toString()); - - HttpRequestHeader msgHeader = - new HttpRequestHeader(HttpRequestHeader.POST, endpointUrl, HttpHeader.HTTP11); - msgHeader.setHeader("Accept", HttpHeader.JSON_CONTENT_TYPE); - msgHeader.setHeader(HttpHeader.CONTENT_TYPE, HttpHeader.JSON_CONTENT_TYPE); - msgHeader.setContentLength(msgBody.length()); - - HttpMessage message = new HttpMessage(msgHeader, msgBody); + HttpMessage message = queryMsgBuilder.buildQueryMessage(query, variables, method); send(message); return message; } catch (IOException e) { @@ -125,23 +63,6 @@ private HttpMessage sendQueryByJsonPost(String query, String variables) { return null; } - public HttpMessage sendQuery(String query, GraphQlParam.RequestMethodOption method) { - return sendQuery(query, "", method); - } - - public HttpMessage sendQuery( - String query, String variables, GraphQlParam.RequestMethodOption method) { - switch (method) { - case GET: - return sendQueryByGet(query, variables); - case POST_GRAPHQL: - return sendQueryByGraphQlPost(query, variables); - case POST_JSON: - default: - return sendQueryByJsonPost(query, variables); - } - } - public void send(HttpMessage message) throws IOException { sender.sendAndReceive(message, requestConfig); } @@ -154,10 +75,6 @@ public void removeListener(RequesterListener listener) { this.listeners.remove(listener); } - URI getEndpointUrl() { - return endpointUrl; - } - /** Notifies the {@link #listeners} of the messages sent. */ private class MessageHandler implements HttpRedirectionValidator { diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/automation/GraphQlJob.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/automation/GraphQlJob.java index 671a0d9a2e7..74f4f4633ba 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/automation/GraphQlJob.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/automation/GraphQlJob.java @@ -223,11 +223,13 @@ public static class Parameters extends AutomationData { private Integer maxAdditionalQueryDepth = GraphQlParam.DEFAULT_MAX_ADDITIONAL_QUERY_DEPTH; private Integer maxArgsDepth = GraphQlParam.DEFAULT_MAX_ARGS_DEPTH; private Boolean optionalArgsEnabled = GraphQlParam.DEFAULT_OPTIONAL_ARGS; - private String argsType = - GraphQlParam.DEFAULT_ARGS_TYPE.toString().toLowerCase(Locale.ROOT); + private String argsType = GraphQlParam.DEFAULT_ARGS_TYPE.name().toLowerCase(Locale.ROOT); private String querySplitType = - GraphQlParam.DEFAULT_QUERY_SPLIT_TYPE.toString().toLowerCase(Locale.ROOT); + GraphQlParam.DEFAULT_QUERY_SPLIT_TYPE.name().toLowerCase(Locale.ROOT); private String requestMethod = - GraphQlParam.DEFAULT_REQUEST_METHOD.toString().toLowerCase(Locale.ROOT); + GraphQlParam.DEFAULT_REQUEST_METHOD.name().toLowerCase(Locale.ROOT); + private String cycleDetectionMode = + GraphQlParam.DEFAULT_CYCLE_DETECTION_MODE.name().toLowerCase(Locale.ROOT); + private Integer maxCycleDetectionAlerts = GraphQlParam.DEFAULT_MAX_CYCLE_DETECTION_ALERTS; } } diff --git a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/automation/GraphQlJobDialog.java b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/automation/GraphQlJobDialog.java index 3e989dd59c4..b6bc5ea53c2 100644 --- a/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/automation/GraphQlJobDialog.java +++ b/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/automation/GraphQlJobDialog.java @@ -23,11 +23,7 @@ import java.io.File; import java.util.Arrays; import javax.swing.DefaultComboBoxModel; -import javax.swing.DefaultListCellRenderer; -import javax.swing.JComboBox; import javax.swing.JFileChooser; -import javax.swing.JLabel; -import javax.swing.JList; import javax.swing.JTextField; import org.parosproxy.paros.view.View; import org.zaproxy.addon.automation.jobs.JobUtils; @@ -40,10 +36,10 @@ public class GraphQlJobDialog extends StandardFieldsDialog { private static final long serialVersionUID = 1L; - private static final String QUERY_GEN_CONFIG_TAB_LABEL = - "graphql.automation.dialog.tab.queryGenConfig"; private static final String[] TAB_LABELS = { - "graphql.automation.dialog.tab.params", QUERY_GEN_CONFIG_TAB_LABEL + "graphql.automation.dialog.tab.params", + "graphql.automation.dialog.tab.queryGenConfig", + "graphql.automation.dialog.tab.cycleDetectionConfig" }; private static final String TITLE = "graphql.automation.dialog.title"; @@ -64,12 +60,17 @@ public class GraphQlJobDialog extends StandardFieldsDialog { private static final String ARGS_TYPE_PARAM = "graphql.automation.dialog.argstype"; private static final String QUERY_SPLIT_TYPE_PARAM = "graphql.automation.dialog.querysplittype"; private static final String REQUEST_METHOD_PARAM = "graphql.automation.dialog.requestmethod"; + private static final String CYCLE_DETECTION_MODE_PARAM = + "graphql.automation.dialog.cycleDetectionMode"; + private static final String MAX_CYCLE_DETECTION_ALERTS_PARAM = + "graphql.automation.dialog.maxCycleAlerts"; private GraphQlJob job; private DefaultComboBoxModel argsTypeModel; private DefaultComboBoxModel querySplitModel; private DefaultComboBoxModel requestMethodModel; + private DefaultComboBoxModel cycleDetectionModel; public GraphQlJobDialog(GraphQlJob job) { super( @@ -79,6 +80,7 @@ public GraphQlJobDialog(GraphQlJob job) { TAB_LABELS); this.job = job; + /* Parameters Tab */ this.addTextField(0, NAME_PARAM, this.job.getData().getName()); // Cannot select the node as it might not be present in the Sites tree this.addNodeSelectField(0, ENDPOINT_PARAM, null, true, false); @@ -106,13 +108,9 @@ public GraphQlJobDialog(GraphQlJob job) { 0, QUERY_GEN_ENABLED_PARAM, JobUtils.unBox(this.job.getParameters().getQueryGenEnabled())); - - this.addFieldListener( - QUERY_GEN_ENABLED_PARAM, - e -> showQueryGenConfigTab(getBoolValue(QUERY_GEN_ENABLED_PARAM))); - this.addPadding(0); + /* Query Generator Config Tab */ this.addNumberField( 1, MAX_QUERY_DEPTH_PARAM, @@ -140,135 +138,57 @@ public GraphQlJobDialog(GraphQlJob job) { OPTIONAL_ARGS_ENABLED_PARAM, JobUtils.unBox(this.job.getParameters().getOptionalArgsEnabled())); - argsTypeModel = new DefaultComboBoxModel(); + argsTypeModel = new DefaultComboBoxModel<>(); Arrays.stream(GraphQlParam.ArgsTypeOption.values()) .forEach(v -> argsTypeModel.addElement(v)); - DefaultListCellRenderer argsTypeRenderer = - new DefaultListCellRenderer() { - private static final long serialVersionUID = 1L; - - @Override - public Component getListCellRendererComponent( - JList list, - Object value, - int index, - boolean isSelected, - boolean cellHasFocus) { - JLabel label = - (JLabel) - super.getListCellRendererComponent( - list, value, index, isSelected, cellHasFocus); - if (value instanceof GraphQlParam.ArgsTypeOption) { - // The name is i18n'ed - label.setText(((GraphQlParam.ArgsTypeOption) value).getName()); - } - return label; - } - }; - - GraphQlParam.ArgsTypeOption hpo = null; - if (this.job.getParameters().getArgsType() != null) { - hpo = - GraphQlParam.ArgsTypeOption.valueOf( - this.job.getParameters().getArgsType().toUpperCase()); - } else { - hpo = GraphQlParam.ArgsTypeOption.BOTH; - } - argsTypeModel.setSelectedItem(hpo); + argsTypeModel.setSelectedItem( + this.job.getParameters().getArgsType() != null + ? GraphQlParam.ArgsTypeOption.valueOf( + this.job.getParameters().getArgsType().toUpperCase()) + : GraphQlParam.ArgsTypeOption.BOTH); this.addComboField(1, ARGS_TYPE_PARAM, argsTypeModel); - Component acField = this.getField(ARGS_TYPE_PARAM); - if (acField instanceof JComboBox) { - ((JComboBox) acField).setRenderer(argsTypeRenderer); - } - querySplitModel = new DefaultComboBoxModel(); + querySplitModel = new DefaultComboBoxModel<>(); Arrays.stream(GraphQlParam.QuerySplitOption.values()) .forEach(v -> querySplitModel.addElement(v)); - DefaultListCellRenderer querySplitRenderer = - new DefaultListCellRenderer() { - private static final long serialVersionUID = 1L; - - @Override - public Component getListCellRendererComponent( - JList list, - Object value, - int index, - boolean isSelected, - boolean cellHasFocus) { - JLabel label = - (JLabel) - super.getListCellRendererComponent( - list, value, index, isSelected, cellHasFocus); - if (value instanceof GraphQlParam.QuerySplitOption) { - // The name is i18n'ed - label.setText(((GraphQlParam.QuerySplitOption) value).getName()); - } - return label; - } - }; - - GraphQlParam.QuerySplitOption qso = null; - if (this.job.getParameters().getQuerySplitType() != null) { - qso = - GraphQlParam.QuerySplitOption.valueOf( - this.job.getParameters().getQuerySplitType().toUpperCase()); - } else { - qso = GraphQlParam.QuerySplitOption.LEAF; - } - querySplitModel.setSelectedItem(qso); + querySplitModel.setSelectedItem( + this.job.getParameters().getQuerySplitType() != null + ? GraphQlParam.QuerySplitOption.valueOf( + this.job.getParameters().getQuerySplitType().toUpperCase()) + : GraphQlParam.QuerySplitOption.LEAF); this.addComboField(1, QUERY_SPLIT_TYPE_PARAM, querySplitModel); - Component qsField = this.getField(QUERY_SPLIT_TYPE_PARAM); - if (acField instanceof JComboBox) { - ((JComboBox) qsField).setRenderer(querySplitRenderer); - } - requestMethodModel = new DefaultComboBoxModel(); + requestMethodModel = new DefaultComboBoxModel<>(); Arrays.stream(GraphQlParam.RequestMethodOption.values()) .forEach(v -> requestMethodModel.addElement(v)); - DefaultListCellRenderer requestMethodRenderer = - new DefaultListCellRenderer() { - private static final long serialVersionUID = 1L; - - @Override - public Component getListCellRendererComponent( - JList list, - Object value, - int index, - boolean isSelected, - boolean cellHasFocus) { - JLabel label = - (JLabel) - super.getListCellRendererComponent( - list, value, index, isSelected, cellHasFocus); - if (value instanceof GraphQlParam.RequestMethodOption) { - // The name is i18n'ed - label.setText(((GraphQlParam.RequestMethodOption) value).getName()); - } - return label; - } - }; - - GraphQlParam.RequestMethodOption rmo = null; - if (this.job.getParameters().getRequestMethod() != null) { - rmo = - GraphQlParam.RequestMethodOption.valueOf( - this.job.getParameters().getRequestMethod().toUpperCase()); - } else { - rmo = GraphQlParam.RequestMethodOption.POST_JSON; - } - requestMethodModel.setSelectedItem(rmo); + requestMethodModel.setSelectedItem( + this.job.getParameters().getRequestMethod() != null + ? GraphQlParam.RequestMethodOption.valueOf( + this.job.getParameters().getRequestMethod().toUpperCase()) + : GraphQlParam.RequestMethodOption.POST_JSON); this.addComboField(1, REQUEST_METHOD_PARAM, requestMethodModel); - Component rmField = this.getField(REQUEST_METHOD_PARAM); - if (acField instanceof JComboBox) { - ((JComboBox) rmField).setRenderer(requestMethodRenderer); - } + this.addPadding(1); - showQueryGenConfigTab(getBoolValue(QUERY_GEN_ENABLED_PARAM)); - } + /* Cycle Detection Config Tab */ + cycleDetectionModel = new DefaultComboBoxModel<>(); + Arrays.stream(GraphQlParam.CycleDetectionModeOption.values()) + .forEach(cycleDetectionModel::addElement); + cycleDetectionModel.setSelectedItem( + this.job.getParameters().getCycleDetectionMode() != null + ? GraphQlParam.CycleDetectionModeOption.valueOf( + this.job.getParameters().getCycleDetectionMode().toUpperCase()) + : GraphQlParam.CycleDetectionModeOption.QUICK); + this.addComboField(2, CYCLE_DETECTION_MODE_PARAM, cycleDetectionModel); + + this.addNumberField( + 2, + MAX_CYCLE_DETECTION_ALERTS_PARAM, + 0, + Integer.MAX_VALUE, + JobUtils.unBox(this.job.getParameters().getMaxCycleDetectionAlerts())); - private void showQueryGenConfigTab(boolean visible) { - this.setTabsVisible(new String[] {QUERY_GEN_CONFIG_TAB_LABEL}, visible); + this.addPadding(2); } @Override @@ -311,6 +231,16 @@ public void save() { this.job.getParameters().setRequestMethod(rm.name().toLowerCase()); } + Object cdmObj = cycleDetectionModel.getSelectedItem(); + if (cdmObj instanceof GraphQlParam.CycleDetectionModeOption) { + GraphQlParam.CycleDetectionModeOption cdm = + (GraphQlParam.CycleDetectionModeOption) cdmObj; + this.job.getParameters().setCycleDetectionMode(cdm.name().toLowerCase()); + } + this.job + .getParameters() + .setMaxCycleDetectionAlerts(this.getIntValue(MAX_CYCLE_DETECTION_ALERTS_PARAM)); + } else { this.job.getParameters().setMaxQueryDepth(null); this.job.getParameters().setLenientMaxQueryDepthEnabled(null); @@ -320,6 +250,8 @@ public void save() { this.job.getParameters().setArgsType(null); this.job.getParameters().setQuerySplitType(null); this.job.getParameters().setRequestMethod(null); + this.job.getParameters().setCycleDetectionMode(null); + this.job.getParameters().setMaxCycleDetectionAlerts(null); } this.job.resetAndSetChanged(); } diff --git a/addOns/graphql/src/main/javahelp/org/zaproxy/addon/graphql/resources/help/contents/alerts.html b/addOns/graphql/src/main/javahelp/org/zaproxy/addon/graphql/resources/help/contents/alerts.html index 8160893deda..245f2ab0326 100644 --- a/addOns/graphql/src/main/javahelp/org/zaproxy/addon/graphql/resources/help/contents/alerts.html +++ b/addOns/graphql/src/main/javahelp/org/zaproxy/addon/graphql/resources/help/contents/alerts.html @@ -25,6 +25,14 @@

GraphQL Alerts

fingerprinting techniques adapted from the tool graphw00f.
Note: If the Tech Detection (Wappalyzer) add-on is installed the fingerprinter will also add identified GraphQL Engines to the Technology tab/data. GraphQlFingerprinter.java + + 50007-3 + GraphQL Circular References in Schema + This alert is raised when cycles are found in the object types in the imported GraphQL schema. + A new alert is raised for each unique cycle. + The alert contains information about the cycle and a message with an example query. + No requests are actually sent. + GraphQlCycleDetector.java
diff --git a/addOns/graphql/src/main/javahelp/org/zaproxy/addon/graphql/resources/help/contents/automation.html b/addOns/graphql/src/main/javahelp/org/zaproxy/addon/graphql/resources/help/contents/automation.html index 3d3b4b60d8c..87cfd79146e 100644 --- a/addOns/graphql/src/main/javahelp/org/zaproxy/addon/graphql/resources/help/contents/automation.html +++ b/addOns/graphql/src/main/javahelp/org/zaproxy/addon/graphql/resources/help/contents/automation.html @@ -32,6 +32,8 @@

Job: graphql

argsType: # Enum [inline, variables, both]: How arguments are specified, default: both querySplitType: # Enum [leaf, root_field, operation]: The level for which a single query is generated, default: leaf requestMethod: # Enum [post_json, post_graphql, get]: The request method, default: post_json + cycleDetectionMode: # Enum [disabled, quick, exhaustive]: The cycle detection mode, default: quick + maxCycleDetectionAlerts: # Int: The maximum number of alerts to raise for detected cycles, default: 100

See also

diff --git a/addOns/graphql/src/main/resources/org/zaproxy/addon/graphql/resources/Messages.properties b/addOns/graphql/src/main/resources/org/zaproxy/addon/graphql/resources/Messages.properties index bab12a236a1..26bf0c5722f 100644 --- a/addOns/graphql/src/main/resources/org/zaproxy/addon/graphql/resources/Messages.properties +++ b/addOns/graphql/src/main/resources/org/zaproxy/addon/graphql/resources/Messages.properties @@ -6,12 +6,16 @@ graphql.api.action.importUrl.param.endurl = The Endpoint URL. graphql.api.action.importUrl.param.url = The URL Locating the GraphQL Schema. graphql.api.action.setOptionArgsType = Sets how arguments are specified. graphql.api.action.setOptionArgsType.param.String = Can be "INLINE", "VARIABLES", or "BOTH". +graphql.api.action.setOptionCycleDetectionMode = Sets the thoroughness of type reference cycle detection in an imported GraphQL schema. +graphql.api.action.setOptionCycleDetectionMode.param.String = Can be "DISABLED", "QUICK", or "EXHAUSTIVE". graphql.api.action.setOptionLenientMaxQueryDepthEnabled = Sets whether or not Maximum Query Depth is enforced leniently. graphql.api.action.setOptionLenientMaxQueryDepthEnabled.param.Boolean = Enforce Leniently (true or false). graphql.api.action.setOptionMaxAdditionalQueryDepth = Sets the maximum additional query generation depth (used if enforced leniently). graphql.api.action.setOptionMaxAdditionalQueryDepth.param.Integer = The Maximum Additional Depth. graphql.api.action.setOptionMaxArgsDepth = Sets the maximum arguments generation depth. graphql.api.action.setOptionMaxArgsDepth.param.Integer = The Maximum Depth. +graphql.api.action.setOptionMaxCycleDetectionAlerts = Sets the maximum number of alerts raised for detected type reference cycles in an imported schema. +graphql.api.action.setOptionMaxCycleDetectionAlerts.param.Integer = The Maximum Number of Alerts. graphql.api.action.setOptionMaxQueryDepth = Sets the maximum query generation depth. graphql.api.action.setOptionMaxQueryDepth.param.Integer = The Maximum Depth. graphql.api.action.setOptionOptionalArgsEnabled = Sets whether or not Optional Arguments should be specified. @@ -23,9 +27,11 @@ graphql.api.action.setOptionQuerySplitType.param.String = Can be "LEAF", "ROOT_F graphql.api.action.setOptionRequestMethod = Sets the request method. graphql.api.action.setOptionRequestMethod.param.String = Can be "POST_JSON", "POST_GRAPHQL", or "GET". graphql.api.view.optionArgsType = Returns how arguments are currently specified. +graphql.api.view.optionCycleDetectionMode = Returns the current cycle detection mode for an imported GraphQL schema. graphql.api.view.optionLenientMaxQueryDepthEnabled = Returns whether or not lenient maximum query generation depth is enabled. graphql.api.view.optionMaxAdditionalQueryDepth = Returns the current maximum additional query generation depth. graphql.api.view.optionMaxArgsDepth = Returns the current maximum arguments generation depth. +graphql.api.view.optionMaxCycleDetectionAlerts = Returns the current maximum limit for cycle detection alerts. graphql.api.view.optionMaxQueryDepth = Returns the current maximum query generation depth. graphql.api.view.optionOptionalArgsEnabled = Returns whether or not optional arguments are currently specified. graphql.api.view.optionQueryGenEnabled = Returns whether the query generator is enabled. @@ -34,19 +40,22 @@ graphql.api.view.optionRequestMethod = Returns the current request method. graphql.automation.desc = GraphQL Automation Framework Integration graphql.automation.dialog.argstype = Arguments Type: +graphql.automation.dialog.cycleDetectionMode = Mode: graphql.automation.dialog.endpoint = Endpoint: graphql.automation.dialog.lenientmaxquery = Lenient Max Query Depth Enabled: +graphql.automation.dialog.maxCycleAlerts = Max Alerts: graphql.automation.dialog.maxaddquerydepth = Max Additional Query Depth: graphql.automation.dialog.maxargsdepth = Max Arguments Depth: graphql.automation.dialog.maxquerydepth = Max Query Depth: graphql.automation.dialog.name = Job Name: graphql.automation.dialog.optargsenabled = Optional Arguments Enabled: -graphql.automation.dialog.querygen = Enable Query Generator: +graphql.automation.dialog.querygen = Generate Queries on Import: graphql.automation.dialog.querysplittype = Query Split Type: graphql.automation.dialog.requestmethod = Request Method: graphql.automation.dialog.schemafile = SchemaFile: graphql.automation.dialog.schemaurl = Schema URL: graphql.automation.dialog.summary = URL: {0}, File: {1} +graphql.automation.dialog.tab.cycleDetectionConfig = Cycle Detection Configuration graphql.automation.dialog.tab.params = Parameters graphql.automation.dialog.tab.queryGenConfig = Query Generator Configuration graphql.automation.dialog.title = GraphQL Job @@ -60,6 +69,11 @@ graphql.cmdline.endurl.help = Sets the Endpoint URL graphql.cmdline.file.help = Imports a GraphQL Schema from a File graphql.cmdline.url.help = Imports a GraphQL Schema from a URL +graphql.cycles.alert.desc = A circular reference was detected in the GraphQL schema, where object types reference each other in a cycle. This can be exploited by attackers to craft deeply recursive queries, potentially leading to Denial of Service (DoS) conditions. +graphql.cycles.alert.name = GraphQL Circular Type Reference +graphql.cycles.alert.ref = https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html#dos-prevention +graphql.cycles.alert.soln = Consider restructuring the schema to avoid circular references. Use IDs or foreign keys instead of direct object references. Enforce query depth limits and use pagination to control deep nested queries. + graphql.desc = Allows you to inspect and attack GraphQL endpoints. graphql.engine.absinthe.docsUrl = https://github.com/absinthe-graphql/absinthe @@ -233,14 +247,18 @@ graphql.introspection.alert.name = GraphQL Endpoint Supports Introspection graphql.introspection.alert.ref = https://spec.graphql.org/October2021/#sec-Introspection graphql.introspection.alert.soln = Disable Introspection on the GraphQL endpoint. +graphql.options.cycleDetectionConfigPanel.title = Cycle Detection Configuration +graphql.options.importConfigPanel.title = Import Configuration graphql.options.label.additionalQueryDepth = Additional Query Depth: graphql.options.label.argsDepth = Maximum Arguments Depth: graphql.options.label.argsType = Specify Arguments: +graphql.options.label.cycleDetectionMaxAlerts = Maximum Alerts: +graphql.options.label.cycleDetectionMode = Mode: graphql.options.label.lenientMaxQueryDepthEnabled = Lenient Maximum Query Depth graphql.options.label.lenientMaxQueryDepthEnabled.tooltip = Prevent invalid queries by allowing additional depth for fields with no leaf types. graphql.options.label.optionalArgsEnabled = Specify Optional Arguments graphql.options.label.queryDepth = Maximum Query Depth: -graphql.options.label.queryGenEnabled = Enable Query Generator +graphql.options.label.queryGenEnabled = Generate Queries on Import graphql.options.label.requestMethod = Request Method: graphql.options.label.split = Generate Query For: graphql.options.panelName = GraphQL @@ -248,6 +266,9 @@ graphql.options.queryGenConfigPanel.title = Query Generator Configuration graphql.options.value.args.both = Both Ways graphql.options.value.args.inline = Inline graphql.options.value.args.variables = Using Variables +graphql.options.value.cycleDetection.disabled = Disabled +graphql.options.value.cycleDetection.exhaustive = Exhaustive +graphql.options.value.cycleDetection.quick = Quick graphql.options.value.request.postJson = POST (with JSON body) graphql.options.value.split.get = GET graphql.options.value.split.leaf = Each Leaf (Scalar or Enum) diff --git a/addOns/graphql/src/main/resources/org/zaproxy/addon/graphql/resources/graphql-max.yaml b/addOns/graphql/src/main/resources/org/zaproxy/addon/graphql/resources/graphql-max.yaml index b330d8f4813..8c4e7302eeb 100644 --- a/addOns/graphql/src/main/resources/org/zaproxy/addon/graphql/resources/graphql-max.yaml +++ b/addOns/graphql/src/main/resources/org/zaproxy/addon/graphql/resources/graphql-max.yaml @@ -12,3 +12,5 @@ argsType: # Enum [inline, variables, both]: How arguments are specified, default: both querySplitType: # Enum [leaf, root_field, operation]: The level for which a single query is generated, default: leaf requestMethod: # Enum [post_json, post_graphql, get]: The request method, default: post_json + cycleDetectionMode: # Enum [disabled, quick, exhaustive]: The cycle detection mode, default: quick + maxCycleDetectionAlerts: # Int: The maximum number of alerts to raise for detected cycles, default: 100 diff --git a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlCycleDetectorUnitTest.java b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlCycleDetectorUnitTest.java new file mode 100644 index 00000000000..b5edbb64e5f --- /dev/null +++ b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlCycleDetectorUnitTest.java @@ -0,0 +1,160 @@ +/* + * 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.graphql; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.SchemaParser; +import graphql.schema.idl.UnExecutableSchemaGenerator; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.parosproxy.paros.control.Control; +import org.parosproxy.paros.core.scanner.Alert; +import org.parosproxy.paros.extension.ExtensionLoader; +import org.parosproxy.paros.model.Model; +import org.parosproxy.paros.network.HttpMessage; +import org.parosproxy.paros.network.HttpRequestHeader; +import org.zaproxy.addon.commonlib.ValueProvider; +import org.zaproxy.addon.graphql.GraphQlCycleDetector.GraphQlCycleDetectionResult; +import org.zaproxy.zap.extension.alert.ExtensionAlert; +import org.zaproxy.zap.network.HttpRequestBody; +import org.zaproxy.zap.testutils.TestUtils; + +class GraphQlCycleDetectorUnitTest extends TestUtils { + GraphQlParam param; + private ValueProvider valueProvider; + + @BeforeEach + void setup() throws Exception { + setUpZap(); + param = + new GraphQlParam( + true, + 5, + true, + 5, + 5, + true, + null, + null, + GraphQlParam.RequestMethodOption.POST_JSON, + GraphQlParam.CycleDetectionModeOption.EXHAUSTIVE, + 100); + valueProvider = mock(ValueProvider.class); + } + + @Test + void shouldDetectCycles() { + // Given + String sdl = getHtml("circularRelationship.graphql"); + GraphQLSchema schema = + UnExecutableSchemaGenerator.makeUnExecutableSchema(new SchemaParser().parse(sdl)); + var generator = new GraphQlGenerator(valueProvider, schema, null, param); + var cyclesDetector = new GraphQlCycleDetector(schema, generator, null, param); + List results = new ArrayList<>(); + // When + cyclesDetector.detectCycles(results::add); + // Then + assertThat( + results, + is( + equalTo( + List.of( + new GraphQlCycleDetectionResult( + "Query -> (Thread -> Message -> Thread)", + "query { thread { message { thread { id } } } }", + "{}"))))); + } + + @Test + void shouldRaiseAlertsForDetectedCycles() throws Exception { + // Given + ExtensionLoader extensionLoader = mock(ExtensionLoader.class); + Control.initSingletonForTesting(mock(Model.class), extensionLoader); + ExtensionAlert extAlert = mock(ExtensionAlert.class); + when(extensionLoader.getExtension(ExtensionAlert.class)).thenReturn(extAlert); + String sdl = getHtml("circularRelationship.graphql"); + GraphQLSchema schema = + UnExecutableSchemaGenerator.makeUnExecutableSchema(new SchemaParser().parse(sdl)); + var generator = new GraphQlGenerator(valueProvider, schema, null, param); + var queryMsgBuilder = + new GraphQlQueryMessageBuilder(UrlBuilder.build("https://example.com/graphql")); + var cyclesDetector = new GraphQlCycleDetector(schema, generator, queryMsgBuilder, param); + // When + cyclesDetector.detectCycles(); + // Then + ArgumentCaptor alertCaptor = ArgumentCaptor.forClass(Alert.class); + verify(extAlert).alertFound(alertCaptor.capture(), isNull()); + assertThat( + alertCaptor.getValue(), + is( + equalTo( + Alert.builder() + .setPluginId(50007) + .setAlertRef("50007-3") + .setName("!graphql.cycles.alert.name!") + .setDescription("!graphql.cycles.alert.desc!") + .setReference("!graphql.cycles.alert.ref!") + .setSolution("!graphql.cycles.alert.soln!") + .setConfidence(Alert.CONFIDENCE_HIGH) + .setRisk(Alert.RISK_INFO) + .setCweId(16) + .setWascId(15) + .setSource(Alert.Source.TOOL) + .setTags( + Map.of( + "OWASP_2023_API4", + "https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/", + "OWASP_2021_A04", + "https://owasp.org/Top10/A04_2021-Insecure_Design/", + "WSTG-v42-APIT-01", + "https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/12-API_Testing/01-Testing_GraphQL", + "CWE-16", + "https://cwe.mitre.org/data/definitions/16.html")) + .setOtherInfo("Query -> (Thread -> Message -> Thread)") + .setMessage( + new HttpMessage( + new HttpRequestHeader( + """ + POST https://example.com/graphql HTTP/1.1 + host: example.com + user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 + pragma: no-cache + cache-control: no-cache + content-type: application/json + Accept: application/json + content-length: 73 + """), + new HttpRequestBody( + "{\"query\":\"query { thread { message { thread { id } } } }\",\"variables\":{}}"))) + .build()))); + } +} diff --git a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlFingerprinterUnitTest.java b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlFingerprinterUnitTest.java index 6f13065c395..29314b2ac93 100644 --- a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlFingerprinterUnitTest.java +++ b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlFingerprinterUnitTest.java @@ -44,6 +44,8 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; +import org.apache.commons.httpclient.URI; +import org.apache.commons.httpclient.URIException; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; @@ -63,6 +65,7 @@ import org.parosproxy.paros.core.scanner.Alert; import org.parosproxy.paros.extension.ExtensionLoader; import org.parosproxy.paros.model.Model; +import org.parosproxy.paros.network.HttpSender; import org.zaproxy.addon.graphql.GraphQlFingerprinter.DiscoveredGraphQlEngine; import org.zaproxy.zap.extension.alert.ExtensionAlert; import org.zaproxy.zap.testutils.NanoServerHandler; @@ -123,7 +126,7 @@ protected NanoHTTPD.Response serve(NanoHTTPD.IHTTPSession session) { "{\"errors\": [{\"code\":\"Oh no! Something went wrong.\"}]}"); } }); - var fp = new GraphQlFingerprinter(UrlBuilder.build(endpointUrl)); + var fp = buildFingerprinter(endpointUrl); // When fp.sendQuery("{zaproxy}"); // Then @@ -151,7 +154,7 @@ int getRequestCount() { } }; nano.addHandler(handler); - var fp = new GraphQlFingerprinter(UrlBuilder.build(endpointUrl)); + var fp = buildFingerprinter(endpointUrl); // When fp.sendQuery("{count}"); fp.sendQuery("{count}"); @@ -166,7 +169,7 @@ void shouldSendQuery() throws Exception { nano.addHandler( new StaticContentServerHandler( "/graphql", "{\"data\": {\"__typename\": \"Query\"}}")); - var fp = new GraphQlFingerprinter(UrlBuilder.build(endpointUrl)); + var fp = buildFingerprinter(endpointUrl); // When fp.sendQuery("{__typename}"); // Then @@ -178,7 +181,7 @@ void shouldFingerprintWithInvalidData() throws Exception { // Given ExtensionAlert extensionAlert = mockExtensionAlert(); nano.addHandler(new GraphQlResponseHandler("{ not actual json… }")); - var fp = new GraphQlFingerprinter(UrlBuilder.build(endpointUrl)); + var fp = buildFingerprinter(endpointUrl); // When fp.fingerprint(); // Then @@ -348,7 +351,7 @@ private static String errorResponse(String error, String field, boolean data) { void shouldFingerprintValidData(String graphqlImpl, String response) throws Exception { // Given nano.addHandler(new GraphQlResponseHandler(response)); - var fp = new GraphQlFingerprinter(UrlBuilder.build(endpointUrl)); + var fp = buildFingerprinter(endpointUrl); List discoveredEngine = new ArrayList<>(1); GraphQlFingerprinter.addEngineHandler(discoveredEngine::add); // When @@ -367,9 +370,8 @@ void shouldFingerprintValidData(String graphqlImpl, String response) throws Exce void shouldFingerprintWithoutAddedHandler() throws Exception { // Given ExtensionAlert extensionAlert = mockExtensionAlert(); - var url = UrlBuilder.build(endpointUrl); nano.addHandler(new GraphQlResponseHandler(errorResponse("The query must be a string."))); - var fp = new GraphQlFingerprinter(url); + var fp = buildFingerprinter(endpointUrl); // When fp.fingerprint(); // Then @@ -380,9 +382,8 @@ void shouldFingerprintWithoutAddedHandler() throws Exception { void shouldFingerprintAfterHandlerReset() throws Exception { // Given ExtensionAlert extensionAlert = mockExtensionAlert(); - var url = UrlBuilder.build(endpointUrl); nano.addHandler(new GraphQlResponseHandler(errorResponse("The query must be a string."))); - var fp = new GraphQlFingerprinter(url); + var fp = buildFingerprinter(endpointUrl); // When GraphQlFingerprinter.resetHandlers(); fp.fingerprint(); @@ -431,4 +432,14 @@ protected Response serve(IHTTPSession session) { NanoHTTPD.Response.Status.OK, "application/json", response); } } + + private static GraphQlFingerprinter buildFingerprinter(String endpointUrlStr) + throws URIException { + URI endpointUri = UrlBuilder.build(endpointUrlStr); + Requestor requestor = + new Requestor( + new GraphQlQueryMessageBuilder(endpointUri), + HttpSender.MANUAL_REQUEST_INITIATOR); + return new GraphQlFingerprinter(endpointUri, requestor); + } } diff --git a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlGeneratorUnitTest.java b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlGeneratorUnitTest.java index ded7403011b..c01303b0e46 100644 --- a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlGeneratorUnitTest.java +++ b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlGeneratorUnitTest.java @@ -38,7 +38,7 @@ class GraphQlGeneratorUnitTest extends TestUtils { @BeforeEach void setup() throws Exception { setUpZap(); - param = new GraphQlParam(true, 5, true, 5, 5, true, null, null, null); + param = new GraphQlParam(true, 5, true, 5, 5, true, null, null, null, null, 0); valueProvider = mock(ValueProvider.class); } @@ -313,7 +313,7 @@ void variableNamesClash() { @Test void lenientDepthDeepNestedLeaf() { - param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null); + param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null, null, 0); generator = createGraphQlGenerator(getHtml("deepNestedLeaf.graphql")); String query = generator.generate(GraphQlGenerator.RequestType.QUERY); String expectedQuery = @@ -323,7 +323,7 @@ void lenientDepthDeepNestedLeaf() { @Test void strictDepthScalarArguments() { - param = new GraphQlParam(true, 1, false, 5, 5, true, null, null, null); + param = new GraphQlParam(true, 1, false, 5, 5, true, null, null, null, null, 0); generator = createGraphQlGenerator(getHtml("scalarArguments.graphql")); String query = generator.generate(GraphQlGenerator.RequestType.QUERY); String expectedQuery = "query { polygon (sides: 1, regular: true) } "; @@ -332,7 +332,7 @@ void strictDepthScalarArguments() { @Test void lenientDepthScalarArguments() { - param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null); + param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null, null, 0); generator = createGraphQlGenerator(getHtml("scalarArguments.graphql")); String query = generator.generate(GraphQlGenerator.RequestType.QUERY); String expectedQuery = "query { polygon (sides: 1, regular: true) { perimeter } } "; @@ -341,7 +341,7 @@ void lenientDepthScalarArguments() { @Test void lenientDepthObjectsImplementInterface() { - param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null); + param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null, null, 0); generator = createGraphQlGenerator(getHtml("objectsImplementInterface.graphql")); String query = generator.generate(GraphQlGenerator.RequestType.QUERY); String expectedQuery = "query { character { ... on Hero { id } } } "; @@ -350,7 +350,7 @@ void lenientDepthObjectsImplementInterface() { @Test void lenientDepthUnionType() { - param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null); + param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null, null, 0); generator = createGraphQlGenerator(getHtml("unionType.graphql")); String query = generator.generate(GraphQlGenerator.RequestType.QUERY); String expectedQuery = "query { firstSearchResult { ... on Photo { height } } } "; @@ -359,7 +359,7 @@ void lenientDepthUnionType() { @Test void lenientDepthEnumType() { - param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null); + param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null, null, 0); generator = createGraphQlGenerator(getHtml("enumType.graphql")); String query = generator.generate(GraphQlGenerator.RequestType.QUERY); String expectedQuery = "query { direction } "; @@ -368,7 +368,7 @@ void lenientDepthEnumType() { @Test void lenientDepthScalarArgumentsVariables() { - param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null); + param = new GraphQlParam(true, 0, true, 5, 5, true, null, null, null, null, 0); generator = createGraphQlGenerator(getHtml("scalarArguments.graphql")); String[] request = generator.generateWithVariables(GraphQlGenerator.RequestType.QUERY); String expectedQuery = @@ -381,7 +381,7 @@ void lenientDepthScalarArgumentsVariables() { @Test void lenientDepthExceeded() { - param = new GraphQlParam(true, 0, true, 3, 5, true, null, null, null); + param = new GraphQlParam(true, 0, true, 3, 5, true, null, null, null, null, 0); generator = createGraphQlGenerator(getHtml("deepNestedLeaf.graphql")); String query = generator.generate(GraphQlGenerator.RequestType.QUERY); String expectedQuery = "query "; diff --git a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlParamUnitTest.java b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlParamUnitTest.java index 675b8e32165..64fc6278a1c 100644 --- a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlParamUnitTest.java +++ b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlParamUnitTest.java @@ -23,13 +23,18 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import java.util.Locale; +import org.apache.commons.configuration.ConfigurationUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.parosproxy.paros.Constant; import org.zaproxy.addon.graphql.GraphQlParam.ArgsTypeOption; +import org.zaproxy.addon.graphql.GraphQlParam.CycleDetectionModeOption; import org.zaproxy.addon.graphql.GraphQlParam.QuerySplitOption; import org.zaproxy.addon.graphql.GraphQlParam.RequestMethodOption; +import org.zaproxy.zap.utils.I18N; import org.zaproxy.zap.utils.ZapXmlConfiguration; /** Unit test for {@link GraphQlParam}. */ @@ -44,6 +49,7 @@ class GraphQlParamUnitTest { @BeforeEach void setUp() { + Constant.messages = new I18N(Locale.ROOT); options = new GraphQlParam(); config = new ZapXmlConfiguration(); options.load(config); @@ -59,7 +65,7 @@ void shouldHaveConfigVersionKey() { void shouldLoadConfigWithArgsTypeOption(ArgsTypeOption value) { // Given options = new GraphQlParam(); - config.setProperty(PARAM_ARGS_TYPE, value.toString()); + config.setProperty(PARAM_ARGS_TYPE, value.name()); // When options.load(config); // Then @@ -82,7 +88,7 @@ void shouldUseDefaultWithInvalidArgsTypeOption() { void shouldLoadConfigWithQuerySplitOption(QuerySplitOption value) { // Given options = new GraphQlParam(); - config.setProperty(PARAM_QUERY_SPLIT_TYPE, value.toString()); + config.setProperty(PARAM_QUERY_SPLIT_TYPE, value.name()); // When options.load(config); // Then @@ -105,7 +111,7 @@ void shouldUseDefaultWithInvalidQuerySplitOption() { void shouldLoadConfigWithRequestMethodOption(RequestMethodOption value) { // Given options = new GraphQlParam(); - config.setProperty(PARAM_REQUEST_METHOD, value.toString()); + config.setProperty(PARAM_REQUEST_METHOD, value.name()); // When options.load(config); // Then @@ -122,4 +128,42 @@ void shouldUseDefaultWithInvalidRequestMethodOption() { // Then assertThat(options.getRequestMethod(), is(equalTo(RequestMethodOption.POST_JSON))); } + + @Test + void shouldWriteConfigCorrectly() { + // Given + options = new GraphQlParam(); + config = new ZapXmlConfiguration(); + // When + options.load(config); + options.setQueryGenEnabled(false); + options.setMaxQueryDepth(1337); + options.setLenientMaxQueryDepthEnabled(false); + options.setMaxAdditionalQueryDepth(42); + options.setMaxArgsDepth(21); + options.setOptionalArgsEnabled(false); + options.setArgsType(ArgsTypeOption.INLINE); + options.setQuerySplitType(QuerySplitOption.ROOT_FIELD); + options.setRequestMethod(RequestMethodOption.POST_GRAPHQL); + options.setCycleDetectionMode(CycleDetectionModeOption.EXHAUSTIVE); + options.setMaxCycleDetectionAlerts(9999); + // Then + assertThat( + ConfigurationUtils.toString(config), + is( + equalTo( + """ +graphql.queryGenEnabled=false +graphql.maxQueryDepth=1337 +graphql.lenientMaxQueryDepth=false +graphql.maxAdditionalQueryDepth=42 +graphql.maxArgsDepth=21 +graphql.optionalArgs=false +graphql.argsType=INLINE +graphql.querySplitType=ROOT_FIELD +graphql.requestMethod=POST_GRAPHQL +graphql.cycleDetectionMode=EXHAUSTIVE +graphql.cycleDetectionMaxAlerts=9999 +graphql[@version]=2"""))); + } } diff --git a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlParserUnitTest.java b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlParserUnitTest.java index 96be738d4c9..4e6d3f09236 100644 --- a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlParserUnitTest.java +++ b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/GraphQlParserUnitTest.java @@ -27,6 +27,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.io.IOException; import java.util.Locale; @@ -37,6 +38,8 @@ import org.parosproxy.paros.Constant; import org.parosproxy.paros.control.Control; import org.parosproxy.paros.core.scanner.Alert; +import org.zaproxy.addon.commonlib.DefaultValueProvider; +import org.zaproxy.addon.commonlib.ExtensionCommonlib; import org.zaproxy.zap.extension.alert.ExtensionAlert; import org.zaproxy.zap.testutils.StaticContentServerHandler; import org.zaproxy.zap.testutils.TestUtils; @@ -44,7 +47,7 @@ class GraphQlParserUnitTest extends TestUtils { - String endpointUrl; + private String endpointUrl; @BeforeEach void setup() throws Exception { @@ -100,6 +103,9 @@ void shouldRaiseAlertWhenSpecified() throws Exception { GraphQlParser gqp = new GraphQlParser(endpointUrl); var extAlert = mock(ExtensionAlert.class); Control.getSingleton().getExtensionLoader().addExtension(extAlert); + var extCommonLib = mock(ExtensionCommonlib.class); + when(extCommonLib.getValueProvider()).thenReturn(new DefaultValueProvider()); + Control.getSingleton().getExtensionLoader().addExtension(extCommonLib); // When gqp.introspect(true); // Then @@ -119,6 +125,9 @@ void shouldImportIntrospectionResponse() throws Exception { "type Query {\n" + " searchSongsByLyrics(lyrics: String = \"Never gonna give you up\"): String\n" + "}"; + var extCommonLib = mock(ExtensionCommonlib.class); + when(extCommonLib.getValueProvider()).thenReturn(new DefaultValueProvider()); + Control.getSingleton().getExtensionLoader().addExtension(extCommonLib); // When gqp.importFile(getResourcePath("introspectionResponse.json").toString()); // Then diff --git a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/automation/GraphQlJobUnitTest.java b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/automation/GraphQlJobUnitTest.java index b06215ad3aa..d7dee7b5080 100644 --- a/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/automation/GraphQlJobUnitTest.java +++ b/addOns/graphql/src/test/java/org/zaproxy/addon/graphql/automation/GraphQlJobUnitTest.java @@ -247,7 +247,7 @@ void shouldReturnConfigParams() { job.getConfigParameters(new GraphQlParamWrapper(), job.getParamMethodName()); // Then - assertThat(params.size(), is(equalTo(9))); + assertThat(params.size(), is(equalTo(11))); assertThat(params.containsKey("queryGenEnabled"), is(equalTo(true))); assertThat(params.containsKey("argsType"), is(equalTo(true))); assertThat(params.containsKey("lenientMaxQueryDepthEnabled"), is(equalTo(true))); @@ -257,6 +257,8 @@ void shouldReturnConfigParams() { assertThat(params.containsKey("optionalArgsEnabled"), is(equalTo(true))); assertThat(params.containsKey("querySplitType"), is(equalTo(true))); assertThat(params.containsKey("requestMethod"), is(equalTo(true))); + assertThat(params.containsKey("cycleDetectionMode"), is(equalTo(true))); + assertThat(params.containsKey("maxCycleDetectionAlerts"), is(equalTo(true))); } @Test