- Rename crowdsec-firewall-bouncer to secubox-app-cs-firewall-bouncer - Rename secubox-auth-logger to secubox-app-auth-logger - Delete secubox-crowdsec-setup (merged into other packages) - Fix circular dependencies in luci-app-secubox-crowdsec - Fix dependency chain in secubox-app-crowdsec-bouncer - Add consolidated get_overview API to crowdsec-dashboard - Improve crowdsec-dashboard overview performance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
195 lines
6.6 KiB
JavaScript
195 lines
6.6 KiB
JavaScript
/**
|
|
* SecuBox Auth Hook - Intercepts LuCI login failures for CrowdSec
|
|
* Copyright (C) 2024 CyberMind.fr
|
|
*
|
|
* This script hooks into LuCI's authentication system to log
|
|
* failed login attempts with the real client IP address.
|
|
*
|
|
* The hook intercepts XMLHttpRequest calls to session.login and
|
|
* reports failures to our CGI endpoint which has access to REMOTE_ADDR.
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Only run once
|
|
if (window._secuboxAuthHookLoaded) return;
|
|
window._secuboxAuthHookLoaded = true;
|
|
|
|
var AUTH_HOOK_URL = '/cgi-bin/secubox-auth-hook';
|
|
|
|
// Debounce to avoid multiple logs for same attempt
|
|
var lastLogTime = 0;
|
|
var lastLogUser = '';
|
|
|
|
/**
|
|
* Log auth failure to our CGI endpoint
|
|
* The CGI endpoint gets REMOTE_ADDR and logs with real client IP
|
|
*/
|
|
function logAuthFailure(username) {
|
|
var now = Date.now();
|
|
// Debounce: don't log same user within 2 seconds
|
|
if (username === lastLogUser && (now - lastLogTime) < 2000) {
|
|
return;
|
|
}
|
|
lastLogTime = now;
|
|
lastLogUser = username;
|
|
|
|
try {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', AUTH_HOOK_URL, true);
|
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
// Send with dummy password - CGI will detect it's a log-only call
|
|
// and just log the failure with the real client IP
|
|
xhr.send(JSON.stringify({
|
|
username: username || 'root',
|
|
password: '__SECUBOX_LOG_FAILURE__'
|
|
}));
|
|
} catch (e) {
|
|
// Silently fail - don't break login flow
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a ubus response indicates login failure
|
|
*/
|
|
function isLoginFailure(result) {
|
|
if (!result) return false;
|
|
|
|
// UBUS JSON-RPC response format: { result: [error_code, data] }
|
|
if (result.result && Array.isArray(result.result)) {
|
|
var errorCode = result.result[0];
|
|
var data = result.result[1];
|
|
|
|
// Error code != 0 means failure
|
|
if (errorCode !== 0) return true;
|
|
|
|
// Check for empty session (credential failure)
|
|
if (data && data.ubus_rpc_session === '') return true;
|
|
if (data && !data.ubus_rpc_session) return true;
|
|
}
|
|
|
|
// Check for error response
|
|
if (result.error) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Extract username from login call params
|
|
*/
|
|
function extractUsername(call) {
|
|
try {
|
|
if (call.params && call.params[2] && call.params[2].username) {
|
|
return call.params[2].username;
|
|
}
|
|
} catch (e) {}
|
|
return 'root';
|
|
}
|
|
|
|
/**
|
|
* Intercept fetch API calls (modern LuCI)
|
|
*/
|
|
var originalFetch = window.fetch;
|
|
if (originalFetch) {
|
|
window.fetch = function(url, options) {
|
|
var requestBody = null;
|
|
var loginCalls = [];
|
|
|
|
// Parse request to find login calls
|
|
if (url && url.indexOf('ubus') !== -1 && options && options.body) {
|
|
try {
|
|
requestBody = JSON.parse(options.body);
|
|
if (Array.isArray(requestBody)) {
|
|
requestBody.forEach(function(call, idx) {
|
|
if (call && call.method === 'call' &&
|
|
call.params && call.params[1] === 'login') {
|
|
loginCalls.push({ call: call, idx: idx });
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
return originalFetch.apply(this, arguments).then(function(response) {
|
|
// Check login results
|
|
if (loginCalls.length > 0) {
|
|
response.clone().json().then(function(data) {
|
|
if (Array.isArray(data)) {
|
|
loginCalls.forEach(function(item) {
|
|
var result = data[item.idx];
|
|
if (isLoginFailure(result)) {
|
|
logAuthFailure(extractUsername(item.call));
|
|
}
|
|
});
|
|
}
|
|
}).catch(function() {});
|
|
}
|
|
return response;
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Intercept XMLHttpRequest (older LuCI versions)
|
|
*/
|
|
var originalXHRSend = XMLHttpRequest.prototype.send;
|
|
var originalXHROpen = XMLHttpRequest.prototype.open;
|
|
|
|
XMLHttpRequest.prototype.open = function(method, url) {
|
|
this._secuboxUrl = url;
|
|
this._secuboxMethod = method;
|
|
return originalXHROpen.apply(this, arguments);
|
|
};
|
|
|
|
XMLHttpRequest.prototype.send = function(body) {
|
|
var xhr = this;
|
|
var url = this._secuboxUrl;
|
|
var loginCalls = [];
|
|
|
|
// Only intercept POST to ubus
|
|
if (this._secuboxMethod === 'POST' && url && url.indexOf('ubus') !== -1 && body) {
|
|
try {
|
|
var parsedBody = JSON.parse(body);
|
|
if (Array.isArray(parsedBody)) {
|
|
parsedBody.forEach(function(call, idx) {
|
|
if (call && call.method === 'call' &&
|
|
call.params && call.params[1] === 'login') {
|
|
loginCalls.push({ call: call, idx: idx });
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
if (loginCalls.length > 0) {
|
|
var originalOnReadyStateChange = xhr.onreadystatechange;
|
|
xhr.onreadystatechange = function() {
|
|
if (xhr.readyState === 4 && xhr.status === 200) {
|
|
try {
|
|
var response = JSON.parse(xhr.responseText);
|
|
if (Array.isArray(response)) {
|
|
loginCalls.forEach(function(item) {
|
|
var result = response[item.idx];
|
|
if (isLoginFailure(result)) {
|
|
logAuthFailure(extractUsername(item.call));
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
if (originalOnReadyStateChange) {
|
|
originalOnReadyStateChange.apply(this, arguments);
|
|
}
|
|
};
|
|
}
|
|
|
|
return originalXHRSend.apply(this, arguments);
|
|
};
|
|
|
|
// Debug message in console
|
|
if (window.console && console.log) {
|
|
console.log('[SecuBox] Auth hook v1.1 loaded - LuCI login failures will be logged for CrowdSec');
|
|
}
|
|
})();
|