diff --git a/src/main/kotlin/net/portswigger/mcp/security/SecurityUtils.kt b/src/main/kotlin/net/portswigger/mcp/security/SecurityUtils.kt index bb67c91..164581e 100644 --- a/src/main/kotlin/net/portswigger/mcp/security/SecurityUtils.kt +++ b/src/main/kotlin/net/portswigger/mcp/security/SecurityUtils.kt @@ -1,5 +1,6 @@ package net.portswigger.mcp.security +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -24,36 +25,36 @@ fun findBurpFrame(): Frame? { .maxByOrNull { it.width * it.height } } +// Keys that hold credential-bearing values in Burp's exported user/project options. +// Cross-referenced against SuiteConfigurationFragmentFields in the Burp desktop codebase. +private val SENSITIVE_KEYS = setOf( + "password", // socks proxy, platform auth, upstream proxy, client certs, app login + "certificate_password", // proxy request listener PKCS12 password + "hashed_key", // Burp REST API key (SHA-256 of the actual key) +) + +private const val REDACTED = "*****" + fun filterConfigCredentials(json: String): String { return try { - val jsonElement = Json.parseToJsonElement(json) - val filteredElement = filterJsonElement(jsonElement) - Json.encodeToString(filteredElement) - } catch (e: Exception) { - throw RuntimeException("Failed to filter credentials", e) + Json.encodeToString(filterJsonElement(Json.parseToJsonElement(json))) + } catch (_: SerializationException) { + // Burp's export is expected to always be valid JSON. If it isn't, fail closed: + // do not echo the original or the parser's message (it quotes surrounding input, + // which can include credential values). + """{"error":"failed to parse config json"}""" } } -private fun filterJsonElement(element: JsonElement): JsonElement { - return when (element) { - is JsonObject -> filterJsonObject(element) - is JsonArray -> filterJsonArray(element) - else -> element - } +private fun filterJsonElement(element: JsonElement): JsonElement = when (element) { + is JsonObject -> JsonObject(element.mapValues { (key, value) -> filterValue(key, value) }) + is JsonArray -> JsonArray(element.map(::filterJsonElement)) + else -> element } -private fun filterJsonObject(obj: JsonObject): JsonObject { - val filteredMap = obj.mapValues { (key, value) -> - when { - value is JsonPrimitive && value.isString && key == "password" -> - JsonPrimitive("*****") - else -> filterJsonElement(value) - } +private fun filterValue(key: String, value: JsonElement): JsonElement = + if (key.lowercase() in SENSITIVE_KEYS && value is JsonPrimitive && value.isString) { + JsonPrimitive(REDACTED) + } else { + filterJsonElement(value) } - return JsonObject(filteredMap) -} - -private fun filterJsonArray(array: JsonArray): JsonArray { - val filteredList = array.map { element -> filterJsonElement(element) } - return JsonArray(filteredList) -} diff --git a/src/test/kotlin/net/portswigger/mcp/security/CredentialFilterTest.kt b/src/test/kotlin/net/portswigger/mcp/security/CredentialFilterTest.kt index 0407508..2ca244c 100644 --- a/src/test/kotlin/net/portswigger/mcp/security/CredentialFilterTest.kt +++ b/src/test/kotlin/net/portswigger/mcp/security/CredentialFilterTest.kt @@ -251,61 +251,58 @@ class CredentialFilterTest { @Test fun `test security filter with malformed Json on user_options`() { - config.filterConfigCredentials = true val malformedJson = """ { "user_options": { - "bchecks": {}, "connections": { - "platform_authentication": { - "credentials": [] - }, - "socks_proxy": { "password": "" - }, - "display": {}, - "extender": {}, - "intruder": {}, - "misc": {}, - "proxy": {}, - "repeater": {}, - "ssl": {} + "socks_proxy": { "password": "leakme" + } } - } """.trimIndent() - val exception = Assertions.assertThrows(RuntimeException::class.java) { - filterConfigCredentials(malformedJson) - } - Assertions.assertEquals("Failed to filter credentials", exception.message) - Assertions.assertNotNull(exception.cause) + val result = filterConfigCredentials(malformedJson) + Assertions.assertFalse(result.contains("leakme"), "Original input must not be echoed on parse failure") + val parsed = Json.parseToJsonElement(result).jsonObject + Assertions.assertNotNull(parsed["error"]) } @Test fun `test security filter with malformed Json on project_options`() { - config.filterConfigCredentials = true val malformedJson = """ { - "bambda": {}, - "logger": {}, - "organiser": {}, "project_options": { "connections": { - "platform_authentication": { - "credentials": [] - }, - "socks_proxy": { "password": "" } + "socks_proxy": { "password": "leakme" } } - }, - "proxy": {}, - "repeater": {}, - "sequencer": {}, - "target": {} - """.trimIndent() - val exception = Assertions.assertThrows(RuntimeException::class.java) { - filterConfigCredentials(malformedJson) + val result = filterConfigCredentials(malformedJson) + Assertions.assertFalse(result.contains("leakme"), "Original input must not be echoed on parse failure") + val parsed = Json.parseToJsonElement(result).jsonObject + Assertions.assertNotNull(parsed["error"]) + } + + @Test + fun `proxy listener certificate password and REST API hashed key are redacted`() { + val input = """ + { + "proxy": { "request_listeners": [ { "certificate_password": "p12pass" } ] }, + "misc": { "api": { "keys": [ { "name": "k1", "hashed_key": "deadbeef" } ] } } } - Assertions.assertEquals("Failed to filter credentials", exception.message) - Assertions.assertNotNull(exception.cause) + """.trimIndent() + val parsed = Json.parseToJsonElement(filterConfigCredentials(input)).jsonObject + val listener = parsed["proxy"]!!.jsonObject["request_listeners"]!!.jsonArray[0].jsonObject + Assertions.assertEquals("*****", listener["certificate_password"]?.jsonPrimitive?.content) + val apiKey = parsed["misc"]!!.jsonObject["api"]!!.jsonObject["keys"]!!.jsonArray[0].jsonObject + Assertions.assertEquals("*****", apiKey["hashed_key"]?.jsonPrimitive?.content) + Assertions.assertEquals("k1", apiKey["name"]?.jsonPrimitive?.content) + } + + @Test + fun `sensitive key matching is case insensitive`() { + val input = """{"Password":"a","Certificate_Password":"b","HASHED_KEY":"c"}""" + val parsed = Json.parseToJsonElement(filterConfigCredentials(input)).jsonObject + Assertions.assertEquals("*****", parsed["Password"]?.jsonPrimitive?.content) + Assertions.assertEquals("*****", parsed["Certificate_Password"]?.jsonPrimitive?.content) + Assertions.assertEquals("*****", parsed["HASHED_KEY"]?.jsonPrimitive?.content) } @Test