Broaden credential filter and fail closed on malformed JSON

This commit is contained in:
Daniel Allen
2026-05-25 15:42:37 +01:00
parent d2ea4ad6e0
commit 18c8564d9a
2 changed files with 61 additions and 63 deletions
@@ -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)
}
@@ -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