From 51dc92a0004d97e858482dfb16072c23a5e05ddf Mon Sep 17 00:00:00 2001 From: "rustam.sharipov" Date: Mon, 31 Aug 2026 15:56:55 +0400 Subject: [PATCH 1/4] fix(teams): tighten client-side webhook URL validation Accept Power Platform (`*.environment.api.powerplatform.com`) and Logic Apps hosts, require a host label before `webhook.office.com` instead of matching any domain ending in it, and scope the legacy Outlook form to `/webhook/`. The placeholder now shows the Power Platform form too. Co-Authored-By: Claude Opus 5 (1M context) --- .../buildServerResources/editNotifierSettings.jsp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/resources/buildServerResources/editNotifierSettings.jsp b/src/main/resources/buildServerResources/editNotifierSettings.jsp index ecfc66f..1e3aa36 100644 --- a/src/main/resources/buildServerResources/editNotifierSettings.jsp +++ b/src/main/resources/buildServerResources/editNotifierSettings.jsp @@ -850,7 +850,7 @@ if (p === 'SLACK') { placeholder = 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXX'; } else if (p === 'TEAMS') { - placeholder = 'https://{tenant}.webhook.office.com/webhookb2/...'; + placeholder = 'https://{tenant}.webhook.office.com/webhookb2/... or https://{env}.environment.api.powerplatform.com/...'; } else if (p === 'DISCORD') { placeholder = 'https://discord.com/api/webhooks/{id}/{token}'; } @@ -863,7 +863,10 @@ const p = platformSelect.value; let ok = false; if (p === 'SLACK') ok = /^https:\/\/hooks\.slack\.com\//.test(val); - else if (p === 'TEAMS') ok = /^https:\/\/.*webhook\.office\.com\//.test(val) || /^https:\/\/outlook\.office\.com\//.test(val); + else if (p === 'TEAMS') ok = /^https:\/\/[a-z0-9_-]+\.webhook\.office\.com\//i.test(val) + || /^https:\/\/outlook\.office\.com\/webhook\//i.test(val) + || /^https:\/\/[a-z0-9_-]+\.logic\.azure\.com\//i.test(val) + || /^https:\/\/[a-z0-9_.-]+\.environment\.api\.powerplatform\.com/i.test(val); else if (p === 'DISCORD') ok = /^https:\/\/discord(?:app)?\.com\/api\/webhooks\//.test(val); urlError.style.display = ok || !val ? 'none' : 'block'; return ok; From 37098e3c782f30704cf3c54f004516a930d3dcab Mon Sep 17 00:00:00 2001 From: "rustam.sharipov" Date: Mon, 31 Aug 2026 15:57:08 +0400 Subject: [PATCH 2/4] feat: give each webhook an optional display name Webhook lists rendered every entry as a masked "Slack Webhook (********)" placeholder, so several webhooks on the same platform were impossible to tell apart once created. WebhookConfiguration gains a nullable `name`, persisted as in the project settings XML (omitted when unset, so existing configuration and backups load unchanged) and settable from the DSL via `webhook.name`. The name is shown in place of the masked label on the project and build configuration tabs, in the inherited/versioned-settings list, in the client-side refreshed list, and on the admin overview cards; URLs stay masked everywhere. The name is display-only: identity, deduplication, inheritance and local disabling still key off the URL. UI-entered names are capped at 100 characters, and the name is escaped at every render site, including the JS list renderer, which previously had no escaping because it only ever rendered server-controlled strings. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 + DSL_USAGE.md | 2 + README.md | 1 + build.number | 2 +- example.teamcity.settings.kts | 1 + examples/settings.kts.example | 1 + .../controllers/NotifierSettingsController.kt | 13 ++++ .../features/TeamNotifyWebhookFeature.kt | 5 +- .../model/TeamNotifyProjectSettings.kt | 4 ++ .../teamnotify/model/WebhookConfiguration.kt | 3 + .../teamnotify/services/WebhookManager.kt | 1 + .../editNotifierSettings.jsp | 37 ++++++++++- .../buildServerResources/teamNotifyAdmin.jsp | 15 +++++ .../model/TeamNotifyProjectSettingsTest.kt | 63 +++++++++++++++++++ 14 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettingsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3179e99..514a2ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### ✨ Features +- **Webhooks can now have a name** – an optional label set when adding a webhook (or via the `webhook.name` DSL parameter) is shown instead of the masked `Slack Webhook (********)` placeholder on the project and build-configuration tabs and on the admin overview, so several webhooks of the same platform can be told apart. URLs stay masked; the name is display-only and does not affect webhook identity, inheritance, or delivery. + ## [1.4.0] - 2026-06-26 ### 🔧 Tooling diff --git a/DSL_USAGE.md b/DSL_USAGE.md index 7a83a65..a9ee1fa 100644 --- a/DSL_USAGE.md +++ b/DSL_USAGE.md @@ -19,6 +19,7 @@ buildFeature { type = "teamnotify.webhook" param("webhook.url", "%env.SLACK_WEBHOOK_URL%") // Reference parameter param("webhook.platform", "SLACK") + param("webhook.name", "#build-alerts") // Optional label shown in the UI param("webhook.enabled", "true") param("webhook.onFailure", "true") } @@ -201,6 +202,7 @@ All webhook parameters are configured using `param()` inside a `buildFeature` bl |-----------|------|----------|---------|-------------|---------| | `webhook.url` | String | **Yes** | - | Webhook URL | `https://discord.com/api/webhooks/...` | | `webhook.platform` | String | **Yes** | - | Platform: `SLACK`, `TEAMS`, or `DISCORD` | `DISCORD` | +| `webhook.name` | String | No | `null` | Display name shown in the TeamNotify UI lists (URLs stay masked) | `"#build-alerts"` | | `webhook.enabled` | String | No | `"true"` | Enable/disable webhook | `"true"` or `"false"` | | `webhook.onStart` | String | No | `"false"` | Trigger on build start | `"true"` or `"false"` | | `webhook.onSuccess` | String | No | `"false"` | Trigger on build success | `"true"` or `"false"` | diff --git a/README.md b/README.md index c17719d..c0a2d2b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ object MyBuild : BuildType({ type = "teamnotify.webhook" param("webhook.url", "%slack.webhook.url%") // Never hardcode the URL! param("webhook.platform", "SLACK") + param("webhook.name", "#build-alerts") // Optional label shown in the TeamNotify UI param("webhook.enabled", "true") param("webhook.onFailure", "true") param("webhook.onSuccess", "true") diff --git a/build.number b/build.number index e0d1ea1..b7c52fb 100644 --- a/build.number +++ b/build.number @@ -1 +1 @@ -205 \ No newline at end of file +212 \ No newline at end of file diff --git a/example.teamcity.settings.kts b/example.teamcity.settings.kts index 4dcd7e9..f429b61 100644 --- a/example.teamcity.settings.kts +++ b/example.teamcity.settings.kts @@ -21,6 +21,7 @@ project { type = "teamnotify.webhook" param("webhook.url", "%env.SLACK_WEBHOOK_URL%") // Use parameter for security param("webhook.platform", "SLACK") + param("webhook.name", "#build-alerts") // Optional label shown in the TeamNotify UI param("webhook.enabled", "true") // Trigger configuration diff --git a/examples/settings.kts.example b/examples/settings.kts.example index 0b5757b..a1ad4f9 100644 --- a/examples/settings.kts.example +++ b/examples/settings.kts.example @@ -30,6 +30,7 @@ project { type = "teamnotify.webhook" param("webhook.url", "%slack.webhook.critical%") param("webhook.platform", "SLACK") + param("webhook.name", "Critical alerts") // Optional label shown in the TeamNotify UI param("webhook.enabled", "true") param("webhook.onFirstFailure", "true") param("webhook.onBuildFixed", "true") diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/controllers/NotifierSettingsController.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/controllers/NotifierSettingsController.kt index 3e1402c..db2da0c 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/controllers/NotifierSettingsController.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/controllers/NotifierSettingsController.kt @@ -28,6 +28,12 @@ class NotifierSettingsController( private val sBuildServer: SBuildServer ) : BaseController() { + companion object { + // Names are display-only labels; cap them so a pasted blob can't blow up the settings XML + // or the webhook list layout. + const val MAX_NAME_LENGTH = 100 + } + fun register(): Unit { webControllerManager.registerController("/notifier/settings.html", this) webControllerManager.registerController("/notifier/testWebhook.html", this) @@ -87,6 +93,7 @@ class NotifierSettingsController( val action = request.getParameter("action") if (action == "add") { val webhookUrl = request.getParameter("webhookUrl")?.trim() + val webhookName = request.getParameter("webhookName")?.trim()?.takeIf { it.isNotEmpty() }?.take(MAX_NAME_LENGTH) val platformRaw = request.getParameter("platform")?.trim()?.uppercase() val onSuccess = request.getParameter("onSuccess") != null val onFailure = request.getParameter("onFailure") != null @@ -134,6 +141,7 @@ class NotifierSettingsController( if (errors.isNotEmpty()) { mv.model["validationErrors"] = errors mv.model["formUrl"] = webhookUrl ?: "" + mv.model["formName"] = webhookName ?: "" mv.model["formPlatform"] = platformRaw ?: "SLACK" mv.model["formOnStart"] = onStart mv.model["formOnSuccess"] = onSuccess @@ -146,6 +154,7 @@ class NotifierSettingsController( val newWebhook = WebhookConfiguration( url = webhookUrl!!, platform = platform!!, + name = webhookName, onStart = onStart, onSuccess = onSuccess, onFailure = onFailure, @@ -184,6 +193,7 @@ class NotifierSettingsController( errors += "Failed to save webhook: ${e.message ?: "Unknown error"}" mv.model["validationErrors"] = errors mv.model["formUrl"] = webhookUrl ?: "" + mv.model["formName"] = webhookName ?: "" mv.model["formPlatform"] = platformRaw ?: "SLACK" mv.model["formOnStart"] = onStart mv.model["formOnSuccess"] = onSuccess @@ -382,6 +392,7 @@ class NotifierSettingsController( """{ "index": $index, "platform": "${webhook.platform}", + "name": ${if (webhook.name != null) "\"${jsonEscape(webhook.name)}\"" else "null"}, "onStart": ${webhook.onStart}, "onSuccess": ${webhook.onSuccess}, "onFailure": ${webhook.onFailure}, @@ -587,6 +598,7 @@ class NotifierSettingsController( } val webhookUrl = request.getParameter("webhookUrl")?.trim() + val webhookName = request.getParameter("webhookName")?.trim()?.takeIf { it.isNotEmpty() }?.take(MAX_NAME_LENGTH) val platformRaw = request.getParameter("platform")?.trim()?.uppercase() val onSuccess = request.getParameter("onSuccess")?.toBoolean() ?: false val onFailure = request.getParameter("onFailure")?.toBoolean() ?: false @@ -619,6 +631,7 @@ class NotifierSettingsController( val newWebhook = WebhookConfiguration( url = webhookUrl, platform = platform, + name = webhookName, onStart = onStart, onSuccess = onSuccess, onFailure = onFailure, diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/features/TeamNotifyWebhookFeature.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/features/TeamNotifyWebhookFeature.kt index 834db56..a0dd96a 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/features/TeamNotifyWebhookFeature.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/features/TeamNotifyWebhookFeature.kt @@ -65,7 +65,10 @@ class TeamNotifyWebhookFeature( val triggerText = if (triggers.isNotEmpty()) triggers.joinToString(", ") else "No triggers" - return "$platform webhook: $url (Triggers: $triggerText)" + val name = params["webhook.name"]?.trim()?.takeIf { it.isNotEmpty() } + val prefix = if (name != null) "$name - " else "" + + return "$prefix$platform webhook: $url (Triggers: $triggerText)" } override fun getDefaultParameters(): Map { diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettings.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettings.kt index 4452ddd..8b87004 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettings.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettings.kt @@ -103,6 +103,7 @@ data class TeamNotifyProjectSettings( WebhookConfiguration( url = url, platform = platform, + name = webhookElement.getChild("name")?.textTrim?.takeIf { it.isNotEmpty() }, enabled = webhookElement.getChild("enabled")?.textTrim?.toBoolean() ?: true, onStart = webhookElement.getChild("onStart")?.textTrim?.toBoolean() ?: false, onSuccess = webhookElement.getChild("onSuccess")?.textTrim?.toBoolean() ?: false, @@ -131,6 +132,9 @@ data class TeamNotifyProjectSettings( val webhookElement = Element("webhook") webhookElement.addContent(Element("url").setText(webhook.url)) webhookElement.addContent(Element("platform").setText(webhook.platform.name)) + webhook.name?.let { + webhookElement.addContent(Element("name").setText(it)) + } webhookElement.addContent(Element("enabled").setText(webhook.enabled.toString())) webhookElement.addContent(Element("onStart").setText(webhook.onStart.toString())) webhookElement.addContent(Element("onSuccess").setText(webhook.onSuccess.toString())) diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/model/WebhookConfiguration.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/model/WebhookConfiguration.kt index 7c5334f..9e43d0f 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/model/WebhookConfiguration.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/model/WebhookConfiguration.kt @@ -9,6 +9,9 @@ enum class WebhookPlatform { data class WebhookConfiguration( val url: String, val platform: WebhookPlatform, + // Optional display name shown in the UI lists so several webhooks of the same platform can be + // told apart (URLs are always masked). Purely cosmetic: webhook identity is still the URL. + val name: String? = null, val onStart: Boolean = false, val onSuccess: Boolean = false, val onFailure: Boolean = false, diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/services/WebhookManager.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/services/WebhookManager.kt index 7c35595..783de00 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/services/WebhookManager.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/services/WebhookManager.kt @@ -135,6 +135,7 @@ class WebhookManager( return WebhookConfiguration( url = url, platform = platform, + name = params["webhook.name"]?.trim()?.takeIf { it.isNotEmpty() }, enabled = params["webhook.enabled"]?.toBoolean() ?: true, onStart = params["webhook.onStart"]?.toBoolean() ?: false, onSuccess = params["webhook.onSuccess"]?.toBoolean() ?: false, diff --git a/src/main/resources/buildServerResources/editNotifierSettings.jsp b/src/main/resources/buildServerResources/editNotifierSettings.jsp index 1e3aa36..c8e7ccf 100644 --- a/src/main/resources/buildServerResources/editNotifierSettings.jsp +++ b/src/main/resources/buildServerResources/editNotifierSettings.jsp @@ -132,6 +132,19 @@ + +
+ + + Shown in the list below so webhooks can be told apart (URLs stay masked). +
+