Skip to content

Commit 38632db

Browse files
authored
Merge pull request #3270 from akto-api-security/feature/improve-graphql-api-check
add more checks for graqhql api detection
2 parents 1c5c267 + c375153 commit 38632db

File tree

2 files changed

+314
-1
lines changed

2 files changed

+314
-1
lines changed

libs/utils/src/main/java/com/akto/graphql/GraphQLUtils.java

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public class GraphQLUtils {//Singleton class
2929

3030
static {
3131
allowedPath.add("graphql");
32+
allowedPath.add("query");
3233
}
3334

3435
private GraphQLUtils() {
@@ -38,6 +39,46 @@ public static GraphQLUtils getUtils() {
3839
return utils;
3940
}
4041

42+
/**
43+
* Validates if the parsed JSON object has valid GraphQL structure
44+
* Checks for: "query" field, valid GraphQL syntax (query/mutation keywords),
45+
* and either "operationName" or "variables" fields
46+
*/
47+
private boolean isValidGraphQLPayload(Map jsonObject, String path) {
48+
if (jsonObject == null) {
49+
return false;
50+
}
51+
52+
// If path contains "graphql", trust it's GraphQL without additional validation
53+
if (path != null && path.contains("graphql")) {
54+
return jsonObject.containsKey(QUERY);
55+
}
56+
57+
// For paths like "/query", do strict validation to avoid false positives
58+
Object queryObj = jsonObject.get(QUERY);
59+
if (queryObj == null || !(queryObj instanceof String)) {
60+
return false;
61+
}
62+
63+
String queryString = (String) queryObj;
64+
// Check if it contains GraphQL operation keywords
65+
String trimmedQuery = queryString.trim().toLowerCase();
66+
boolean hasGraphQLKeyword = trimmedQuery.startsWith("query") ||
67+
trimmedQuery.startsWith("mutation") ||
68+
trimmedQuery.startsWith("subscription") ||
69+
trimmedQuery.startsWith("{"); // Anonymous query
70+
71+
if (!hasGraphQLKeyword) {
72+
return false;
73+
}
74+
75+
// Additional check: Should have both operationName and variables fields (typical GraphQL structure)
76+
boolean hasOperationName = jsonObject.containsKey("operationName");
77+
boolean hasVariables = jsonObject.containsKey("variables");
78+
79+
return hasOperationName && hasVariables;
80+
}
81+
4182
public HashMap<String, Object> fieldTraversal(Field field) {
4283
HashMap<String, Object> map = new HashMap<>();
4384

@@ -96,7 +137,6 @@ public List<HttpResponseParams> parseGraphqlResponseParam(HttpResponseParams res
96137
}
97138
String requestPayload = responseParams.getRequestParams().getPayload();
98139
if (!isAllowedForParse || !requestPayload.contains(QUERY)) {
99-
// DO NOT PARSE as it's not graphql query
100140
return responseParamsList;
101141
}
102142

@@ -118,6 +158,22 @@ public List<HttpResponseParams> parseGraphqlResponseParam(HttpResponseParams res
118158
return responseParamsList;
119159
}
120160

161+
// Validate GraphQL structure after parsing (single parse, no duplication)
162+
if (listOfRequestPayload != null) {
163+
// For array payloads, validate first element
164+
if (listOfRequestPayload.length > 0 && listOfRequestPayload[0] instanceof Map) {
165+
if (!isValidGraphQLPayload((Map) listOfRequestPayload[0], path)) {
166+
return responseParamsList;
167+
}
168+
}
169+
} else if (mapOfRequestPayload != null) {
170+
if (!isValidGraphQLPayload(mapOfRequestPayload, path)) {
171+
return responseParamsList;
172+
}
173+
} else {
174+
return responseParamsList;
175+
}
176+
121177
if (listOfRequestPayload != null) {
122178
for (Object obj : listOfRequestPayload) {
123179
if (obj instanceof Map) {
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package com.akto.graphql;
2+
3+
import static org.junit.Assert.*;
4+
5+
import com.akto.dto.HttpRequestParams;
6+
import com.akto.dto.HttpResponseParams;
7+
import org.junit.Before;
8+
import org.junit.Test;
9+
10+
import java.util.HashMap;
11+
import java.util.List;
12+
import java.util.Map;
13+
import java.util.Arrays;
14+
15+
public class GraphQLUtilsTest {
16+
17+
private GraphQLUtils graphQLUtils;
18+
19+
@Before
20+
public void setup() {
21+
graphQLUtils = GraphQLUtils.getUtils();
22+
}
23+
24+
// Test 1: Valid GraphQL request with /query path (your real example)
25+
@Test
26+
public void testValidGraphQLWithQueryPath() {
27+
String payload = "{\n" +
28+
" \"operationName\": \"Offers\",\n" +
29+
" \"query\": \"query Offers {\\n offers {\\n getPromotions(placementIds: \\\"post-cashout\\\") {\\n promotions {\\n placement {\\n placementId\\n }\\n }\\n }\\n }\\n}\\n\",\n" +
30+
" \"variables\": {}\n" +
31+
"}";
32+
33+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query", payload);
34+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
35+
36+
assertNotNull("Result should not be null", result);
37+
assertFalse("Should parse valid GraphQL request with /query path", result.isEmpty());
38+
assertTrue("URL should be transformed to GraphQL endpoint path",
39+
result.get(0).getRequestParams().getURL().contains("/query/query/Offers/offers"));
40+
}
41+
42+
// Test 2: Valid GraphQL request with /graphql path
43+
@Test
44+
public void testValidGraphQLWithGraphqlPath() {
45+
String payload = "{\n" +
46+
" \"operationName\": \"GetUser\",\n" +
47+
" \"query\": \"query GetUser { user(id: 1) { name email } }\",\n" +
48+
" \"variables\": {}\n" +
49+
"}";
50+
51+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/graphql", payload);
52+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
53+
54+
assertNotNull(result);
55+
assertFalse("Should parse valid GraphQL request with /graphql path", result.isEmpty());
56+
}
57+
58+
// Test 3: GraphQL mutation
59+
@Test
60+
public void testValidGraphQLMutation() {
61+
String payload = "{\n" +
62+
" \"operationName\": \"CreateUser\",\n" +
63+
" \"query\": \"mutation CreateUser($name: String!) { createUser(name: $name) { id name } }\",\n" +
64+
" \"variables\": {\"name\": \"John\"}\n" +
65+
"}";
66+
67+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query", payload);
68+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
69+
70+
assertNotNull(result);
71+
assertFalse("Should parse valid GraphQL mutation", result.isEmpty());
72+
assertTrue("URL should contain mutation operation type",
73+
result.get(0).getRequestParams().getURL().contains("/mutation/"));
74+
}
75+
76+
// Test 4: GraphQL subscription
77+
@Test
78+
public void testValidGraphQLSubscription() {
79+
String payload = "{\n" +
80+
" \"operationName\": \"OnMessage\",\n" +
81+
" \"query\": \"subscription OnMessage { messageAdded { content } }\",\n" +
82+
" \"variables\": {}\n" +
83+
"}";
84+
85+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query", payload);
86+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
87+
88+
assertNotNull(result);
89+
assertFalse("Should parse valid GraphQL subscription", result.isEmpty());
90+
}
91+
92+
// Test 5: Anonymous GraphQL query (no operation name)
93+
@Test
94+
public void testAnonymousGraphQLQuery() {
95+
String payload = "{\n" +
96+
" \"operationName\": null,\n" +
97+
" \"query\": \"{ user(id: 1) { name } }\",\n" +
98+
" \"variables\": {}\n" +
99+
"}";
100+
101+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query", payload);
102+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
103+
104+
assertNotNull(result);
105+
assertFalse("Should parse anonymous GraphQL query starting with {", result.isEmpty());
106+
}
107+
108+
// Test 6: REST API with /query path - should NOT parse
109+
@Test
110+
public void testRestAPIWithQueryPath() {
111+
String payload = "{\n" +
112+
" \"search\": \"user\",\n" +
113+
" \"filters\": {\"active\": true}\n" +
114+
"}";
115+
116+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/api/query/search", payload);
117+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
118+
119+
assertNotNull(result);
120+
assertTrue("Should NOT parse REST API without 'query' keyword in payload", result.isEmpty());
121+
}
122+
123+
// Test 7: REST API with "query" field but not GraphQL - should NOT parse
124+
@Test
125+
public void testRestAPIWithQueryFieldNotGraphQL() {
126+
String payload = "{\n" +
127+
" \"query\": \"search term\",\n" +
128+
" \"limit\": 10\n" +
129+
"}";
130+
131+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/api/query/data", payload);
132+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
133+
134+
assertNotNull(result);
135+
assertTrue("Should NOT parse REST API with 'query' field that's not GraphQL", result.isEmpty());
136+
}
137+
138+
// Test 8: Missing operationName field - should NOT parse (strict validation for /query)
139+
@Test
140+
public void testMissingOperationName() {
141+
String payload = "{\n" +
142+
" \"query\": \"query GetUser { user(id: 1) { name } }\",\n" +
143+
" \"variables\": {}\n" +
144+
"}";
145+
146+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query", payload);
147+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
148+
149+
assertNotNull(result);
150+
assertTrue("Should NOT parse GraphQL without operationName for /query path", result.isEmpty());
151+
}
152+
153+
// Test 9: Missing variables field - should NOT parse (strict validation for /query)
154+
@Test
155+
public void testMissingVariables() {
156+
String payload = "{\n" +
157+
" \"operationName\": \"GetUser\",\n" +
158+
" \"query\": \"query GetUser { user(id: 1) { name } }\"\n" +
159+
"}";
160+
161+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query", payload);
162+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
163+
164+
assertNotNull(result);
165+
assertTrue("Should NOT parse GraphQL without variables for /query path", result.isEmpty());
166+
}
167+
168+
// Test 10: /graphql path with missing operationName - SHOULD parse (relaxed validation)
169+
@Test
170+
public void testGraphqlPathMissingOperationName() {
171+
String payload = "{\n" +
172+
" \"query\": \"query { user(id: 1) { name } }\",\n" +
173+
" \"variables\": {}\n" +
174+
"}";
175+
176+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/graphql", payload);
177+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
178+
179+
assertNotNull(result);
180+
assertFalse("Should parse GraphQL with /graphql path even without operationName", result.isEmpty());
181+
}
182+
183+
// Test 11: Empty payload
184+
@Test
185+
public void testEmptyPayload() {
186+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query", "");
187+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
188+
189+
assertNotNull(result);
190+
assertTrue("Should not parse empty payload", result.isEmpty());
191+
}
192+
193+
// Test 12: Invalid JSON payload
194+
@Test
195+
public void testInvalidJSON() {
196+
String payload = "{invalid json";
197+
198+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query", payload);
199+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
200+
201+
assertNotNull(result);
202+
assertTrue("Should not parse invalid JSON", result.isEmpty());
203+
}
204+
205+
// Test 13: Path without /query or /graphql
206+
@Test
207+
public void testPathWithoutQueryOrGraphql() {
208+
String payload = "{\n" +
209+
" \"operationName\": \"GetUser\",\n" +
210+
" \"query\": \"query GetUser { user(id: 1) { name } }\",\n" +
211+
" \"variables\": {}\n" +
212+
"}";
213+
214+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/api/users", payload);
215+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
216+
217+
assertNotNull(result);
218+
assertTrue("Should not parse when path doesn't contain /query or /graphql", result.isEmpty());
219+
}
220+
221+
// Test 14: GraphQL with query parameters in URL
222+
@Test
223+
public void testGraphQLWithQueryParams() {
224+
String payload = "{\n" +
225+
" \"operationName\": \"GetUser\",\n" +
226+
" \"query\": \"query GetUser { user(id: 1) { name } }\",\n" +
227+
" \"variables\": {}\n" +
228+
"}";
229+
230+
HttpResponseParams responseParams = createHttpResponseParams("https://api.example.com/query?version=v1&debug=true", payload);
231+
List<HttpResponseParams> result = graphQLUtils.parseGraphqlResponseParam(responseParams);
232+
233+
assertNotNull(result);
234+
assertFalse("Should parse GraphQL with query parameters in URL", result.isEmpty());
235+
assertTrue("Should preserve query parameters",
236+
result.get(0).getRequestParams().getURL().contains("version=v1"));
237+
}
238+
239+
// Helper method to create HttpResponseParams
240+
private HttpResponseParams createHttpResponseParams(String url, String payload) {
241+
HttpRequestParams requestParams = new HttpRequestParams();
242+
requestParams.setUrl(url);
243+
requestParams.setPayload(payload);
244+
requestParams.setMethod("POST");
245+
246+
Map<String, List<String>> headers = new HashMap<>();
247+
headers.put("content-type", Arrays.asList("application/json"));
248+
requestParams.setHeaders(headers);
249+
250+
HttpResponseParams responseParams = new HttpResponseParams();
251+
responseParams.requestParams = requestParams;
252+
responseParams.statusCode = 200;
253+
responseParams.setPayload("{}"); // Empty response for testing
254+
255+
return responseParams;
256+
}
257+
}

0 commit comments

Comments
 (0)