Follow-up to #87: include Organizer id/status, align naming with Montoya (#94)

* refactor: rename GetOrganizerRequests to GetOrganizerItems to match Montoya terminology

* feat: include id and status in Organizer item serialization

* feat: dedicated Organizer permission separate from HTTP history

* refactor: rename HistoryAccess to DataAccess to cover Organizer items

* fix: keep config change listener alive so checkbox sync survives GC

* fix: declare data access listener before init so it isn't null at registration

* chore: stop tracking .idea/ and update gitignore

* fix: serialize Organizer item status using displayName() instead of enum constant name
This commit is contained in:
portswigger-penguin
2026-05-25 11:35:03 +01:00
committed by GitHub
parent ed03cc73a9
commit 30815f3e09
17 changed files with 117 additions and 125 deletions
+1 -4
View File
@@ -5,10 +5,7 @@ build/
!**/src/test/**/build/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
.idea/
*.iws
*.iml
*.ipr
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
-1
View File
@@ -1 +0,0 @@
burp-mcp
View File
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.2.21" />
</component>
</project>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
-1
View File
@@ -1 +0,0 @@
<template />
-5
View File
@@ -1,5 +0,0 @@
<template unencoded="false">
<roots>
<root index="0" path="" />
</roots>
</template>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
@@ -53,6 +53,12 @@ class ConfigUi(private val config: McpConfig, private val providers: List<Provid
private var toggleListener: ((Boolean) -> Unit)? = null
private var suppressToggleEvents: Boolean = false
private val dataAccessRefreshListener: () -> Unit = {
SwingUtilities.invokeLater {
serverConfigurationPanel.updateDataAccessCheckboxes()
}
}
init {
enabledToggle.setState(config.enabled, animate = false)
hostField.text = config.host
@@ -81,12 +87,7 @@ class ConfigUi(private val config: McpConfig, private val providers: List<Provid
}
private fun setupConfigListeners() {
val historyAccessRefreshListener = {
SwingUtilities.invokeLater {
serverConfigurationPanel.updateHistoryAccessCheckboxes()
}
}
val handle = config.addHistoryAccessChangeListener(historyAccessRefreshListener)
val handle = config.addDataAccessChangeListener(dataAccessRefreshListener)
listenerHandles.add(handle)
}
@@ -16,7 +16,7 @@ class McpConfig(storage: PersistedObject, private val logging: Logging) {
var host by storage.string("127.0.0.1")
var port by storage.int(9876)
var requireHttpRequestApproval by storage.boolean(true)
var requireHistoryAccessApproval by storage.boolean(true)
var requireDataAccessApproval by storage.boolean(true)
private var _alwaysAllowHttpHistory by storage.boolean(false)
var alwaysAllowHttpHistory: Boolean
@@ -24,7 +24,7 @@ class McpConfig(storage: PersistedObject, private val logging: Logging) {
set(value) {
if (_alwaysAllowHttpHistory != value) {
_alwaysAllowHttpHistory = value
notifyHistoryAccessChanged()
notifyDataAccessChanged()
}
}
@@ -34,13 +34,23 @@ class McpConfig(storage: PersistedObject, private val logging: Logging) {
set(value) {
if (_alwaysAllowWebSocketHistory != value) {
_alwaysAllowWebSocketHistory = value
notifyHistoryAccessChanged()
notifyDataAccessChanged()
}
}
private var _alwaysAllowOrganizer by storage.boolean(false)
var alwaysAllowOrganizer: Boolean
get() = _alwaysAllowOrganizer
set(value) {
if (_alwaysAllowOrganizer != value) {
_alwaysAllowOrganizer = value
notifyDataAccessChanged()
}
}
private var _autoApproveTargets by storage.stringList("")
private val targetsChangeListeners = CopyOnWriteArrayList<ListenerRegistration>()
private val historyAccessChangeListeners = CopyOnWriteArrayList<ListenerRegistration>()
private val dataAccessChangeListeners = CopyOnWriteArrayList<ListenerRegistration>()
var autoApproveTargets: String
get() = _autoApproveTargets
@@ -113,24 +123,24 @@ class McpConfig(storage: PersistedObject, private val logging: Logging) {
}
}
fun addHistoryAccessChangeListener(listener: () -> Unit): ListenerHandle {
fun addDataAccessChangeListener(listener: () -> Unit): ListenerHandle {
val registration = ListenerRegistration(listener)
historyAccessChangeListeners.add(registration)
return ListenerHandle { removeHistoryAccessChangeListener(registration) }
dataAccessChangeListeners.add(registration)
return ListenerHandle { removeDataAccessChangeListener(registration) }
}
private fun removeHistoryAccessChangeListener(registration: ListenerRegistration) {
historyAccessChangeListeners.remove(registration)
private fun removeDataAccessChangeListener(registration: ListenerRegistration) {
dataAccessChangeListeners.remove(registration)
}
private fun notifyHistoryAccessChanged() {
cleanupStaleListeners(historyAccessChangeListeners)
val listeners = historyAccessChangeListeners.mapNotNull { it.listener.get() }
private fun notifyDataAccessChanged() {
cleanupStaleListeners(dataAccessChangeListeners)
val listeners = dataAccessChangeListeners.mapNotNull { it.listener.get() }
listeners.forEach { listener ->
try {
listener()
} catch (e: Exception) {
logging.logToError("History access change listener failed: ${e.message}")
logging.logToError("Data access change listener failed: ${e.message}")
}
}
}
@@ -142,7 +152,7 @@ class McpConfig(storage: PersistedObject, private val logging: Logging) {
fun cleanup() {
targetsChangeListeners.clear()
historyAccessChangeListeners.clear()
dataAccessChangeListeners.clear()
}
}
@@ -17,6 +17,7 @@ class ServerConfigurationPanel(
private lateinit var alwaysAllowHttpHistoryCheckBox: JCheckBox
private lateinit var alwaysAllowWebSocketHistoryCheckBox: JCheckBox
private lateinit var alwaysAllowOrganizerCheckBox: JCheckBox
init {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
@@ -61,12 +62,12 @@ class ServerConfigurationPanel(
add(httpRequestApprovalCheckBox)
add(createVerticalStrut(Design.Spacing.MD))
val historyAccessApprovalCheckBox = createHistoryAccessApprovalCheckBox()
add(historyAccessApprovalCheckBox)
val dataAccessApprovalCheckBox = createDataAccessApprovalCheckBox()
add(dataAccessApprovalCheckBox)
add(createVerticalStrut(Design.Spacing.SM))
alwaysAllowHttpHistoryCheckBox = createIndentedCheckBox(
"Always allow HTTP history access", config.alwaysAllowHttpHistory, config.requireHistoryAccessApproval
"Always allow HTTP history access", config.alwaysAllowHttpHistory, config.requireDataAccessApproval
) { config.alwaysAllowHttpHistory = it }
add(alwaysAllowHttpHistoryCheckBox)
add(createVerticalStrut(Design.Spacing.SM))
@@ -74,9 +75,17 @@ class ServerConfigurationPanel(
alwaysAllowWebSocketHistoryCheckBox = createIndentedCheckBox(
"Always allow WebSocket history access",
config.alwaysAllowWebSocketHistory,
config.requireHistoryAccessApproval
config.requireDataAccessApproval
) { config.alwaysAllowWebSocketHistory = it }
add(alwaysAllowWebSocketHistoryCheckBox)
add(createVerticalStrut(Design.Spacing.SM))
alwaysAllowOrganizerCheckBox = createIndentedCheckBox(
"Always allow Organizer access",
config.alwaysAllowOrganizer,
config.requireDataAccessApproval
) { config.alwaysAllowOrganizer = it }
add(alwaysAllowOrganizerCheckBox)
add(validationErrorLabel)
}
@@ -95,26 +104,30 @@ class ServerConfigurationPanel(
return enabledPanel
}
private fun createHistoryAccessApprovalCheckBox(): JCheckBox {
private fun createDataAccessApprovalCheckBox(): JCheckBox {
return createStandardCheckBox(
"Require approval for history access", config.requireHistoryAccessApproval
"Require approval for project data access", config.requireDataAccessApproval
) { enabled ->
config.requireHistoryAccessApproval = enabled
config.requireDataAccessApproval = enabled
if (!enabled) {
config.alwaysAllowHttpHistory = false
config.alwaysAllowWebSocketHistory = false
config.alwaysAllowOrganizer = false
alwaysAllowHttpHistoryCheckBox.isSelected = false
alwaysAllowWebSocketHistoryCheckBox.isSelected = false
alwaysAllowOrganizerCheckBox.isSelected = false
}
alwaysAllowHttpHistoryCheckBox.isEnabled = enabled
alwaysAllowWebSocketHistoryCheckBox.isEnabled = enabled
alwaysAllowOrganizerCheckBox.isEnabled = enabled
}
}
fun updateHistoryAccessCheckboxes() {
fun updateDataAccessCheckboxes() {
SwingUtilities.invokeLater {
alwaysAllowHttpHistoryCheckBox.isSelected = config.alwaysAllowHttpHistory
alwaysAllowWebSocketHistoryCheckBox.isSelected = config.alwaysAllowWebSocketHistory
alwaysAllowOrganizerCheckBox.isSelected = config.alwaysAllowOrganizer
}
}
@@ -1,6 +1,7 @@
package net.portswigger.mcp.schema
import burp.api.montoya.collaborator.Interaction as CollaboratorInteraction
import burp.api.montoya.organizer.OrganizerItem
import burp.api.montoya.proxy.ProxyHttpRequestResponse
import burp.api.montoya.proxy.ProxyWebSocketMessage
import burp.api.montoya.scanner.audit.issues.AuditIssue
@@ -52,6 +53,16 @@ fun ProxyHttpRequestResponse.toSerializableForm(): HttpRequestResponse {
)
}
fun OrganizerItem.toSerializableForm(): OrganizerItemDetails {
return OrganizerItemDetails(
id = id(),
status = status().displayName(),
request = request()?.toString() ?: "<no request>",
response = response()?.toString() ?: "<no response>",
notes = annotations().notes()
)
}
fun ProxyWebSocketMessage.toSerializableForm(): WebSocketMessage {
return WebSocketMessage(
payload = payload()?.toString() ?: "<no payload>",
@@ -108,6 +119,15 @@ data class HttpRequestResponse(
val notes: String?
)
@Serializable
data class OrganizerItemDetails(
val id: Int,
val status: String,
val request: String?,
val response: String?,
val notes: String?
)
@Serializable
data class Interaction(
val interactionId: String,
@@ -6,34 +6,35 @@ import javax.swing.SwingUtilities
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
enum class HistoryAccessType() {
HTTP_HISTORY(), WEBSOCKET_HISTORY();
enum class DataAccessType() {
HTTP_HISTORY(), WEBSOCKET_HISTORY(), ORGANIZER();
}
interface HistoryAccessApprovalHandler {
suspend fun requestHistoryAccess(accessType: HistoryAccessType, config: McpConfig): Boolean
interface DataAccessApprovalHandler {
suspend fun requestDataAccess(accessType: DataAccessType, config: McpConfig): Boolean
}
class SwingHistoryAccessApprovalHandler : HistoryAccessApprovalHandler {
override suspend fun requestHistoryAccess(
accessType: HistoryAccessType, config: McpConfig
class SwingDataAccessApprovalHandler : DataAccessApprovalHandler {
override suspend fun requestDataAccess(
accessType: DataAccessType, config: McpConfig
): Boolean {
return suspendCoroutine { continuation ->
SwingUtilities.invokeLater {
val historyTypeName = when (accessType) {
HistoryAccessType.HTTP_HISTORY -> "HTTP history"
HistoryAccessType.WEBSOCKET_HISTORY -> "WebSocket history"
val accessTypeName = when (accessType) {
DataAccessType.HTTP_HISTORY -> "HTTP history"
DataAccessType.WEBSOCKET_HISTORY -> "WebSocket history"
DataAccessType.ORGANIZER -> "Organizer items"
}
val message = buildString {
appendLine("An MCP client is requesting access to your Burp Suite $historyTypeName.")
appendLine("An MCP client is requesting access to your Burp Suite $accessTypeName.")
appendLine()
appendLine("This may include sensitive data from previous web sessions.")
appendLine("Choose how you would like to respond:")
}
val options = arrayOf(
"Allow Once", "Always Allow $historyTypeName", "Deny"
"Allow Once", "Always Allow $accessTypeName", "Deny"
)
val burpFrame = findBurpFrame()
@@ -49,8 +50,9 @@ class SwingHistoryAccessApprovalHandler : HistoryAccessApprovalHandler {
1 -> {
when (accessType) {
HistoryAccessType.HTTP_HISTORY -> config.alwaysAllowHttpHistory = true
HistoryAccessType.WEBSOCKET_HISTORY -> config.alwaysAllowWebSocketHistory = true
DataAccessType.HTTP_HISTORY -> config.alwaysAllowHttpHistory = true
DataAccessType.WEBSOCKET_HISTORY -> config.alwaysAllowWebSocketHistory = true
DataAccessType.ORGANIZER -> config.alwaysAllowOrganizer = true
}
continuation.resume(true)
}
@@ -64,26 +66,27 @@ class SwingHistoryAccessApprovalHandler : HistoryAccessApprovalHandler {
}
}
object HistoryAccessSecurity {
object DataAccessSecurity {
var approvalHandler: HistoryAccessApprovalHandler = SwingHistoryAccessApprovalHandler()
var approvalHandler: DataAccessApprovalHandler = SwingDataAccessApprovalHandler()
suspend fun checkHistoryAccessPermission(
accessType: HistoryAccessType, config: McpConfig
suspend fun checkDataAccessPermission(
accessType: DataAccessType, config: McpConfig
): Boolean {
if (!config.requireHistoryAccessApproval) {
if (!config.requireDataAccessApproval) {
return true
}
val isAlwaysAllowed = when (accessType) {
HistoryAccessType.HTTP_HISTORY -> config.alwaysAllowHttpHistory
HistoryAccessType.WEBSOCKET_HISTORY -> config.alwaysAllowWebSocketHistory
DataAccessType.HTTP_HISTORY -> config.alwaysAllowHttpHistory
DataAccessType.WEBSOCKET_HISTORY -> config.alwaysAllowWebSocketHistory
DataAccessType.ORGANIZER -> config.alwaysAllowOrganizer
}
if (isAlwaysAllowed) {
return true
}
return approvalHandler.requestHistoryAccess(accessType, config)
return approvalHandler.requestDataAccess(accessType, config)
}
}
@@ -15,17 +15,17 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.portswigger.mcp.config.McpConfig
import net.portswigger.mcp.schema.toSerializableForm
import net.portswigger.mcp.security.HistoryAccessSecurity
import net.portswigger.mcp.security.HistoryAccessType
import net.portswigger.mcp.security.DataAccessSecurity
import net.portswigger.mcp.security.DataAccessType
import net.portswigger.mcp.security.HttpRequestSecurity
import java.awt.KeyboardFocusManager
import java.util.regex.Pattern
import javax.swing.JTextArea
private suspend fun checkHistoryPermissionOrDeny(
accessType: HistoryAccessType, config: McpConfig, api: MontoyaApi, logMessage: String
private suspend fun checkDataAccessOrDeny(
accessType: DataAccessType, config: McpConfig, api: MontoyaApi, logMessage: String
): Boolean {
val allowed = HistoryAccessSecurity.checkHistoryAccessPermission(accessType, config)
val allowed = DataAccessSecurity.checkDataAccessPermission(accessType, config)
if (!allowed) {
api.logging().logToOutput("MCP $logMessage access denied")
return false
@@ -283,7 +283,7 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {
mcpPaginatedTool<GetProxyHttpHistory>("Displays items within the proxy HTTP history") {
val allowed = runBlocking {
checkHistoryPermissionOrDeny(HistoryAccessType.HTTP_HISTORY, config, api, "HTTP history")
checkDataAccessOrDeny(DataAccessType.HTTP_HISTORY, config, api, "HTTP history")
}
if (!allowed) {
return@mcpPaginatedTool sequenceOf("HTTP history access denied by Burp Suite")
@@ -294,7 +294,7 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {
mcpPaginatedTool<GetProxyHttpHistoryRegex>("Displays items matching a specified regex within the proxy HTTP history") {
val allowed = runBlocking {
checkHistoryPermissionOrDeny(HistoryAccessType.HTTP_HISTORY, config, api, "HTTP history")
checkDataAccessOrDeny(DataAccessType.HTTP_HISTORY, config, api, "HTTP history")
}
if (!allowed) {
return@mcpPaginatedTool sequenceOf("HTTP history access denied by Burp Suite")
@@ -305,23 +305,23 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {
.map { truncateIfNeeded(Json.encodeToString(it.toSerializableForm())) }
}
mcpPaginatedTool<GetOrganizerRequests>("Displays items within the Organizer tab") {
mcpPaginatedTool<GetOrganizerItems>("Displays items within the Organizer tab") {
val allowed = runBlocking {
checkHistoryPermissionOrDeny(HistoryAccessType.HTTP_HISTORY, config, api, "HTTP history")
checkDataAccessOrDeny(DataAccessType.ORGANIZER, config, api, "Organizer")
}
if (!allowed) {
return@mcpPaginatedTool sequenceOf("HTTP history access denied by Burp Suite")
return@mcpPaginatedTool sequenceOf("Organizer access denied by Burp Suite")
}
api.organizer().items().asSequence().map { truncateIfNeeded(Json.encodeToString(it.toSerializableForm())) }
}
mcpPaginatedTool<GetOrganizerRequestsRegex>("Displays items matching a specified regex within the Organizer tab") {
mcpPaginatedTool<GetOrganizerItemsRegex>("Displays items matching a specified regex within the Organizer tab") {
val allowed = runBlocking {
checkHistoryPermissionOrDeny(HistoryAccessType.HTTP_HISTORY, config, api, "HTTP history")
checkDataAccessOrDeny(DataAccessType.ORGANIZER, config, api, "Organizer")
}
if (!allowed) {
return@mcpPaginatedTool sequenceOf("HTTP history access denied by Burp Suite")
return@mcpPaginatedTool sequenceOf("Organizer access denied by Burp Suite")
}
val compiledRegex = Pattern.compile(regex)
@@ -331,7 +331,7 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {
mcpPaginatedTool<GetProxyWebsocketHistory>("Displays items within the proxy WebSocket history") {
val allowed = runBlocking {
checkHistoryPermissionOrDeny(HistoryAccessType.WEBSOCKET_HISTORY, config, api, "WebSocket history")
checkDataAccessOrDeny(DataAccessType.WEBSOCKET_HISTORY, config, api, "WebSocket history")
}
if (!allowed) {
return@mcpPaginatedTool sequenceOf("WebSocket history access denied by Burp Suite")
@@ -343,7 +343,7 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {
mcpPaginatedTool<GetProxyWebsocketHistoryRegex>("Displays items matching a specified regex within the proxy WebSocket history") {
val allowed = runBlocking {
checkHistoryPermissionOrDeny(HistoryAccessType.WEBSOCKET_HISTORY, config, api, "WebSocket history")
checkDataAccessOrDeny(DataAccessType.WEBSOCKET_HISTORY, config, api, "WebSocket history")
}
if (!allowed) {
return@mcpPaginatedTool sequenceOf("WebSocket history access denied by Burp Suite")
@@ -486,10 +486,10 @@ data class GetProxyHttpHistory(override val count: Int, override val offset: Int
data class GetProxyHttpHistoryRegex(val regex: String, override val count: Int, override val offset: Int) : Paginated
@Serializable
data class GetOrganizerRequests(override val count: Int, override val offset: Int) : Paginated
data class GetOrganizerItems(override val count: Int, override val offset: Int) : Paginated
@Serializable
data class GetOrganizerRequestsRegex(val regex: String, override val count: Int, override val offset: Int) : Paginated
data class GetOrganizerItemsRegex(val regex: String, override val count: Int, override val offset: Int) : Paginated
@Serializable
data class GetProxyWebsocketHistory(override val count: Int, override val offset: Int) : Paginated
@@ -58,9 +58,10 @@ class ToolsKtTest {
every { getBoolean("enabled") } returns true
every { getBoolean("configEditingTooling") } returns true
every { getBoolean("requireHttpRequestApproval") } returns false
every { getBoolean("requireHistoryAccessApproval") } returns false
every { getBoolean("requireDataAccessApproval") } returns false
every { getBoolean("_alwaysAllowHttpHistory") } returns false
every { getBoolean("_alwaysAllowWebSocketHistory") } returns false
every { getBoolean("_alwaysAllowOrganizer") } returns false
every { getString("host") } returns "127.0.0.1"
every { getString("_autoApproveTargets") } returns ""
every { getInteger("port") } returns testPort