Skip to content

Commit 19ccfdf

Browse files
committed
Security hardening: fix session fixation, origin validation, command injection, and 12 more issues
- Prevent session fixation by always generating server-side UUIDs for new sessions - Harden origin validation to exact match only (remove wildcard suffix and localhost substring matching) - Add command injection prevention for all active response arguments via _sanitize_ar_argument - Fix auth token scopes: empty list [] now correctly denies access (None = full access for legacy) - Fix circuit breaker tripping on user input errors (narrow to connection/server exceptions) - Fix retry logic: let httpx 5xx/ConnectError/TimeoutException propagate to tenacity instead of wrapping - Fix SSE ACTIVE_CONNECTIONS double-decrement by moving dec into generator finally block - Replace regex IP validation with ipaddress.ip_address() for proper IPv4/IPv6 support - Fix SanitizingLogFilter crash when log record args is a dict - Replace Redis KEYS with SCAN for O(log N) iteration instead of O(N) blocking - Fix indexer _search retry: let server errors propagate for tenacity - Improve check_agent_isolation and check_user_status to use alert history - Add level parameter format validation and group_by parameter whitelist - Bound auth token storage (10k max), OAuth stores, and rate limiter memory - Remove Redis URL from log messages to prevent credential leakage - Add 21 new tests covering all fixes (54 total) - Bump version to 4.0.9
1 parent af34eeb commit 19ccfdf

File tree

16 files changed

+503
-80
lines changed

16 files changed

