Implement X-Callback-Secret for validating API requests

This commit is contained in:
2025-12-15 23:16:48 +01:00
parent c57322e38d
commit 53bb0eb79d
14 changed files with 159 additions and 16 deletions

View File

@@ -19,6 +19,7 @@ package web
import (
"bytes"
"context"
"crypto/subtle"
"crypto/tls"
"encoding/json"
"errors"
@@ -171,6 +172,31 @@ func UnbanIPHandler(c *gin.Context) {
// BanNotificationHandler processes incoming ban notifications from Fail2Ban.
func BanNotificationHandler(c *gin.Context) {
// Validate callback secret
settings := config.GetSettings()
providedSecret := c.GetHeader("X-Callback-Secret")
expectedSecret := settings.CallbackSecret
// Use constant-time comparison to prevent timing attacks
if expectedSecret == "" {
log.Printf("⚠️ Callback secret not configured, rejecting request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Callback secret not configured"})
return
}
if providedSecret == "" {
log.Printf("⚠️ Missing X-Callback-Secret header in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Callback-Secret header"})
return
}
// Constant-time comparison
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(expectedSecret)) != 1 {
log.Printf("⚠️ Invalid callback secret in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid callback secret"})
return
}
var request struct {
ServerID string `json:"serverId"`
IP string `json:"ip" binding:"required"`

View File

@@ -60,7 +60,7 @@ function showBanEventToast(event) {
+ ' <i class="fas fa-shield-alt text-red-500"></i>'
+ ' </div>'
+ ' <div class="flex-1 min-w-0">'
+ ' <div class="font-semibold text-sm">New Block Detected</div>'
+ ' <div class="font-semibold text-sm">New block occurred</div>'
+ ' <div class="text-sm mt-1">'
+ ' <span class="font-mono font-semibold">' + escapeHtml(ip) + '</span>'
+ ' <span> banned in </span>'

View File

@@ -48,6 +48,19 @@ function loadSettings() {
// Set callback URL and add auto-update listener for port changes
const callbackURLInput = document.getElementById('callbackURL');
callbackURLInput.value = data.callbackUrl || '';
const callbackSecretInput = document.getElementById('callbackSecret');
const toggleLink = document.getElementById('toggleCallbackSecretLink');
if (callbackSecretInput) {
callbackSecretInput.value = data.callbackSecret || '';
// Reset to password type when loading
if (callbackSecretInput.type === 'text') {
callbackSecretInput.type = 'password';
}
// Update link text
if (toggleLink) {
toggleLink.textContent = 'show secret';
}
}
// Auto-update callback URL when port changes (if using default localhost pattern)
function updateCallbackURLIfDefault() {
@@ -159,6 +172,7 @@ function saveSettings(event) {
debug: document.getElementById('debugMode').checked,
destemail: document.getElementById('destEmail').value.trim(),
callbackUrl: callbackUrl,
callbackSecret: document.getElementById('callbackSecret').value.trim(),
alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"],
bantimeIncrement: document.getElementById('bantimeIncrement').checked,
defaultJailEnable: document.getElementById('defaultJailEnable').checked,
@@ -428,3 +442,15 @@ if (advancedIntegrationSelect) {
advancedIntegrationSelect.addEventListener('change', updateAdvancedIntegrationFields);
}
// Toggle callback secret visibility
function toggleCallbackSecretVisibility() {
const input = document.getElementById('callbackSecret');
const link = document.getElementById('toggleCallbackSecretLink');
if (!input || !link) return;
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
link.textContent = isPassword ? 'hide secret' : 'show secret';
}

View File

@@ -220,6 +220,16 @@
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.callback_url_hint">This URL is used by all Fail2Ban instances to send ban alerts back to Fail2Ban UI. For local deployments, use the same port as Fail2Ban UI (e.g., http://127.0.0.1:8080). For reverse proxy setups, use your TLS-encrypted endpoint (e.g., https://fail2ban.example.com).</p>
</div>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label for="callbackSecret" class="block text-sm font-medium text-gray-700" data-i18n="settings.callback_secret">Fail2ban Callback URL Secret</label>
<a href="#" id="toggleCallbackSecretLink" class="text-sm text-blue-600 hover:text-blue-800 underline" onclick="toggleCallbackSecretVisibility(); return false;">show secret</a>
</div>
<input type="password" class="w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-100 cursor-not-allowed" id="callbackSecret" readonly
data-i18n-placeholder="settings.callback_secret_placeholder" placeholder="Auto-generated 42-character secret" />
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.callback_secret.description">This secret is automatically generated and used to authenticate ban notification requests. It is included in the fail2ban action configuration.</p>
</div>
<!-- Debug Log Output -->
<div class="flex items-center border border-gray-200 rounded-lg p-2 overflow-x-auto bg-gray-50">
<input type="checkbox" id="debugMode" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out">