diff --git a/src/main/kotlin/net/portswigger/mcp/config/McpConfig.kt b/src/main/kotlin/net/portswigger/mcp/config/McpConfig.kt index ebd2f69..7c1b5d1 100644 --- a/src/main/kotlin/net/portswigger/mcp/config/McpConfig.kt +++ b/src/main/kotlin/net/portswigger/mcp/config/McpConfig.kt @@ -49,6 +49,16 @@ class McpConfig(storage: PersistedObject, private val logging: Logging) { } } + private var _filterConfigCredentials by storage.boolean(true) + var filterConfigCredentials: Boolean + get() = _filterConfigCredentials + set(value) { + if (_filterConfigCredentials != value) { + _filterConfigCredentials = value + notifyHistoryAccessChanged() + } + } + fun addAutoApproveTarget(target: String): Boolean { val currentTargets = getAutoApproveTargetsList() if (target.trim().isNotEmpty() && !currentTargets.contains(target.trim())) { diff --git a/src/main/kotlin/net/portswigger/mcp/config/components/ServerConfigurationPanel.kt b/src/main/kotlin/net/portswigger/mcp/config/components/ServerConfigurationPanel.kt index 04c75ae..6334b6f 100644 --- a/src/main/kotlin/net/portswigger/mcp/config/components/ServerConfigurationPanel.kt +++ b/src/main/kotlin/net/portswigger/mcp/config/components/ServerConfigurationPanel.kt @@ -77,6 +77,14 @@ class ServerConfigurationPanel( config.requireHistoryAccessApproval ) { config.alwaysAllowWebSocketHistory = it } add(alwaysAllowWebSocketHistoryCheckBox) + add(createVerticalStrut(Design.Spacing.MD)) + + val filterConfigCredentialsCheckBox = createCheckBoxWithSubtitle( + "Filter config credentials", + "Hides sensitive data in config files (Platform Authentication, socks proxy, etc.)", + config.filterConfigCredentials + ) { config.filterConfigCredentials = it } + add(filterConfigCredentialsCheckBox) add(validationErrorLabel) } diff --git a/src/main/kotlin/net/portswigger/mcp/security/SecurityUtils.kt b/src/main/kotlin/net/portswigger/mcp/security/SecurityUtils.kt index a960230..27c71bd 100644 --- a/src/main/kotlin/net/portswigger/mcp/security/SecurityUtils.kt +++ b/src/main/kotlin/net/portswigger/mcp/security/SecurityUtils.kt @@ -1,6 +1,14 @@ package net.portswigger.mcp.security import java.awt.Frame +import net.portswigger.mcp.config.McpConfig +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* + +@Serializable +data class SecurityConfig( + val options: Map> +) /** * Finds the Burp Suite main frame or the largest available frame as fallback @@ -17,4 +25,38 @@ fun findBurpFrame(): Frame? { } ?: Frame.getFrames() .filter { it.isVisible && it.isDisplayable } .maxByOrNull { it.width * it.height } +} + +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) + } +} + +private fun filterJsonElement(element: JsonElement): JsonElement { + return when (element) { + is JsonObject -> filterJsonObject(element) + is JsonArray -> filterJsonArray(element) + 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) + } + } + return JsonObject(filteredMap) +} + +private fun filterJsonArray(array: JsonArray): JsonArray { + val filteredList = array.map { element -> filterJsonElement(element) } + return JsonArray(filteredList) } \ No newline at end of file diff --git a/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt b/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt index ad4dd44..7aef22e 100644 --- a/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt +++ b/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt @@ -17,6 +17,7 @@ import net.portswigger.mcp.schema.toSerializableForm import net.portswigger.mcp.security.HistoryAccessSecurity import net.portswigger.mcp.security.HistoryAccessType import net.portswigger.mcp.security.HttpRequestSecurity +import net.portswigger.mcp.security.filterConfigCredentials import java.awt.KeyboardFocusManager import java.util.regex.Pattern import javax.swing.JTextArea @@ -147,14 +148,24 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) { "output_project_options", "Outputs current project-level configuration in JSON format. You can use this to determine the schema for available config options." ) { - api.burpSuite().exportProjectOptionsAsJson() + val json = api.burpSuite().exportProjectOptionsAsJson() + if (config.filterConfigCredentials == true) { + filterConfigCredentials(json) + } else { + json + } } mcpTool( "output_user_options", "Outputs current user-level configuration in JSON format. You can use this to determine the schema for available config options." ) { - api.burpSuite().exportUserOptionsAsJson() + val json = api.burpSuite().exportUserOptionsAsJson() + if (config.filterConfigCredentials == true) { + filterConfigCredentials(json) + } else { + json + } } val toolingDisabledMessage = diff --git a/src/test/kotlin/net/portswigger/mcp/security/CredentialFilterTest.kt b/src/test/kotlin/net/portswigger/mcp/security/CredentialFilterTest.kt new file mode 100644 index 0000000..0407508 --- /dev/null +++ b/src/test/kotlin/net/portswigger/mcp/security/CredentialFilterTest.kt @@ -0,0 +1,361 @@ +package net.portswigger.mcp.security + +import burp.api.montoya.MontoyaApi +import burp.api.montoya.persistence.PersistedObject +import burp.api.montoya.logging.Logging +import net.portswigger.mcp.config.McpConfig +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.BeforeEach +import io.mockk.mockk +import io.mockk.every +import kotlinx.serialization.json.* + +class CredentialFilterTest { + + private lateinit var config: McpConfig + private lateinit var api: MontoyaApi + private lateinit var mockLogging: Logging + private lateinit var persistedObject: PersistedObject + private lateinit var projectOptionString: String + private lateinit var usersOptionString: String + + @BeforeEach + fun setUp() { + api = mockk() + mockLogging = mockk() + persistedObject = mockk() + val storage = mutableMapOf( + "enabled" to true, + "configEditingTooling" to false, + "requireHttpRequestApproval" to true, + "host" to "127.0.0.1", + "_autoApproveTargets" to "", + "port" to 9876 + ) + + persistedObject = mockk().apply { + every { getBoolean(any()) } answers { storage[firstArg()] as? Boolean ?: false } + every { getString(any()) } answers { storage[firstArg()] as? String ?: "" } + every { getInteger(any()) } answers { storage[firstArg()] as? Int ?: 0 } + every { setBoolean(any(), any()) } answers { + storage[firstArg()] = secondArg() + } + every { setString(any(), any()) } answers { + storage[firstArg()] = secondArg() + } + every { setInteger(any(), any()) } answers { + storage[firstArg()] = secondArg() + } + } + + mockLogging = mockk().apply { + every { logToError(any()) } returns Unit + } + + this.get_user_options_with_customizable_field() + this.get_project_options_with_customizable_field() + config = McpConfig(persistedObject, mockLogging) + } + + fun get_user_options_with_customizable_field(username: String = "", password: String = ""): String { + this.usersOptionString = """ + { + "user_options": { + "bchecks": {}, + "connections": { + "platform_authentication": { + "credentials": [ + { + "username": "$username", + "password": "$password" + } + ], + "do_platform_authentication": false, + "prompt_on_authentication_failure": false + }, + "socks_proxy": { + "username": "$username", + "password": "$password" + }, + "upstream_proxy": { + "servers": [] + } + }, + "display": {}, + "extender": {}, + "intruder": {}, + "misc": {}, + "proxy": {}, + "repeater": {}, + "ssl": {} + } + } + """.trimIndent() + return this.usersOptionString + } + + fun get_project_options_with_customizable_field(username: String = "", password: String = ""): String { + this.projectOptionString = """ + { + "bambda": {}, + "logger": {}, + "organiser": {}, + "project_options": { + "connections": { + "out_of_scope_requests": {}, + "platform_authentication": { + "credentials": [ + { + "username": "$username", + "password": "$password" + } + ], + "do_platform_authentication": false, + "prompt_on_authentication_failure": false, + "use_user_options": true + }, + "socks_proxy": { + "username": "$username", + "password": "$password" + }, + "timeouts": { + "connect_timeout": 5000, + "read_timeout": 5000 + }, + "upstream_proxy": { + "servers": [], + "use_user_options": true + } + }, + "dns": {}, + "http": {}, + "misc": {}, + "sessions": {}, + "ssl": {} + }, + "proxy": {}, + "repeater": {}, + "sequencer": {}, + "target": {} + } + """.trimIndent() + return this.projectOptionString + } + + @Test + fun `test security filter on project_options `() { + config.filterConfigCredentials = true + projectOptionString = get_project_options_with_customizable_field("testuser", "testpass") + val filteredProjectJson = filterConfigCredentials(projectOptionString) + val parsedJson = Json.parseToJsonElement(filteredProjectJson).jsonObject + + val credentials = parsedJson["project_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("platform_authentication")?.jsonObject + ?.get("credentials")?.jsonArray + + val socks_proxy = parsedJson["project_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("socks_proxy")?.jsonObject + + credentials?.forEach { credential -> + val credentialObj = credential.jsonObject + Assertions.assertEquals("*****", credentialObj["password"]?.jsonPrimitive?.content) + } + + socks_proxy?.let { + Assertions.assertEquals("*****", socks_proxy["password"]?.jsonPrimitive?.content) + } + } + + @Test + fun `test security filter on user_options`() { + config.filterConfigCredentials = true + usersOptionString = get_user_options_with_customizable_field("testuser", "testpass") + val filteredUserJson = filterConfigCredentials(usersOptionString) + val parsedJson = Json.parseToJsonElement(filteredUserJson).jsonObject + + val credentials = parsedJson["user_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("platform_authentication")?.jsonObject + ?.get("credentials")?.jsonArray + + val socks_proxy = parsedJson["user_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("socks_proxy")?.jsonObject + + credentials?.forEach { credential -> + val credentialObj = credential.jsonObject + Assertions.assertEquals("*****", credentialObj["password"]?.jsonPrimitive?.content) + } + + socks_proxy?.let { + Assertions.assertEquals("*****", it["password"]?.jsonPrimitive?.content) + } + } + + @Test + fun `test security filter with empty credentials on user_options`() { + config.filterConfigCredentials = true + val empty_user_credentials: String = get_user_options_with_customizable_field("", "") + val filteredJson = filterConfigCredentials(empty_user_credentials) + val parsedJson = Json.parseToJsonElement(filteredJson).jsonObject + + val credentials = parsedJson["user_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("platform_authentication")?.jsonObject + ?.get("credentials")?.jsonArray + + val socks_proxy = parsedJson["user_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("socks_proxy")?.jsonObject + + credentials?.forEach { credential -> + val credentialObj = credential.jsonObject + Assertions.assertTrue(credentialObj["username"]?.jsonPrimitive?.content.isNullOrEmpty()) + Assertions.assertEquals("*****", credentialObj["password"]?.jsonPrimitive?.content) + } + socks_proxy?.let { + Assertions.assertTrue(socks_proxy["username"]?.jsonPrimitive?.content.isNullOrEmpty()) + Assertions.assertEquals("*****", socks_proxy["password"]?.jsonPrimitive?.content) + } + } + + @Test + fun `test security filter with empty credentials on project_options`() { + config.filterConfigCredentials = true + val empty_project_credentials = get_project_options_with_customizable_field("", "") + val filteredJson = filterConfigCredentials(empty_project_credentials) + val parsedJson = Json.parseToJsonElement(filteredJson).jsonObject + + val credentials = parsedJson["project_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("platform_authentication")?.jsonObject + ?.get("credentials")?.jsonArray + + val socks_proxy = parsedJson["project_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("socks_proxy")?.jsonObject + + credentials?.forEach { credential -> + val credentialObj = credential.jsonObject + Assertions.assertTrue(credentialObj["username"]?.jsonPrimitive?.content.isNullOrEmpty()) + Assertions.assertEquals("*****", credentialObj["password"]?.jsonPrimitive?.content) + } + socks_proxy?.let { + Assertions.assertTrue(socks_proxy["username"]?.jsonPrimitive?.content.isNullOrEmpty()) + Assertions.assertEquals("*****", socks_proxy["password"]?.jsonPrimitive?.content) + } + } + + @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": {} + } + } + """.trimIndent() + val exception = Assertions.assertThrows(RuntimeException::class.java) { + filterConfigCredentials(malformedJson) + } + Assertions.assertEquals("Failed to filter credentials", exception.message) + Assertions.assertNotNull(exception.cause) + } + + @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": "" } + } + }, + "proxy": {}, + "repeater": {}, + "sequencer": {}, + "target": {} + + """.trimIndent() + val exception = Assertions.assertThrows(RuntimeException::class.java) { + filterConfigCredentials(malformedJson) + } + Assertions.assertEquals("Failed to filter credentials", exception.message) + Assertions.assertNotNull(exception.cause) + } + + @Test + fun `test security filter with username but no password on user_options`() { + config.filterConfigCredentials = true + val jsonWithUsernameOnly = get_user_options_with_customizable_field("testuser", "") + val filteredJson = filterConfigCredentials(jsonWithUsernameOnly) + val parsedJson = Json.parseToJsonElement(filteredJson).jsonObject + + val credentials = parsedJson["user_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("platform_authentication")?.jsonObject + ?.get("credentials")?.jsonArray + + val socks_proxy = parsedJson["user_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("socks_proxy")?.jsonObject + + credentials?.forEach { credential -> + val credentialObj = credential.jsonObject + Assertions.assertEquals("*****", credentialObj["password"]?.jsonPrimitive?.content) + } + + socks_proxy?.let { + Assertions.assertEquals("*****", socks_proxy["password"]?.jsonPrimitive?.content) + } + } + + @Test + fun `test security filter with username but no password on project_options`() { + config.filterConfigCredentials = true + val jsonWithUsernameOnly = get_project_options_with_customizable_field("testuser", "") + val filteredJson = filterConfigCredentials(jsonWithUsernameOnly) + val parsedJson = Json.parseToJsonElement(filteredJson).jsonObject + + val credentials = parsedJson["project_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("platform_authentication")?.jsonObject + ?.get("credentials")?.jsonArray + + val socks_proxy = parsedJson["project_options"]?.jsonObject + ?.get("connections")?.jsonObject + ?.get("socks_proxy")?.jsonObject + + credentials?.forEach { credential -> + val credentialObj = credential.jsonObject + Assertions.assertEquals("*****", credentialObj["password"]?.jsonPrimitive?.content) + } + socks_proxy?.let { + Assertions.assertEquals("*****", socks_proxy["password"]?.jsonPrimitive?.content) + } + } +} \ No newline at end of file