diff --git a/build.gradle b/build.gradle index 0100dc2..6c565a0 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,19 @@ dependencies { implementation "com.vladsch.flexmark:flexmark-all:0.64.0" implementation "org.json:json:20250107" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0' + + // Test dependencies + testImplementation 'org.jetbrains.kotlin:kotlin-test:2.1.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testImplementation 'org.mockito:mockito-core:5.11.0' + testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1' + testImplementation 'com.google.code.gson:gson:2.10.1' + testCompileOnly 'net.portswigger.burp.extensions:montoya-api:2023.5' + testRuntimeOnly 'net.portswigger.burp.extensions:montoya-api:2023.5' +} + +tasks.test { + useJUnitPlatform() } java { @@ -61,6 +74,11 @@ sourceSets { srcDirs = ['resources'] } } + test { + kotlin { + srcDir 'src/test/kotlin' + } + } } build { diff --git a/src/main/kotlin/inql/BurpScannerCheck.kt b/src/main/kotlin/inql/BurpScannerCheck.kt index 38b2801..2cea0a4 100644 --- a/src/main/kotlin/inql/BurpScannerCheck.kt +++ b/src/main/kotlin/inql/BurpScannerCheck.kt @@ -10,6 +10,7 @@ import burp.api.montoya.scanner.audit.insertionpoint.AuditInsertionPoint import burp.api.montoya.scanner.audit.issues.AuditIssue import burp.api.montoya.scanner.audit.issues.AuditIssueConfidence import burp.api.montoya.scanner.audit.issues.AuditIssueSeverity +import inql.graphql.scanners.BatchScanner class BurpScannerCheck : ScanCheck { companion object { @@ -70,6 +71,76 @@ class BurpScannerCheck : ScanCheck { val result = Burp.Montoya.http().sendRequest(newReq) issues.addAll(this.passiveAudit(result).auditIssues()) } + + // --- Batch Query Detection --- + val batchScanner = BatchScanner(baseRequestResponse.request()) + val batchResults = batchScanner.scan() + + for (result in batchResults) { + if (result.supported) { + val typeLabel = when (result.type) { + BatchScanner.BatchType.ALIAS -> "Alias-Based" + BatchScanner.BatchType.ARRAY -> "Array-Based" + } + val description = when (result.type) { + BatchScanner.BatchType.ALIAS -> """ + The GraphQL endpoint supports alias-based query batching.

+ An attacker can combine multiple operations into a single HTTP request using + GraphQL aliases (e.g., alias1: fieldName alias2: fieldName).

+ This can be exploited for:
+ + """.trimIndent() + BatchScanner.BatchType.ARRAY -> """ + The GraphQL endpoint supports array-based query batching.

+ An attacker can send a JSON array of independent query objects in a single + HTTP request. Each query executes separately on the server.

+ This can be exploited for:
+ + """.trimIndent() + } + + val remediation = """ + Consider implementing:
+ + """.trimIndent() + + issues.add( + AuditIssue.auditIssue( + "GraphQL $typeLabel Batch Query Support Detected", + description, + remediation, + baseRequestResponse.url(), + AuditIssueSeverity.LOW, + AuditIssueConfidence.CERTAIN, + null, + """""", + AuditIssueSeverity.LOW, + if (result.response != null) { + listOf(baseRequestResponse) + } else { + emptyList() + }, + ), + ) + } + } return AuditResult.auditResult(issues) } diff --git a/src/main/kotlin/inql/Config.kt b/src/main/kotlin/inql/Config.kt index f9b6910..2d743d7 100644 --- a/src/main/kotlin/inql/Config.kt +++ b/src/main/kotlin/inql/Config.kt @@ -44,6 +44,11 @@ class Config private constructor() { "report.poi.depth" to 2, "report.poi.format" to "text", + // Batch scanning + "report.batch" to true, + "report.batch.alias" to true, + "report.batch.array" to true, + // hooks on POIScanner.kt "report.poi.auth" to true, "report.poi.privileged" to true, diff --git a/src/main/kotlin/inql/graphql/scanners/BatchScanner.kt b/src/main/kotlin/inql/graphql/scanners/BatchScanner.kt new file mode 100644 index 0000000..b292274 --- /dev/null +++ b/src/main/kotlin/inql/graphql/scanners/BatchScanner.kt @@ -0,0 +1,255 @@ +package inql.graphql.scanners + +import burp.Burp +import burp.api.montoya.http.message.requests.HttpRequest +import burp.api.montoya.http.message.responses.HttpResponse +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import inql.Logger + +class BatchScanner(private val requestTemplate: HttpRequest) { + + enum class BatchType { ALIAS, ARRAY } + + data class BatchScanResult( + val type: BatchType, + val supported: Boolean, + val statusCode: Int, + val response: HttpResponse?, + val detail: String, + ) + + companion object { + // Minimal introspection query — guaranteed to work on any GraphQL server + private const val PROBE_QUERY = "__typename" + + /** + * Build the alias-batched query body. + * + * Produces: + * { + * "query": "query { alias1: __typename alias2: __typename }" + * } + * + * If the server resolves both aliases, batching via aliases is supported. + */ + fun buildAliasBatchBody(): String { + val obj = JsonObject() + obj.addProperty( + "query", + "query { inql_batch_alias1: $PROBE_QUERY inql_batch_alias2: $PROBE_QUERY }" + ) + return Gson().toJson(obj) + } + + /** + * Build the array-batched query body. + * + * Produces: + * [ + * { "query": "query { __typename }" }, + * { "query": "query { __typename }" } + * ] + * + * If the server returns an array of two results, array batching is supported. + */ + fun buildArrayBatchBody(): String { + val arr = JsonArray() + for (i in 1..2) { + val obj = JsonObject() + obj.addProperty("query", "query { $PROBE_QUERY }") + arr.add(obj) + } + return Gson().toJson(arr) + } + + /** + * Analyze whether the alias batch response succeeded. + * + * Success criteria: + * - HTTP 200 + * - Body is valid JSON + * - `data` object contains both `inql_batch_alias1` and `inql_batch_alias2` + * - No `errors` array, OR errors array is empty + */ + fun analyzeAliasBatchResponse(resp: HttpResponse): Pair { + if (resp.statusCode() !in 200..299) { + return false to "Non-2xx status code: ${resp.statusCode()}" + } + + return try { + val body = resp.bodyToString() + val json = JsonParser.parseString(body) + + if (!json.isJsonObject) { + return false to "Response body is not a JSON object" + } + + val root = json.asJsonObject + val data = root.getAsJsonObject("data") + ?: return false to "No 'data' field in response" + + val hasAlias1 = data.has("inql_batch_alias1") + val hasAlias2 = data.has("inql_batch_alias2") + + if (hasAlias1 && hasAlias2) { + // Check for errors + val errors = root.getAsJsonArray("errors") + if (errors != null && errors.size() > 0) { + false to "Both aliases resolved but errors present: ${errors.size()} error(s)" + } else { + true to "Both aliases resolved successfully" + } + } else { + false to "Missing aliases in response (alias1=$hasAlias1, alias2=$hasAlias2)" + } + } catch (e: Exception) { + false to "Failed to parse response: ${e.message}" + } + } + + /** + * Analyze whether the array batch response succeeded. + * + * Success criteria: + * - HTTP 200 + * - Body is a JSON array with exactly 2 elements + * - Each element has a `data` object + */ + fun analyzeArrayBatchResponse(resp: HttpResponse): Pair { + if (resp.statusCode() !in 200..299) { + return false to "Non-2xx status code: ${resp.statusCode()}" + } + + return try { + val body = resp.bodyToString() + val json = JsonParser.parseString(body) + + if (!json.isJsonArray) { + // Some servers return a single error object when array batching + // is disabled — that's a clear "not supported" + if (json.isJsonObject) { + val errors = json.asJsonObject.getAsJsonArray("errors") + if (errors != null && errors.size() > 0) { + return false to "Server returned error object instead of array" + } + } + return false to "Response body is not a JSON array" + } + + val arr = json.asJsonArray + if (arr.size() != 2) { + return false to "Expected 2 results, got ${arr.size()}" + } + + val allHaveData = arr.all { + it.isJsonObject && it.asJsonObject.has("data") + } + + if (allHaveData) { + true to "Server returned ${arr.size()} results in array" + } else { + false to "Array elements missing 'data' field" + } + } catch (e: Exception) { + false to "Failed to parse response: ${e.message}" + } + } + } + + /** + * Run both batch probes against the target and return results. + */ + fun scan(): List { + val results = mutableListOf() + + // --- Alias Batching --- + try { + val aliasBody = buildAliasBatchBody() + val aliasReq = requestTemplate + .withHeader("Content-Type", "application/json") + .withBody(aliasBody) + + Logger.debug("BatchScanner: sending alias batch probe to ${aliasReq.url()}") + val aliasResp = Burp.Montoya.http().sendRequest(aliasReq) + val resp = aliasResp.response() + + val (supported, detail) = analyzeAliasBatchResponse(resp) + results.add( + BatchScanResult( + type = BatchType.ALIAS, + supported = supported, + statusCode = resp.statusCode().toInt(), + response = resp, + detail = detail, + ) + ) + Logger.info("BatchScanner: alias batching ${if (supported) "SUPPORTED" else "not supported"} — $detail") + } catch (e: Exception) { + Logger.error("BatchScanner: alias probe failed: ${e.message}") + results.add( + BatchScanResult( + type = BatchType.ALIAS, + supported = false, + statusCode = 0, + response = null, + detail = "Request failed: ${e.message}", + ) + ) + } + + // --- Array Batching --- + try { + val arrayBody = buildArrayBatchBody() + val arrayReq = requestTemplate + .withHeader("Content-Type", "application/json") + .withBody(arrayBody) + + Logger.debug("BatchScanner: sending array batch probe to ${arrayReq.url()}") + val arrayResp = Burp.Montoya.http().sendRequest(arrayReq) + val resp = arrayResp.response() + + val (supported, detail) = analyzeArrayBatchResponse(resp) + results.add( + BatchScanResult( + type = BatchType.ARRAY, + supported = supported, + statusCode = resp.statusCode().toInt(), + response = resp, + detail = detail, + ) + ) + Logger.info("BatchScanner: array batching ${if (supported) "SUPPORTED" else "not supported"} — $detail") + } catch (e: Exception) { + Logger.error("BatchScanner: array probe failed: ${e.message}") + results.add( + BatchScanResult( + type = BatchType.ARRAY, + supported = false, + statusCode = 0, + response = null, + detail = "Request failed: ${e.message}", + ) + ) + } + + return results + } + + /** + * Return a human-readable summary of batch scan results. + */ + fun scanAsString(): String { + val results = scan() + return results.joinToString("\n") { r -> + val status = if (r.supported) "✓ SUPPORTED" else "✗ Not supported" + val typeLabel = when (r.type) { + BatchType.ALIAS -> "Alias Batching" + BatchType.ARRAY -> "Array Batching" + } + "$typeLabel: $status (HTTP ${r.statusCode}) — ${r.detail}" + } + } +} diff --git a/src/main/kotlin/inql/scanner/scanresults/ScanResultsTreeNode.kt b/src/main/kotlin/inql/scanner/scanresults/ScanResultsTreeNode.kt index 65b0738..44feeac 100644 --- a/src/main/kotlin/inql/scanner/scanresults/ScanResultsTreeNode.kt +++ b/src/main/kotlin/inql/scanner/scanresults/ScanResultsTreeNode.kt @@ -3,6 +3,7 @@ package inql.scanner.scanresults import com.google.gson.Gson import inql.Config import inql.graphql.GQLSchema +import inql.graphql.scanners.BatchScanner import inql.graphql.scanners.CyclesScanner import inql.graphql.scanners.POIScanner import inql.graphql.scanners.POIScanner.Companion.getActiveKeywordsCount @@ -199,6 +200,15 @@ class ScanResultTreeNode(val scanResult: ScanResult) : this.add(CycleDetectionLazyNode("Cycle Detection", this.scanResult, gqlSchema)) } + // Batch Query Detection + if (config.getBoolean("report.batch") == true) { + val batchNode = LazyLeafTreeNode("Batch Query Detection") { + val batchScanner = BatchScanner(scanResult.requestTemplate) + batchScanner.scanAsString().ifBlank { "" } + } + this.add(batchNode) + } + // Add request template this.add(TreeNodeWithCustomLabel("Request Template", scanResult.requestTemplate.withBody("").toString())) diff --git a/src/test/kotlin/inql/graphql/scanners/BatchScannerTest.kt b/src/test/kotlin/inql/graphql/scanners/BatchScannerTest.kt new file mode 100644 index 0000000..f063cb9 --- /dev/null +++ b/src/test/kotlin/inql/graphql/scanners/BatchScannerTest.kt @@ -0,0 +1,388 @@ +package inql.graphql.scanners + +import burp.api.montoya.http.message.responses.HttpResponse +import com.google.gson.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +class BatchScannerTest { + + // ────────────────────────────────────────────── + // Helper: create a mock HttpResponse + // ────────────────────────────────────────────── + private fun mockResponse(statusCode: Short, body: String): HttpResponse { + val resp = mock(HttpResponse::class.java) + `when`(resp.statusCode()).thenReturn(statusCode) + `when`(resp.bodyToString()).thenReturn(body) + return resp + } + + // ══════════════════════════════════════════════ + // PAYLOAD GENERATION + // ══════════════════════════════════════════════ + + @Nested + @DisplayName("Alias batch payload generation") + inner class AliasBatchPayload { + + @Test + fun `payload is valid JSON with a query field`() { + val body = BatchScanner.buildAliasBatchBody() + val json = JsonParser.parseString(body) + assertTrue(json.isJsonObject) + assertTrue(json.asJsonObject.has("query")) + } + + @Test + fun `query contains both alias names`() { + val body = BatchScanner.buildAliasBatchBody() + val query = JsonParser.parseString(body).asJsonObject["query"].asString + assertTrue(query.contains("inql_batch_alias1")) + assertTrue(query.contains("inql_batch_alias2")) + } + + @Test + fun `query uses __typename as the probe field`() { + val body = BatchScanner.buildAliasBatchBody() + val query = JsonParser.parseString(body).asJsonObject["query"].asString + assertTrue(query.contains("__typename")) + } + } + + @Nested + @DisplayName("Array batch payload generation") + inner class ArrayBatchPayload { + + @Test + fun `payload is a valid JSON array`() { + val body = BatchScanner.buildArrayBatchBody() + val json = JsonParser.parseString(body) + assertTrue(json.isJsonArray) + } + + @Test + fun `array contains exactly 2 elements`() { + val body = BatchScanner.buildArrayBatchBody() + val arr = JsonParser.parseString(body).asJsonArray + assertEquals(2, arr.size()) + } + + @Test + fun `each element has a query field`() { + val body = BatchScanner.buildArrayBatchBody() + val arr = JsonParser.parseString(body).asJsonArray + arr.forEach { elem -> + assertTrue(elem.isJsonObject) + assertTrue(elem.asJsonObject.has("query")) + } + } + + @Test + fun `each query contains __typename`() { + val body = BatchScanner.buildArrayBatchBody() + val arr = JsonParser.parseString(body).asJsonArray + arr.forEach { elem -> + val query = elem.asJsonObject["query"].asString + assertTrue(query.contains("__typename")) + } + } + } + + // ══════════════════════════════════════════════ + // ALIAS BATCH RESPONSE ANALYSIS + // ══════════════════════════════════════════════ + + @Nested + @DisplayName("Alias batch response analysis") + inner class AliasBatchAnalysis { + + @Test + fun `detects supported when both aliases present in data`() { + val body = """ + { + "data": { + "inql_batch_alias1": "Query", + "inql_batch_alias2": "Query" + } + } + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp) + assertTrue(supported) + } + + @Test + fun `detects not supported when only one alias present`() { + val body = """ + { + "data": { + "inql_batch_alias1": "Query" + } + } + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("alias2=false")) + } + + @Test + fun `detects not supported on HTTP 400`() { + val resp = mockResponse(400, """{"errors": [{"message": "bad request"}]}""") + val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("400")) + } + + @Test + fun `detects not supported on HTTP 500`() { + val resp = mockResponse(500, "Internal Server Error") + val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp) + assertFalse(supported) + } + + @Test + fun `detects not supported when response is not JSON`() { + val resp = mockResponse(200, "Not Found") + val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("parse") || detail.contains("not a JSON")) + } + + @Test + fun `detects not supported when data field is missing`() { + val body = """{"errors": [{"message": "syntax error"}]}""" + val resp = mockResponse(200, body) + val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("No 'data' field")) + } + + @Test + fun `reports warning when aliases resolve but errors are present`() { + val body = """ + { + "data": { + "inql_batch_alias1": "Query", + "inql_batch_alias2": "Query" + }, + "errors": [{"message": "rate limited"}] + } + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, detail) = BatchScanner.analyzeAliasBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("errors present")) + } + + @Test + fun `succeeds when errors array is empty`() { + val body = """ + { + "data": { + "inql_batch_alias1": "Query", + "inql_batch_alias2": "Query" + }, + "errors": [] + } + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp) + assertTrue(supported) + } + + @Test + fun `handles null data values gracefully`() { + val body = """ + { + "data": { + "inql_batch_alias1": null, + "inql_batch_alias2": null + } + } + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp) + assertTrue(supported, "Null values still indicate the server processed both aliases") + } + } + + // ══════════════════════════════════════════════ + // ARRAY BATCH RESPONSE ANALYSIS + // ══════════════════════════════════════════════ + + @Nested + @DisplayName("Array batch response analysis") + inner class ArrayBatchAnalysis { + + @Test + fun `detects supported when response is array of 2 results`() { + val body = """ + [ + {"data": {"__typename": "Query"}}, + {"data": {"__typename": "Query"}} + ] + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, _) = BatchScanner.analyzeArrayBatchResponse(resp) + assertTrue(supported) + } + + @Test + fun `detects not supported when server returns single object`() { + val body = """{"data": {"__typename": "Query"}}""" + val resp = mockResponse(200, body) + val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("not a JSON array")) + } + + @Test + fun `detects not supported when server returns error object`() { + val body = """ + { + "errors": [{"message": "Batching is not allowed"}] + } + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("error object")) + } + + @Test + fun `detects not supported on HTTP 400`() { + val resp = mockResponse(400, "Bad Request") + val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("400")) + } + + @Test + fun `detects not supported when array has wrong count`() { + val body = """ + [ + {"data": {"__typename": "Query"}} + ] + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("Expected 2")) + } + + @Test + fun `detects not supported when array elements lack data field`() { + val body = """ + [ + {"errors": [{"message": "fail"}]}, + {"errors": [{"message": "fail"}]} + ] + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("missing 'data'")) + } + + @Test + fun `handles empty response body`() { + val resp = mockResponse(200, "") + val (supported, detail) = BatchScanner.analyzeArrayBatchResponse(resp) + assertFalse(supported) + assertTrue(detail.contains("parse") || detail.contains("not a JSON")) + } + + @Test + fun `handles HTTP 405 Method Not Allowed`() { + val resp = mockResponse(405, "Method Not Allowed") + val (supported, _) = BatchScanner.analyzeArrayBatchResponse(resp) + assertFalse(supported) + } + + @Test + fun `accepts response with extra fields in array elements`() { + val body = """ + [ + {"data": {"__typename": "Query"}, "extensions": {"tracing": {}}}, + {"data": {"__typename": "Query"}, "extensions": {"tracing": {}}} + ] + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, _) = BatchScanner.analyzeArrayBatchResponse(resp) + assertTrue(supported) + } + } + + // ══════════════════════════════════════════════ + // EDGE CASES & CROSS-CUTTING + // ══════════════════════════════════════════════ + + @Nested + @DisplayName("Edge cases") + inner class EdgeCases { + + @Test + fun `alias analysis handles deeply nested data`() { + val body = """ + { + "data": { + "inql_batch_alias1": {"nested": {"deep": true}}, + "inql_batch_alias2": {"nested": {"deep": true}} + } + } + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp) + assertTrue(supported) + } + + @Test + fun `alias analysis handles numeric values`() { + val body = """ + { + "data": { + "inql_batch_alias1": 42, + "inql_batch_alias2": 99 + } + } + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, _) = BatchScanner.analyzeAliasBatchResponse(resp) + assertTrue(supported) + } + + @Test + fun `array analysis handles mixed success and error elements`() { + // Both elements have "data", even though one also has errors + val body = """ + [ + {"data": {"__typename": "Query"}}, + {"data": null, "errors": [{"message": "not found"}]} + ] + """.trimIndent() + val resp = mockResponse(200, body) + val (supported, _) = BatchScanner.analyzeArrayBatchResponse(resp) + // Still counts as supported — server processed the array + assertTrue(supported) + } + + @Test + fun `alias payload is stable across invocations`() { + val body1 = BatchScanner.buildAliasBatchBody() + val body2 = BatchScanner.buildAliasBatchBody() + assertEquals(body1, body2) + } + + @Test + fun `array payload is stable across invocations`() { + val body1 = BatchScanner.buildArrayBatchBody() + val body2 = BatchScanner.buildArrayBatchBody() + assertEquals(body1, body2) + } + } +}