+503
-80
lines changed

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [4.0.9] - 2026-03-02
9+
10+
### Security
11+
- **Session fixation prevention**: Server now always generates UUIDs for new sessions; client-provided session IDs are only used to look up existing sessions
12+
- **Origin validation hardened**: Removed insecure wildcard suffix matching (`*.example.com`) and overly-permissive localhost substring matching; only exact match and explicit `*` wildcard are allowed
13+
- **Command injection prevention**: All active response and rollback methods now sanitize arguments, blocking shell metacharacters (`;`, `|`, `` ` ``, `$`, etc.)
14+
- **Auth token scopes**: Empty scopes list (`[]`) now correctly denies all access; previously returned True like `None` (full access)
15+
- **Bounded token storage**: Auth token store evicts oldest entries when exceeding 10,000 tokens
16+
- **OAuth bounded stores**: Authorization codes (1,000 max), access/refresh tokens (5,000 max), and client registrations (1,000 max) are now bounded to prevent memory exhaustion
17+
- **Rate limiter bounded memory**: Added `MAX_TRACKED_CLIENTS = 10,000` with automatic cleanup of stale entries
18+
- **Redis URL logging**: Removed Redis URLs (which may contain passwords) from log messages
19+
20+
### Fixed
21+
- **Circuit breaker tripping on user errors**: Narrowed `expected_exception` from `Exception` to specific connection/server error types so `ValueError` doesn't trip the circuit
22+
- **Retry logic defeated by exception wrapping**: 5xx `HTTPStatusError`, `ConnectError`, and `TimeoutException` now propagate directly to tenacity instead of being wrapped
23+
- **SSE ACTIVE_CONNECTIONS double-decrement**: Moved decrement into SSE generator `finally` block with `track_connection` flag to prevent gauge going negative
24+
- **IPv6 validation**: Replaced regex-based IP validation with `ipaddress.ip_address()` for proper IPv4 and IPv6 support
25+
- **SanitizingLogFilter dict args**: Fixed crash when log record args is a dict instead of a tuple
26+
- **Redis KEYS command**: Replaced `KEYS` (O(N) blocking) with `SCAN` (cursor-based iteration) in `RedisSessionStore`
27+
- **Indexer retry logic**: `_search()` now lets 5xx, ConnectError, and TimeoutException propagate for tenacity retry
28+
- **`check_agent_isolation`**: Now checks alert history for isolation evidence instead of using disconnected status as a proxy
29+
- **`check_user_status`**: Now searches active response alert history instead of returning hardcoded data
30+
31+
### Added
32+
- `_sanitize_ar_argument()` static method on `WazuhClient` for input sanitization of active response commands
33+
- `group_by` parameter validation with whitelist of allowed fields
34+
- `level` parameter format validation (must match `^[0-9]{1,2}\+?$`)
35+
- 21 new test cases covering all audit v2 fixes (54 total tests)
36+
37+
### Changed
38+
- `CircuitBreakerConfig.expected_exception` now accepts `Union[Type[Exception], Tuple[Type[Exception], ...]]`
39+
- `WazuhClient` circuit breaker uses `(ConnectionError, httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError)` instead of `Exception`
40+
841
## [4.0.8] - 2026-02-26
942

1043
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
**Production-ready MCP server connecting AI assistants to Wazuh SIEM.**
99

10-
> **Version 4.0.8** | Wazuh 4.8.0 - 4.14.3 | [Full Changelog](CHANGELOG.md)
10+
> **Version 4.0.9** | Wazuh 4.8.0 - 4.14.3 | [Full Changelog](CHANGELOG.md)
1111
1212
---
1313

docs/api/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# FastMCP Tools API Reference
22

3-
Complete reference for all 48 tools available in Wazuh MCP Server v4.0.8.
3+
Complete reference for all 48 tools available in Wazuh MCP Server v4.0.9.
44

55
## 🛠️ Tool Categories
66

@@ -141,7 +141,7 @@ All tools return JSON responses with consistent structure:
141141
"metadata": {
142142
"query_time": "2024-01-01T12:00:00Z",
143143
"api_source": "wazuh_server",
144-
"version": "4.0.8"
144+
"version": "4.0.9"
145145
}
146146
}
147147
```

docs/configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Configuration Guide
22

3-
Complete configuration reference for Wazuh MCP Server v4.0.8.
3+
Complete configuration reference for Wazuh MCP Server v4.0.9.
44

55
## 📋 Configuration Overview
66

@@ -128,7 +128,7 @@ CACHE_MAX_SIZE=1000 # Maximum cache entries
128128
# Transport Settings
129129
MCP_TRANSPORT=streamable-http # Transport type (streamable-http)
130130
MCP_SERVER_NAME=Wazuh MCP Server # Server name
131-
MCP_SERVER_VERSION=4.0.8 # Server version
131+
MCP_SERVER_VERSION=4.0.9 # Server version
132132

133133
# Tool Configuration
134134
ENABLE_SECURITY_TOOLS=true # Enable security analysis tools

docs/security/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Security Configuration Guide
22

3-
Comprehensive security hardening guide for Wazuh MCP Server v4.0.8 production deployments.
3+
Comprehensive security hardening guide for Wazuh MCP Server v4.0.9 production deployments.
44

55
## 🔒 Security Overview
66

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "wazuh-mcp-server"
7-
version = "4.0.8"
7+
version = "4.0.9"
88
description = "Production-grade MCP remote server for Wazuh SIEM integration with SSE transport"
99
readme = "README.md"
1010
license = {text = "MIT"}

src/wazuh_mcp_server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
through the Model Context Protocol (MCP), enabling natural language security operations.
55
"""
66

7-
__version__ = "4.0.8"
7+
__version__ = "4.0.9"
88
__all__ = ["__version__"]

src/wazuh_mcp_server/api/wazuh_client.py

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,13 @@ def __init__(self, config: WazuhConfig):
5959
self._cache_ttl = 300 # 5 minutes for static data
6060
self._cache_max_size = 100
6161

62-
# Circuit breaker for API resilience
63-
circuit_config = CircuitBreakerConfig(failure_threshold=5, recovery_timeout=60, expected_exception=Exception)
62+
# Circuit breaker for API resilience — only trip on connection/server errors,
63+
# not on user-input errors (ValueError) which shouldn't degrade the circuit
64+
circuit_config = CircuitBreakerConfig(
65+
failure_threshold=5,
66+
recovery_timeout=60,
67+
expected_exception=(ConnectionError, httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError),
68+
)
6469
self._circuit_breaker = CircuitBreaker(circuit_config)
6570

6671
# Initialize Wazuh Indexer client if configured (required for Wazuh 4.8.0+)
@@ -421,15 +426,23 @@ async def _execute_request(self, method: str, endpoint: str, **kwargs) -> Dict[s
421426
return response.json()
422427
except (json.JSONDecodeError, ValueError):
423428
raise ValueError(f"Invalid JSON response from Wazuh API after re-auth: {endpoint}")
429+
elif e.response.status_code >= 500:
430+
# Server errors: let them propagate as httpx exceptions so tenacity
431+
# retry logic can see them and retry (via _is_retryable)
432+
logger.error(f"Wazuh API server error: {e.response.status_code} - {e.response.text}")
433+
raise
424434
else:
435+
# Client errors (4xx except 401): not retryable, wrap as ValueError
425436
logger.error(f"Wazuh API request failed: {e.response.status_code} - {e.response.text}")
426437
raise ValueError(f"Wazuh API request failed: {e.response.status_code} - {e.response.text}")
427438
except httpx.ConnectError:
439+
# Let connection errors propagate for retry logic
428440
logger.error(f"Lost connection to Wazuh server at {self.config.wazuh_host}")
429-
raise ConnectionError(f"Lost connection to Wazuh server at {self.config.wazuh_host}")
441+
raise
430442
except httpx.TimeoutException:
443+
# Let timeout errors propagate for retry logic
431444
logger.error("Request timeout to Wazuh server")
432-
raise ConnectionError("Request timeout to Wazuh server")
445+
raise
433446

434447
async def get_manager_info(self) -> Dict[str, Any]:
435448
"""Get Wazuh manager information (cached for 5 minutes)."""
@@ -837,8 +850,27 @@ async def validate_connection(self) -> Dict[str, Any]:
837850
# Active Response / Action Tools
838851
# =========================================================================
839852

853+
@staticmethod
854+
def _sanitize_ar_argument(value: str, param_name: str) -> str:
855+
"""Sanitize active response argument to prevent command injection.
856+
857+
Active response arguments are passed to shell commands on agents.
858+
Only allow safe characters to prevent shell metacharacter injection.
859+
"""
860+
import re
861+
862+
# Strip leading/trailing whitespace
863+
value = value.strip()
864+
if not value:
865+
raise ValueError(f"{param_name} cannot be empty")
866+
# Block shell metacharacters and control chars
867+
if re.search(r'[;&|`$(){}\[\]<>!\\\'"\n\r\t]', value):
868+
raise ValueError(f"{param_name} contains invalid characters")
869+
return value
870+
840871
async def block_ip(self, ip_address: str, duration: int = 0, agent_id: str = None) -> Dict[str, Any]:
841872
"""Block IP via firewall-drop active response."""
873+
ip_address = self._sanitize_ar_argument(ip_address, "ip_address")
842874
data = {
843875
"command": "firewall-drop0",
844876
"agent_list": [agent_id] if agent_id else ["all"],
@@ -854,29 +886,32 @@ async def isolate_host(self, agent_id: str) -> Dict[str, Any]:
854886

855887
async def kill_process(self, agent_id: str, process_id: int) -> Dict[str, Any]:
856888
"""Kill process on agent via active response."""
857-
data = {"command": "kill-process0", "agent_list": [agent_id], "arguments": [str(process_id)]}
889+
data = {"command": "kill-process0", "agent_list": [agent_id], "arguments": [str(int(process_id))]}
858890
return await self.execute_active_response(data)
859891

860892
async def disable_user(self, agent_id: str, username: str) -> Dict[str, Any]:
861893
"""Disable user account on agent via active response."""
894+
username = self._sanitize_ar_argument(username, "username")
862895
data = {"command": "disable-account0", "agent_list": [agent_id], "arguments": [username]}
863896
return await self.execute_active_response(data)
864897

865898
async def quarantine_file(self, agent_id: str, file_path: str) -> Dict[str, Any]:
866899
"""Quarantine file on agent via active response."""
900+
file_path = self._sanitize_ar_argument(file_path, "file_path")
867901
data = {"command": "quarantine0", "agent_list": [agent_id], "arguments": [file_path]}
868902
return await self.execute_active_response(data)
869903

870904
async def run_active_response(self, agent_id: str, command: str, parameters: dict = None) -> Dict[str, Any]:
871905
"""Execute generic active response command."""
872906
args = []
873907
if parameters:
874-
args = [f"{k}={v}" for k, v in parameters.items()]
908+
args = [self._sanitize_ar_argument(f"{k}={v}", f"parameter:{k}") for k, v in parameters.items()]
875909
data = {"command": command, "agent_list": [agent_id], "arguments": args}
876910
return await self.execute_active_response(data)
877911

878912
async def firewall_drop(self, agent_id: str, src_ip: str, duration: int = 0) -> Dict[str, Any]:
879913
"""Add firewall drop rule via active response."""
914+
src_ip = self._sanitize_ar_argument(src_ip, "src_ip")
880915
data = {
881916
"command": "firewall-drop0",
882917
"agent_list": [agent_id],
@@ -887,6 +922,7 @@ async def firewall_drop(self, agent_id: str, src_ip: str, duration: int = 0) ->
887922

888923
async def host_deny(self, agent_id: str, src_ip: str) -> Dict[str, Any]:
889924
"""Add hosts.deny entry via active response."""
925+
src_ip = self._sanitize_ar_argument(src_ip, "src_ip")
890926
data = {
891927
"command": "host-deny0",
892928
"agent_list": [agent_id],
@@ -915,18 +951,33 @@ async def check_blocked_ip(self, ip_address: str, agent_id: str = None) -> Dict[
915951
return {"data": {"ip_address": ip_address, "blocked": len(matches) > 0, "matching_alerts": len(matches)}}
916952

917953
async def check_agent_isolation(self, agent_id: str) -> Dict[str, Any]:
918-
"""Check agent isolation status."""
954+
"""Check agent isolation status by examining agent connectivity and alert history."""
919955
result = await self._request("GET", "/agents", params={"agents_list": agent_id, "select": "id,name,status"})
920956
agents = result.get("data", {}).get("affected_items", [])
921957
if not agents:
922958
raise ValueError(f"Agent {agent_id} not found")
923959
agent = agents[0]
960+
status = agent.get("status")
961+
# Check alert history for isolation commands if indexer is available
962+
isolation_confirmed = False
963+
if self._indexer_client and status == "disconnected":
964+
try:
965+
alerts = await self._indexer_client.get_alerts(limit=20)
966+
items = alerts.get("data", {}).get("affected_items", [])
967+
isolation_confirmed = any(
968+
_dict_contains_text(a, "host-isolation") and _dict_contains_text(a, agent_id) for a in items
969+
)
970+
except Exception:
971+
pass
924972
return {
925973
"data": {
926974
"agent_id": agent_id,
927-
"isolated": agent.get("status") == "disconnected",
928-
"status": agent.get("status"),
975+
"status": status,
976+
"possibly_isolated": status == "disconnected",
977+
"isolation_confirmed": isolation_confirmed,
929978
"name": agent.get("name"),
979+
"note": "A disconnected agent may be isolated or simply offline. "
980+
"Check isolation_confirmed for active response evidence.",
930981
}
931982
}
932983

@@ -938,13 +989,32 @@ async def check_process(self, agent_id: str, process_id: int) -> Dict[str, Any]:
938989
return {"data": {"agent_id": agent_id, "process_id": process_id, "running": running}}
939990

940991
async def check_user_status(self, agent_id: str, username: str) -> Dict[str, Any]:
941-
"""Check if a user account is disabled."""
992+
"""Check user account status by searching active response alerts and agent logs."""
993+
# Search for disable-account active response alerts
994+
disable_evidence = False
995+
enable_evidence = False
996+
if self._indexer_client:
997+
try:
998+
alerts = await self._indexer_client.get_alerts(limit=50)
999+
items = alerts.get("data", {}).get("affected_items", [])
1000+
for alert in items:
1001+
if _dict_contains_text(alert, username) and _dict_contains_text(alert, agent_id):
1002+
if _dict_contains_text(alert, "disable-account"):
1003+
disable_evidence = True
1004+
if _dict_contains_text(alert, "enable-account"):
1005+
enable_evidence = True
1006+
except Exception:
1007+
pass
1008+
# Most recent action takes precedence
1009+
likely_disabled = disable_evidence and not enable_evidence
9421010
return {
9431011
"data": {
9441012
"agent_id": agent_id,
9451013
"username": username,
946-
"disabled": False,
947-
"note": "Check agent logs for disable-account confirmation",
1014+
"likely_disabled": likely_disabled,
1015+
"disable_action_found": disable_evidence,
1016+
"enable_action_found": enable_evidence,
1017+
"note": "Status based on active response alert history. " "Verify on the host for definitive status.",
9481018
}
9491019
}
9501020

@@ -966,16 +1036,19 @@ async def unisolate_host(self, agent_id: str) -> Dict[str, Any]:
9661036

9671037
async def enable_user(self, agent_id: str, username: str) -> Dict[str, Any]:
9681038
"""Re-enable user account via active response."""
1039+
username = self._sanitize_ar_argument(username, "username")
9691040
data = {"command": "enable-account0", "agent_list": [agent_id], "arguments": [username]}
9701041
return await self.execute_active_response(data)
9711042

9721043
async def restore_file(self, agent_id: str, file_path: str) -> Dict[str, Any]:
9731044
"""Restore a quarantined file via active response."""
1045+
file_path = self._sanitize_ar_argument(file_path, "file_path")
9741046
data = {"command": "quarantine0", "agent_list": [agent_id], "arguments": ["restore", file_path]}
9751047
return await self.execute_active_response(data)
9761048

9771049
async def firewall_allow(self, agent_id: str, src_ip: str) -> Dict[str, Any]:
9781050
"""Remove firewall drop rule via active response."""
1051+
src_ip = self._sanitize_ar_argument(src_ip, "src_ip")
9791052
data = {
9801053
"command": "firewall-drop0",
9811054
"agent_list": [agent_id],
@@ -985,6 +1058,7 @@ async def firewall_allow(self, agent_id: str, src_ip: str) -> Dict[str, Any]:
9851058

9861059
async def host_allow(self, agent_id: str, src_ip: str) -> Dict[str, Any]:
9871060
"""Remove hosts.deny entry via active response."""
1061+
src_ip = self._sanitize_ar_argument(src_ip, "src_ip")
9881062
data = {
9891063
"command": "host-deny0",
9901064
"agent_list": [agent_id],

src/wazuh_mcp_server/api/wazuh_indexer.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ async def _ensure_initialized(self):
9292
@retry(
9393
stop=stop_after_attempt(3),
9494
wait=wait_exponential(multiplier=1, min=1, max=10),
95-
retry=retry_if_exception_type((httpx.RequestError, httpx.HTTPStatusError)),
95+
retry=retry_if_exception_type(
96+
(httpx.RequestError, httpx.HTTPStatusError, httpx.ConnectError, httpx.TimeoutException)
97+
),
9698
reraise=True,
9799
)
98100
async def _search(
@@ -127,11 +129,16 @@ async def _search(
127129

128130
except httpx.HTTPStatusError as e:
129131
logger.error(f"Indexer search failed: {e.response.status_code} - {e.response.text}")
132+
if e.response.status_code >= 500:
133+
# Let server errors propagate so tenacity retry can see them
134+
raise
130135
raise ValueError(f"Indexer query failed: {e.response.status_code}")
131136
except httpx.ConnectError:
132-
raise ConnectionError(f"Cannot connect to Wazuh Indexer at {self.host}:{self.port}")
137+
# Let connection errors propagate for retry
138+
raise
133139
except httpx.TimeoutException:
134-
raise ConnectionError(f"Timeout connecting to Wazuh Indexer at {self.host}:{self.port}")
140+
# Let timeout errors propagate for retry
141+
raise
135142

136143
async def get_alerts(
137144
self,

src/wazuh_mcp_server/auth.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ def is_valid(self) -> bool:
4040

4141
def has_scope(self, scope: str) -> bool:
4242
"""Check if token has specific scope."""
43+
if self.scopes is None:
44+
return True # None means scopes not configured (legacy tokens)
4345
if not self.scopes:
44-
return True # No scopes means full access
46+
return False # Empty list means no permissions
4547
return scope in self.scopes
4648

4749

@@ -207,6 +209,15 @@ def create_token(self, api_key: str) -> Optional[str]:
207209
metadata={"api_key_name": key_obj.name, **key_obj.metadata},
208210
)
209211

212+
# Bound token storage to prevent memory growth
213+
if len(self.tokens) > 10000:
214+
self.cleanup_expired()
215+
if len(self.tokens) > 10000:
216+
# Still too many — evict oldest tokens
217+
sorted_tokens = sorted(self.tokens.items(), key=lambda t: t[1].created_at)
218+
for old_token, _ in sorted_tokens[: len(sorted_tokens) - 5000]:
219+
self.tokens.pop(old_token, None)
220+
210221
self.tokens[token] = token_obj
211222
return token
212223

0 commit comments

Comments
 (0)