mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-12 07:38:39 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 882d25bb68 | |||
| 0ebf6583ea | |||
| 6be19f0bb5 | |||
| 9e5185c458 | |||
| 520b69a1c6 | |||
| 56107fa86f | |||
| f86a4331e8 | |||
| 1c47e246ec | |||
| d914f2398b | |||
| 9c1f3c811b |
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status, Header
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
import logging
|
import logging
|
||||||
@@ -92,6 +92,18 @@ async def get_current_user_from_token(authorization: Optional[str] = None) -> Op
|
|||||||
detail="Authentication failed"
|
detail="Authentication failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def require_authenticated_user(authorization: Optional[str] = Header(None)) -> Dict[str, Any]:
|
||||||
|
"""FastAPI dependency: require a valid operator JWT, else 401.
|
||||||
|
|
||||||
|
Reads the Authorization header itself, so it can be attached at router or
|
||||||
|
route level to gate operator/UI endpoints that must not be public:
|
||||||
|
APIRouter(..., dependencies=[Depends(require_authenticated_user)])
|
||||||
|
@router.get(..., dependencies=[Depends(require_authenticated_user)])
|
||||||
|
Any authenticated user passes (no fine-grained RBAC here) — this restores the
|
||||||
|
pre-existing "logged-in users only" expectation without changing role access.
|
||||||
|
"""
|
||||||
|
return await get_current_user_from_token(authorization)
|
||||||
|
|
||||||
async def get_current_user_from_token_no_exception(authorization: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
async def get_current_user_from_token_no_exception(authorization: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get current user from JWT token without raising HTTPException.
|
Get current user from JWT token without raising HTTPException.
|
||||||
|
|||||||
+141
-84
@@ -251,7 +251,7 @@ def calculate_agent_health(status, last_seen):
|
|||||||
return "offline"
|
return "offline"
|
||||||
|
|
||||||
@router.get("", summary="Get All Agents", response_description="List of all agents")
|
@router.get("", summary="Get All Agents", response_description="List of all agents")
|
||||||
async def get_agents(pool_id: Optional[int] = None, authorization: str = Header(None)):
|
async def get_agents(pool_id: Optional[int] = None, authorization: str = Header(None), x_api_key: Optional[str] = Header(None)):
|
||||||
"""
|
"""
|
||||||
# Get All Agents
|
# Get All Agents
|
||||||
|
|
||||||
@@ -303,9 +303,22 @@ async def get_agents(pool_id: Optional[int] = None, authorization: str = Header(
|
|||||||
- **haproxy_status**: Status of HAProxy service on agent's server
|
- **haproxy_status**: Status of HAProxy service on agent's server
|
||||||
- **last_seen**: Last heartbeat timestamp
|
- **last_seen**: Last heartbeat timestamp
|
||||||
"""
|
"""
|
||||||
|
# SECURITY (GHSA-3p5c-m5m4-mjpx): the agent inventory (names, hostnames, IPs,
|
||||||
|
# pools, OS) is operator data and was previously served unauthenticated — it is
|
||||||
|
# also the read-back channel used in the RCE exfil PoC. Require EITHER a valid
|
||||||
|
# operator JWT OR a valid agent X-API-Key: deployed agents poll this endpoint
|
||||||
|
# (with their key, not a JWT) to read their own applied_config_version and avoid
|
||||||
|
# re-applying config on restart, so a JWT-only gate would break them. Checked
|
||||||
|
# before the try so the 401 is not swallowed by the generic handler.
|
||||||
|
if authorization:
|
||||||
|
current_user = await get_current_user_from_token(authorization) # raises 401 on invalid JWT
|
||||||
|
else:
|
||||||
|
from auth_middleware import validate_agent_api_key
|
||||||
|
if not await validate_agent_api_key(x_api_key):
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
try:
|
try:
|
||||||
conn = await get_database_connection()
|
conn = await get_database_connection()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if pool_id:
|
if pool_id:
|
||||||
agents = await conn.fetch("""
|
agents = await conn.fetch("""
|
||||||
@@ -763,6 +776,14 @@ async def generate_uninstall_script(platform: str, authorization: str = Header(N
|
|||||||
sudo ./uninstall-agent.sh
|
sudo ./uninstall-agent.sh
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
# SECURITY (GHSA-3p5c-m5m4-mjpx): require authentication (operator JWT or agent
|
||||||
|
# key), consistent with generate-install-script. The uninstall script itself is
|
||||||
|
# generic (no secrets/topology), but an agent-management endpoint should not be
|
||||||
|
# anonymously reachable. Checked before the try so the 401 is not swallowed.
|
||||||
|
if authorization:
|
||||||
|
await get_current_user_from_token(authorization)
|
||||||
|
elif not await validate_agent_api_key(x_api_key):
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
try:
|
try:
|
||||||
# Normalize platform to a canonical key (always 'linux' or 'macos').
|
# Normalize platform to a canonical key (always 'linux' or 'macos').
|
||||||
# macOS agents register with platform 'darwin' (from `uname -s`), so the
|
# macOS agents register with platform 'darwin' (from `uname -s`), so the
|
||||||
@@ -958,14 +979,24 @@ def _extract_agent_ip(heartbeat_data: AgentHeartbeat) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@router.post("/{agent_id}/heartbeat")
|
@router.post("/{agent_id}/heartbeat")
|
||||||
async def agent_heartbeat(agent_id: int, heartbeat_data: AgentHeartbeat):
|
async def agent_heartbeat(agent_id: int, heartbeat_data: AgentHeartbeat, x_api_key: Optional[str] = Header(None)):
|
||||||
"""Receive agent heartbeat and update status."""
|
"""Receive agent heartbeat and update status."""
|
||||||
|
# Agent authentication is MANDATORY (GHSA-3p5c-m5m4-mjpx). This legacy by-ID
|
||||||
|
# heartbeat previously had NO auth, allowing unauthenticated state spoofing of
|
||||||
|
# any agent row. Deployed agents use the by-name heartbeat; a valid global
|
||||||
|
# agent token is now required here too. NOTE: raised BEFORE the try below so
|
||||||
|
# the 401 is not swallowed by the generic `except Exception` handler.
|
||||||
|
from auth_middleware import validate_agent_api_key
|
||||||
|
agent_auth = await validate_agent_api_key(x_api_key)
|
||||||
|
if not agent_auth:
|
||||||
|
logger.warning(f"Missing/invalid API key on by-id heartbeat for agent ID {agent_id}")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
try:
|
try:
|
||||||
conn = await get_database_connection()
|
conn = await get_database_connection()
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
UPDATE agents
|
UPDATE agents
|
||||||
SET status = 'online',
|
SET status = 'online',
|
||||||
last_seen = CURRENT_TIMESTAMP,
|
last_seen = CURRENT_TIMESTAMP,
|
||||||
hostname = COALESCE($2, hostname),
|
hostname = COALESCE($2, hostname),
|
||||||
haproxy_status = COALESCE($3, haproxy_status),
|
haproxy_status = COALESCE($3, haproxy_status),
|
||||||
@@ -1088,6 +1119,8 @@ async def agent_config_applied_notification(agent_name: str, notification_data:
|
|||||||
await close_database_connection(conn)
|
await close_database_connection(conn)
|
||||||
return {"status": "ok", "message": "Config applied notification received"}
|
return {"status": "ok", "message": "Config applied notification received"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise # let auth 401/403 propagate (do not turn it into a 200 error body)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process config applied notification from agent '{agent_name}': {e}")
|
logger.error(f"Failed to process config applied notification from agent '{agent_name}': {e}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
@@ -1183,6 +1216,8 @@ async def agent_config_validation_failed(agent_name: str, notification_data: dic
|
|||||||
|
|
||||||
return {"status": "ok", "message": "Validation error notification received"}
|
return {"status": "ok", "message": "Validation error notification received"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise # let auth 401/403 propagate (do not turn it into a 200 error body)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process validation error notification from agent '{agent_name}': {e}")
|
logger.error(f"Failed to process validation error notification from agent '{agent_name}': {e}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
@@ -1472,6 +1507,8 @@ async def agent_config_sync(agent_name: str, sync_data: dict, x_api_key: Optiona
|
|||||||
logger.info(f"CONFIG SYNC: Agent '{agent_name}' synced {len(active_backends)} backends, {len(active_frontends)} frontends, {len(active_servers)} servers with database")
|
logger.info(f"CONFIG SYNC: Agent '{agent_name}' synced {len(active_backends)} backends, {len(active_frontends)} frontends, {len(active_servers)} servers with database")
|
||||||
return {"status": "ok", "message": f"Config synced - {len(active_backends)} backends, {len(active_frontends)} frontends, {len(active_servers)} servers processed"}
|
return {"status": "ok", "message": f"Config synced - {len(active_backends)} backends, {len(active_frontends)} frontends, {len(active_servers)} servers processed"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise # let auth 401/403 propagate (do not turn it into a 200 error body)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process config sync from agent '{agent_name}': {e}")
|
logger.error(f"Failed to process config sync from agent '{agent_name}': {e}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
@@ -1532,21 +1569,25 @@ async def agent_heartbeat_by_name(
|
|||||||
logger.error(f"Unexpected error processing heartbeat: {e}")
|
logger.error(f"Unexpected error processing heartbeat: {e}")
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
# Agent authentication is MANDATORY (GHSA-3p5c-m5m4-mjpx). A valid global agent
|
||||||
|
# token is required to heartbeat OR auto-register. Deployed agents always send
|
||||||
|
# X-API-Key; an absent/invalid key is an unauthenticated caller. This is done
|
||||||
|
# OUTSIDE the processing try below (whose generic `except Exception` would
|
||||||
|
# otherwise convert the 401 into a 500), and before opening a DB connection
|
||||||
|
# (validate_agent_api_key(None) needs no DB). Closes keyless heartbeat spoofing
|
||||||
|
# and keyless rogue-agent auto-registration (the `elif not agent` keyless path
|
||||||
|
# below is now unreachable, since agent_auth is guaranteed truthy past here).
|
||||||
|
from auth_middleware import validate_agent_api_key
|
||||||
|
agent_auth = await validate_agent_api_key(x_api_key)
|
||||||
|
if not agent_auth:
|
||||||
|
logger.warning(f"Missing/invalid API key on heartbeat for agent '{heartbeat_data.name}'")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
# Continue with normal heartbeat processing
|
# Continue with normal heartbeat processing
|
||||||
try:
|
try:
|
||||||
# Validate agent API key for security
|
|
||||||
from auth_middleware import validate_agent_api_key
|
|
||||||
agent_auth = await validate_agent_api_key(x_api_key)
|
|
||||||
|
|
||||||
conn = await get_database_connection()
|
conn = await get_database_connection()
|
||||||
agent_name = heartbeat_data.name
|
agent_name = heartbeat_data.name
|
||||||
|
|
||||||
# If API key provided, validate it exists but allow placeholder agent updates
|
|
||||||
if x_api_key and not agent_auth:
|
|
||||||
await close_database_connection(conn)
|
|
||||||
logger.warning(f"Invalid API key provided by agent '{agent_name}'")
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
||||||
|
|
||||||
agent = await conn.fetchrow("SELECT id, pool_id, api_key FROM agents WHERE name = $1", agent_name)
|
agent = await conn.fetchrow("SELECT id, pool_id, api_key FROM agents WHERE name = $1", agent_name)
|
||||||
|
|
||||||
# If agent exists and is using a different API key, update the token association
|
# If agent exists and is using a different API key, update the token association
|
||||||
@@ -1955,9 +1996,19 @@ async def agent_heartbeat_by_name(
|
|||||||
@router.get("/{agent_name}/config")
|
@router.get("/{agent_name}/config")
|
||||||
async def get_agent_config(agent_name: str, x_api_key: Optional[str] = Header(None)):
|
async def get_agent_config(agent_name: str, x_api_key: Optional[str] = Header(None)):
|
||||||
"""Get HAProxy configuration for specific agent"""
|
"""Get HAProxy configuration for specific agent"""
|
||||||
|
# Validate agent API key — MANDATORY (GHSA-3p5c-m5m4-mjpx). Checked BEFORE any
|
||||||
|
# DB work and before the existence check, so an unauthenticated caller learns
|
||||||
|
# neither the full haproxy.cfg nor whether the agent exists. Raised before the
|
||||||
|
# try so it is not swallowed by the generic handler; validate_agent_api_key(None)
|
||||||
|
# returns None without touching the DB.
|
||||||
|
from auth_middleware import validate_agent_api_key
|
||||||
|
agent_auth = await validate_agent_api_key(x_api_key)
|
||||||
|
if not agent_auth:
|
||||||
|
logger.warning(f"Missing/invalid API key for agent '{agent_name}' config fetch")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
try:
|
try:
|
||||||
conn = await get_database_connection()
|
conn = await get_database_connection()
|
||||||
|
|
||||||
# Get agent info first to check pool
|
# Get agent info first to check pool
|
||||||
# CRITICAL: Include cluster's haproxy_bin_path, haproxy_config_path, stats_socket_path
|
# CRITICAL: Include cluster's haproxy_bin_path, haproxy_config_path, stats_socket_path
|
||||||
# These are needed for dynamic validation - cluster admin can change paths without reinstalling agent
|
# These are needed for dynamic validation - cluster admin can change paths without reinstalling agent
|
||||||
@@ -1969,30 +2020,19 @@ async def get_agent_config(agent_name: str, x_api_key: Optional[str] = Header(No
|
|||||||
LEFT JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
LEFT JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||||
WHERE a.name = $1
|
WHERE a.name = $1
|
||||||
""", agent_name)
|
""", agent_name)
|
||||||
|
|
||||||
if not agent_info:
|
if not agent_info:
|
||||||
await close_database_connection(conn)
|
await close_database_connection(conn)
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
||||||
|
|
||||||
# Validate agent API key
|
# Log which agent's API key was used (for audit trail)
|
||||||
# API key is global - can be used for multiple agents
|
if agent_auth['name'] == agent_name:
|
||||||
if x_api_key:
|
logger.info(f"Agent '{agent_name}' fetching config using its own API key")
|
||||||
from auth_middleware import validate_agent_api_key
|
else:
|
||||||
agent_auth = await validate_agent_api_key(x_api_key)
|
logger.info(f"Agent '{agent_name}' fetching config using API key from agent '{agent_auth['name']}'")
|
||||||
|
|
||||||
if not agent_auth:
|
logger.debug(f"Config fetch authorized for agent '{agent_name}'")
|
||||||
await close_database_connection(conn)
|
|
||||||
logger.warning(f"Invalid API key provided for agent '{agent_name}' config fetch")
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
||||||
|
|
||||||
# Log which agent's API key was used (for audit trail)
|
|
||||||
if agent_auth['name'] == agent_name:
|
|
||||||
logger.info(f"Agent '{agent_name}' fetching config using its own API key")
|
|
||||||
else:
|
|
||||||
logger.info(f"Agent '{agent_name}' fetching config using API key from agent '{agent_auth['name']}'")
|
|
||||||
|
|
||||||
logger.debug(f"Config fetch authorized for agent '{agent_name}'")
|
|
||||||
|
|
||||||
if not agent_info['enabled']:
|
if not agent_info['enabled']:
|
||||||
await close_database_connection(conn)
|
await close_database_connection(conn)
|
||||||
return {
|
return {
|
||||||
@@ -2083,9 +2123,18 @@ async def get_agent_config(agent_name: str, x_api_key: Optional[str] = Header(No
|
|||||||
@router.get("/{agent_name}/ssl-certificates")
|
@router.get("/{agent_name}/ssl-certificates")
|
||||||
async def get_agent_ssl_certificates(agent_name: str, since: Optional[str] = None, x_api_key: Optional[str] = Header(None)):
|
async def get_agent_ssl_certificates(agent_name: str, since: Optional[str] = None, x_api_key: Optional[str] = Header(None)):
|
||||||
"""Get SSL certificates for specific agent's cluster"""
|
"""Get SSL certificates for specific agent's cluster"""
|
||||||
|
# Validate agent API key — MANDATORY (GHSA-3p5c-m5m4-mjpx). This response
|
||||||
|
# returns SSL private_key_content, so authentication is checked BEFORE any DB
|
||||||
|
# work and before the existence check. Raised before the try so the 401 is not
|
||||||
|
# swallowed; validate_agent_api_key(None) returns None without a DB hit.
|
||||||
|
from auth_middleware import validate_agent_api_key
|
||||||
|
agent_auth = await validate_agent_api_key(x_api_key)
|
||||||
|
if not agent_auth:
|
||||||
|
logger.warning(f"Missing/invalid API key for agent '{agent_name}' SSL certificates")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
try:
|
try:
|
||||||
conn = await get_database_connection()
|
conn = await get_database_connection()
|
||||||
|
|
||||||
# Get agent and cluster info first
|
# Get agent and cluster info first
|
||||||
agent_info = await conn.fetchrow("""
|
agent_info = await conn.fetchrow("""
|
||||||
SELECT a.id, a.name, a.pool_id, hc.id as cluster_id, hc.name as cluster_name,
|
SELECT a.id, a.name, a.pool_id, hc.id as cluster_id, hc.name as cluster_name,
|
||||||
@@ -2094,29 +2143,18 @@ async def get_agent_ssl_certificates(agent_name: str, since: Optional[str] = Non
|
|||||||
LEFT JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
LEFT JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||||
WHERE a.name = $1
|
WHERE a.name = $1
|
||||||
""", agent_name)
|
""", agent_name)
|
||||||
|
|
||||||
if not agent_info:
|
if not agent_info:
|
||||||
await close_database_connection(conn)
|
await close_database_connection(conn)
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
||||||
|
|
||||||
# Validate agent API key
|
# Log which agent's API key was used (for audit trail)
|
||||||
# API key is global - can be used for multiple agents
|
if agent_auth['name'] == agent_name:
|
||||||
if x_api_key:
|
logger.info(f"Agent '{agent_name}' fetching SSL certificates using its own API key")
|
||||||
from auth_middleware import validate_agent_api_key
|
else:
|
||||||
agent_auth = await validate_agent_api_key(x_api_key)
|
logger.info(f"Agent '{agent_name}' fetching SSL certificates using API key from agent '{agent_auth['name']}'")
|
||||||
|
|
||||||
if not agent_auth:
|
logger.debug(f"SSL fetch authorized for agent '{agent_name}'")
|
||||||
await close_database_connection(conn)
|
|
||||||
logger.warning(f"Invalid API key provided for agent '{agent_name}' SSL certificates")
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
||||||
|
|
||||||
# Log which agent's API key was used (for audit trail)
|
|
||||||
if agent_auth['name'] == agent_name:
|
|
||||||
logger.info(f"Agent '{agent_name}' fetching SSL certificates using its own API key")
|
|
||||||
else:
|
|
||||||
logger.info(f"Agent '{agent_name}' fetching SSL certificates using API key from agent '{agent_auth['name']}'")
|
|
||||||
|
|
||||||
logger.debug(f"SSL fetch authorized for agent '{agent_name}'")
|
|
||||||
|
|
||||||
if not agent_info['enabled']:
|
if not agent_info['enabled']:
|
||||||
await close_database_connection(conn)
|
await close_database_connection(conn)
|
||||||
@@ -2440,16 +2478,24 @@ async def get_latest_script_version(platform: str = "macos"):
|
|||||||
@router.get("/{agent_name}/upgrade-status")
|
@router.get("/{agent_name}/upgrade-status")
|
||||||
async def get_agent_upgrade_status(agent_name: str, x_api_key: Optional[str] = Header(None)):
|
async def get_agent_upgrade_status(agent_name: str, x_api_key: Optional[str] = Header(None)):
|
||||||
"""Get agent upgrade status - used by agents to check if they should upgrade"""
|
"""Get agent upgrade status - used by agents to check if they should upgrade"""
|
||||||
|
# Validate agent API key — MANDATORY (GHSA-3p5c-m5m4-mjpx). Checked before any
|
||||||
|
# DB work; deployed agents always send X-API-Key. Raised before the try so the
|
||||||
|
# 401 is not swallowed; validate_agent_api_key(None) returns None without a DB hit.
|
||||||
|
from auth_middleware import validate_agent_api_key
|
||||||
|
agent_auth = await validate_agent_api_key(x_api_key)
|
||||||
|
if not agent_auth:
|
||||||
|
logger.warning(f"Missing/invalid API key for agent '{agent_name}' upgrade status")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
try:
|
try:
|
||||||
conn = await get_database_connection()
|
conn = await get_database_connection()
|
||||||
|
|
||||||
# Check if agent has upgrade pending (include platform and pool for validation)
|
# Check if agent has upgrade pending (include platform and pool for validation)
|
||||||
agent = await conn.fetchrow("""
|
agent = await conn.fetchrow("""
|
||||||
SELECT status, version as current_version, platform, pool_id
|
SELECT status, version as current_version, platform, pool_id
|
||||||
FROM agents
|
FROM agents
|
||||||
WHERE name = $1
|
WHERE name = $1
|
||||||
""", agent_name)
|
""", agent_name)
|
||||||
|
|
||||||
if not agent:
|
if not agent:
|
||||||
await close_database_connection(conn)
|
await close_database_connection(conn)
|
||||||
return {
|
return {
|
||||||
@@ -2457,26 +2503,15 @@ async def get_agent_upgrade_status(agent_name: str, x_api_key: Optional[str] = H
|
|||||||
"target_version": "",
|
"target_version": "",
|
||||||
"message": "Agent not found"
|
"message": "Agent not found"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate agent API key
|
# Log which agent's API key was used (for audit trail)
|
||||||
# API key is global - can be used for multiple agents
|
if agent_auth['name'] == agent_name:
|
||||||
if x_api_key:
|
logger.debug(f"Agent '{agent_name}' checking upgrade status using its own API key")
|
||||||
from auth_middleware import validate_agent_api_key
|
else:
|
||||||
agent_auth = await validate_agent_api_key(x_api_key)
|
logger.info(f"Agent '{agent_name}' checking upgrade status using API key from agent '{agent_auth['name']}'")
|
||||||
|
|
||||||
if not agent_auth:
|
logger.debug(f"Upgrade status check authorized for agent '{agent_name}'")
|
||||||
await close_database_connection(conn)
|
|
||||||
logger.warning(f"Invalid API key provided for agent '{agent_name}' upgrade status")
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
||||||
|
|
||||||
# Log which agent's API key was used (for audit trail)
|
|
||||||
if agent_auth['name'] == agent_name:
|
|
||||||
logger.debug(f"Agent '{agent_name}' checking upgrade status using its own API key")
|
|
||||||
else:
|
|
||||||
logger.info(f"Agent '{agent_name}' checking upgrade status using API key from agent '{agent_auth['name']}'")
|
|
||||||
|
|
||||||
logger.debug(f"Upgrade status check authorized for agent '{agent_name}'")
|
|
||||||
|
|
||||||
await close_database_connection(conn)
|
await close_database_connection(conn)
|
||||||
|
|
||||||
# Agent should upgrade if status is 'upgrading'
|
# Agent should upgrade if status is 'upgrading'
|
||||||
@@ -2910,7 +2945,17 @@ async def get_agent_script_template(platform: str, authorization: str = Header(N
|
|||||||
"""Get the latest script template for specified platform from database"""
|
"""Get the latest script template for specified platform from database"""
|
||||||
try:
|
try:
|
||||||
current_user = await get_current_user_from_token(authorization)
|
current_user = await get_current_user_from_token(authorization)
|
||||||
|
|
||||||
|
# SECURITY (GHSA-7rhv-c5pc-69r8): the raw install/upgrade script is a
|
||||||
|
# version-management surface. Gate reads with agents.version too, matching
|
||||||
|
# the write path above (operator/security_admin/super_admin retain access).
|
||||||
|
has_permission = await check_user_permission(current_user["id"], "agents", "version")
|
||||||
|
if not has_permission:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Insufficient permissions: agents.version required"
|
||||||
|
)
|
||||||
|
|
||||||
conn = await get_database_connection()
|
conn = await get_database_connection()
|
||||||
|
|
||||||
# Get latest script template for platform
|
# Get latest script template for platform
|
||||||
@@ -2961,7 +3006,19 @@ async def save_agent_script_template(platform: str, template_data: dict, authori
|
|||||||
"""Save updated script template to database using shared helper function"""
|
"""Save updated script template to database using shared helper function"""
|
||||||
try:
|
try:
|
||||||
current_user = await get_current_user_from_token(authorization)
|
current_user = await get_current_user_from_token(authorization)
|
||||||
|
|
||||||
|
# SECURITY (GHSA-7rhv-c5pc-69r8): agent script templates become the
|
||||||
|
# install/self-upgrade script executed as root on HAProxy nodes. A poisoned
|
||||||
|
# template is RCE. Authentication alone is NOT enough — require the same
|
||||||
|
# agents.version permission as POST /versions; otherwise any JWT holder
|
||||||
|
# (including viewer) could overwrite the active script.
|
||||||
|
has_permission = await check_user_permission(current_user["id"], "agents", "version")
|
||||||
|
if not has_permission:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Insufficient permissions: agents.version required"
|
||||||
|
)
|
||||||
|
|
||||||
script_content = template_data.get('script_content', '')
|
script_content = template_data.get('script_content', '')
|
||||||
version = template_data.get('version', '')
|
version = template_data.get('version', '')
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Configuration Management and Validation API
|
|||||||
Provides endpoints for HAProxy configuration validation, templates, and optimization
|
Provides endpoints for HAProxy configuration validation, templates, and optimization
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Header, Request
|
from fastapi import APIRouter, HTTPException, Header, Request, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
@@ -18,7 +18,7 @@ from utils.config_templates import (
|
|||||||
)
|
)
|
||||||
from utils.haproxy_config_parser import parse_haproxy_config
|
from utils.haproxy_config_parser import parse_haproxy_config
|
||||||
from utils.logging_config import log_with_correlation, PerformanceLogger
|
from utils.logging_config import log_with_correlation, PerformanceLogger
|
||||||
from auth_middleware import get_current_user_from_token
|
from auth_middleware import get_current_user_from_token, require_authenticated_user
|
||||||
from database.connection import get_database_connection, close_database_connection
|
from database.connection import get_database_connection, close_database_connection
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/config", tags=["Configuration Management"])
|
router = APIRouter(prefix="/api/config", tags=["Configuration Management"])
|
||||||
@@ -62,7 +62,7 @@ class ConfigOptimizationRequest(BaseModel):
|
|||||||
optimization_level: str = "balanced" # conservative, balanced, aggressive
|
optimization_level: str = "balanced" # conservative, balanced, aggressive
|
||||||
target_environment: str = "production" # development, staging, production
|
target_environment: str = "production" # development, staging, production
|
||||||
|
|
||||||
@router.post("/validate")
|
@router.post("/validate", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was optional-auth; runs HAProxy validator on caller input
|
||||||
async def validate_configuration(
|
async def validate_configuration(
|
||||||
request: ConfigValidationRequest,
|
request: ConfigValidationRequest,
|
||||||
current_user: dict = None,
|
current_user: dict = None,
|
||||||
@@ -203,7 +203,7 @@ async def get_template_details(template_id: str):
|
|||||||
)
|
)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to get template: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to get template: {str(e)}")
|
||||||
|
|
||||||
@router.post("/templates/{template_id}/generate")
|
@router.post("/templates/{template_id}/generate", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was optional-auth
|
||||||
async def generate_configuration(
|
async def generate_configuration(
|
||||||
template_id: str,
|
template_id: str,
|
||||||
request: TemplateGenerationRequest,
|
request: TemplateGenerationRequest,
|
||||||
@@ -271,7 +271,7 @@ async def generate_configuration(
|
|||||||
detail=f"Configuration generation failed: {str(e)}"
|
detail=f"Configuration generation failed: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/optimize")
|
@router.post("/optimize", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was optional-auth
|
||||||
async def optimize_configuration(
|
async def optimize_configuration(
|
||||||
request: ConfigOptimizationRequest,
|
request: ConfigOptimizationRequest,
|
||||||
current_user: dict = None,
|
current_user: dict = None,
|
||||||
|
|||||||
@@ -201,13 +201,15 @@ async def get_pending_config_requests(agent_name: str, x_api_key: Optional[str]
|
|||||||
Called during heartbeat.
|
Called during heartbeat.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Validate agent API key
|
# Validate agent API key — MANDATORY (GHSA-3p5c-m5m4-mjpx). Deployed agents
|
||||||
|
# always send X-API-Key; an absent/invalid key is unauthenticated. This
|
||||||
|
# endpoint also mutates state (marks requests 'processing'), so a keyless
|
||||||
|
# caller could otherwise starve the real agent.
|
||||||
agent_auth = await validate_agent_api_key(x_api_key)
|
agent_auth = await validate_agent_api_key(x_api_key)
|
||||||
|
if not agent_auth:
|
||||||
if x_api_key and not agent_auth:
|
logger.warning(f"Missing/invalid API key from '{agent_name}' for pending requests")
|
||||||
logger.warning(f"Invalid API key provided by agent '{agent_name}' for pending requests")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
||||||
|
|
||||||
conn = await get_database_connection()
|
conn = await get_database_connection()
|
||||||
|
|
||||||
# Get pending requests
|
# Get pending requests
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Header
|
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||||
|
from auth_middleware import require_authenticated_user
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
@@ -11,7 +12,7 @@ from agent_notifications import get_cluster_agents_status
|
|||||||
router = APIRouter(prefix="/api", tags=["dashboard", "pools"])
|
router = APIRouter(prefix="/api", tags=["dashboard", "pools"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@router.get("/dashboard/overview")
|
@router.get("/dashboard/overview", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): leaked cluster/pool/agent stats, names, health & alerts anonymously (auth was optional)
|
||||||
async def get_dashboard_overview(cluster_id: Optional[int] = None, authorization: str = Header(None)):
|
async def get_dashboard_overview(cluster_id: Optional[int] = None, authorization: str = Header(None)):
|
||||||
"""Get dashboard overview with comprehensive statistics, optionally filtered by cluster"""
|
"""Get dashboard overview with comprehensive statistics, optionally filtered by cluster"""
|
||||||
try:
|
try:
|
||||||
@@ -238,7 +239,7 @@ async def get_dashboard_overview(cluster_id: Optional[int] = None, authorization
|
|||||||
logger.error(f"Error fetching dashboard overview: {e}")
|
logger.error(f"Error fetching dashboard overview: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get("/dashboard/stats")
|
@router.get("/dashboard/stats", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): aggregate cluster/agent counts
|
||||||
async def get_dashboard_stats():
|
async def get_dashboard_stats():
|
||||||
"""Get dashboard statistics"""
|
"""Get dashboard statistics"""
|
||||||
try:
|
try:
|
||||||
@@ -279,7 +280,7 @@ async def get_dashboard_stats():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get("/pools")
|
@router.get("/pools", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): pool names/env/counts
|
||||||
async def get_pools():
|
async def get_pools():
|
||||||
"""Get all HAProxy cluster pools"""
|
"""Get all HAProxy cluster pools"""
|
||||||
try:
|
try:
|
||||||
@@ -350,7 +351,7 @@ async def get_pools():
|
|||||||
logger.error(f"Error fetching pools: {e}")
|
logger.error(f"Error fetching pools: {e}")
|
||||||
return {"pools": []}
|
return {"pools": []}
|
||||||
|
|
||||||
@router.get("/haproxy-cluster-pools")
|
@router.get("/haproxy-cluster-pools", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c)
|
||||||
async def get_haproxy_cluster_pools():
|
async def get_haproxy_cluster_pools():
|
||||||
"""Get all HAProxy cluster pools (legacy endpoint)"""
|
"""Get all HAProxy cluster pools (legacy endpoint)"""
|
||||||
# Just call the main pools endpoint
|
# Just call the main pools endpoint
|
||||||
@@ -474,7 +475,7 @@ async def update_pool(pool_id: int, pool: PoolUpdate, authorization: str = Heade
|
|||||||
logger.error(f"Failed to update pool: {e}")
|
logger.error(f"Failed to update pool: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update pool: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update pool: {str(e)}")
|
||||||
|
|
||||||
@router.get("/haproxy-cluster-pools/{pool_id}/agents")
|
@router.get("/haproxy-cluster-pools/{pool_id}/agents", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): full agent inventory — same class as GET /api/agents
|
||||||
async def get_pool_agents(pool_id: int):
|
async def get_pool_agents(pool_id: int):
|
||||||
"""Get all agents for a specific pool"""
|
"""Get all agents for a specific pool"""
|
||||||
try:
|
try:
|
||||||
@@ -561,7 +562,7 @@ async def get_pool_agents(pool_id: int):
|
|||||||
logger.error(f"Error fetching pool agents: {e}")
|
logger.error(f"Error fetching pool agents: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to fetch pool agents: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to fetch pool agents: {str(e)}")
|
||||||
|
|
||||||
@router.get("/haproxy/stats")
|
@router.get("/haproxy/stats", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c)
|
||||||
async def get_haproxy_stats(cluster_id: Optional[int] = None):
|
async def get_haproxy_stats(cluster_id: Optional[int] = None):
|
||||||
"""Get HAProxy statistics"""
|
"""Get HAProxy statistics"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ Dashboard Stats Router
|
|||||||
API endpoints for HAProxy statistics dashboard
|
API endpoints for HAProxy statistics dashboard
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from services.dashboard_stats_service import dashboard_stats_service
|
from services.dashboard_stats_service import dashboard_stats_service
|
||||||
|
from auth_middleware import require_authenticated_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dashboard-stats", tags=["dashboard-stats"])
|
# SECURITY (GHSA-3p5c-m5m4-mjpx): this entire router (traffic metrics, backend
|
||||||
|
# health, cluster topology, agent status) was mounted without authentication.
|
||||||
|
# Require a valid JWT on every route. The frontend Dashboard already sends the
|
||||||
|
# operator JWT on these calls, so this is transparent to the UI.
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/dashboard-stats",
|
||||||
|
tags=["dashboard-stats"],
|
||||||
|
dependencies=[Depends(require_authenticated_user)],
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ Production-Ready Health Check and Monitoring Endpoints
|
|||||||
Provides comprehensive system health monitoring for Kubernetes and production environments
|
Provides comprehensive system health monitoring for Kubernetes and production environments
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from auth_middleware import require_authenticated_user
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
@@ -74,7 +75,7 @@ async def readiness_probe():
|
|||||||
logger.error(f"Readiness probe failed: {e}")
|
logger.error(f"Readiness probe failed: {e}")
|
||||||
raise HTTPException(status_code=503, detail=f"Not ready: {str(e)}")
|
raise HTTPException(status_code=503, detail=f"Not ready: {str(e)}")
|
||||||
|
|
||||||
@router.get("/deep")
|
@router.get("/deep", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): leaks host CPU/mem/disk, DB/redis/python versions, PID
|
||||||
async def deep_health_check():
|
async def deep_health_check():
|
||||||
"""Comprehensive health check with detailed system information"""
|
"""Comprehensive health check with detailed system information"""
|
||||||
global _health_cache
|
global _health_cache
|
||||||
@@ -197,7 +198,7 @@ async def deep_health_check():
|
|||||||
|
|
||||||
raise HTTPException(status_code=503, detail=error_response)
|
raise HTTPException(status_code=503, detail=error_response)
|
||||||
|
|
||||||
@router.get("/agents")
|
@router.get("/agents", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): leaks agent names/hostnames
|
||||||
async def agents_health():
|
async def agents_health():
|
||||||
"""Monitor agent connectivity and health status"""
|
"""Monitor agent connectivity and health status"""
|
||||||
try:
|
try:
|
||||||
@@ -261,7 +262,7 @@ async def agents_health():
|
|||||||
logger.error(f"Agent health check failed: {e}")
|
logger.error(f"Agent health check failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Agent health check failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Agent health check failed: {str(e)}")
|
||||||
|
|
||||||
@router.get("/clusters")
|
@router.get("/clusters", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): leaks cluster names, HAProxy versions, pending counts
|
||||||
async def clusters_health():
|
async def clusters_health():
|
||||||
"""Monitor HAProxy cluster health and configuration status"""
|
"""Monitor HAProxy cluster health and configuration status"""
|
||||||
try:
|
try:
|
||||||
@@ -329,7 +330,7 @@ async def clusters_health():
|
|||||||
logger.error(f"Cluster health check failed: {e}")
|
logger.error(f"Cluster health check failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Cluster health check failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Cluster health check failed: {str(e)}")
|
||||||
|
|
||||||
@router.get("/errors")
|
@router.get("/errors", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): app error metrics, sibling of /deep,/agents,/clusters
|
||||||
async def error_statistics():
|
async def error_statistics():
|
||||||
"""Get application error statistics and metrics"""
|
"""Get application error statistics and metrics"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -87,6 +87,32 @@ class AccountCreate(BaseModel):
|
|||||||
raise ValueError("eab_hmac_key is not valid base64; copy it exactly from your CA account.")
|
raise ValueError("eab_hmac_key is not valid base64; copy it exactly from your CA account.")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('directory_url')
|
||||||
|
@classmethod
|
||||||
|
def _validate_directory_url(cls, v):
|
||||||
|
# SECURITY (GHSA-3vh4-gvxx-wm2p): reject non-https URLs and literal
|
||||||
|
# non-public IP hosts at the API boundary. The full DNS-based SSRF check
|
||||||
|
# runs at fetch time (acme_service.get_directory -> ssrf_guard).
|
||||||
|
if not v:
|
||||||
|
return v
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import ipaddress
|
||||||
|
from utils.ssrf_guard import is_public_ip
|
||||||
|
parsed = urlparse(v.strip())
|
||||||
|
if parsed.scheme.lower() != 'https':
|
||||||
|
raise ValueError("directory_url must be an https URL")
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
raise ValueError("directory_url has no host")
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
is_ip_literal = True
|
||||||
|
except ValueError:
|
||||||
|
is_ip_literal = False
|
||||||
|
if is_ip_literal and not is_public_ip(host):
|
||||||
|
raise ValueError("directory_url must not point to a private/loopback IP address")
|
||||||
|
return v
|
||||||
|
|
||||||
@model_validator(mode='after')
|
@model_validator(mode='after')
|
||||||
def _require_provider_for_dns01(self):
|
def _require_provider_for_dns01(self):
|
||||||
if self.challenge_type == 'dns-01' and not (self.dns_provider or '').strip():
|
if self.challenge_type == 'dns-01' and not (self.dns_provider or '').strip():
|
||||||
|
|||||||
@@ -104,16 +104,40 @@ async def test_acme_connection(authorization: str = Header(None), directory_url:
|
|||||||
finally:
|
finally:
|
||||||
await close_database_connection(conn)
|
await close_database_connection(conn)
|
||||||
|
|
||||||
|
# SECURITY (GHSA-3vh4-gvxx-wm2p): validate the URL before any outbound request
|
||||||
|
# (https-only; block loopback/RFC1918/link-local/cloud-metadata after DNS),
|
||||||
|
# pin the connector to IPv4, and never follow redirects. Also do NOT reflect
|
||||||
|
# arbitrary upstream JSON keys back to the caller — that was an information-
|
||||||
|
# disclosure oracle. Only report presence of the FIXED, known ACME directory
|
||||||
|
# field names (never attacker-controlled data).
|
||||||
|
from utils.ssrf_guard import assert_public_url, safe_connector, SSRFValidationError
|
||||||
|
|
||||||
|
directory_url = str(directory_url)
|
||||||
|
try:
|
||||||
|
await assert_public_url(directory_url)
|
||||||
|
except SSRFValidationError as e:
|
||||||
|
return {"success": False, "error": f"Refused to fetch directory URL: {e}"}
|
||||||
|
|
||||||
|
_KNOWN_ACME_FIELDS = ["newNonce", "newAccount", "newOrder", "newAuthz", "revokeCert", "keyChange"]
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
||||||
async with session.get(str(directory_url), timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
async with session.get(
|
||||||
|
directory_url,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=10),
|
||||||
|
allow_redirects=False,
|
||||||
|
) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.json()
|
data = await resp.json(content_type=None)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"success": False, "error": "Directory URL did not return a JSON object"}
|
||||||
|
present = [k for k in _KNOWN_ACME_FIELDS if k in data]
|
||||||
|
if not present:
|
||||||
|
return {"success": False, "error": "Response is not a valid ACME directory"}
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"directory": str(directory_url),
|
"directory": directory_url,
|
||||||
"endpoints": list(data.keys()) if isinstance(data, dict) else []
|
"endpoints": present,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {"success": False, "error": f"HTTP {resp.status} from directory URL"}
|
return {"success": False, "error": f"HTTP {resp.status} from directory URL"}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
# Import database and models
|
# Import database and models
|
||||||
from database.connection import get_database_connection, close_database_connection
|
from database.connection import get_database_connection, close_database_connection
|
||||||
from auth_middleware import get_current_user_from_token
|
from auth_middleware import get_current_user_from_token, require_authenticated_user
|
||||||
from models.ssl import SSLCertificate, SSLCertificateCreate, SSLCertificateUpdate, SSLCertificateResponse
|
from models.ssl import SSLCertificate, SSLCertificateCreate, SSLCertificateUpdate, SSLCertificateResponse
|
||||||
from utils.ssl_parser import parse_ssl_certificate, validate_private_key, validate_certificate_chain, format_certificate_info
|
from utils.ssl_parser import parse_ssl_certificate, validate_private_key, validate_certificate_chain, format_certificate_info
|
||||||
from utils.activity_log import log_user_activity
|
from utils.activity_log import log_user_activity
|
||||||
@@ -825,7 +825,8 @@ async def get_ssl_certificate(cert_id: int, authorization: str = Header(None)):
|
|||||||
logger.error(f"Error getting SSL certificate: {e}")
|
logger.error(f"Error getting SSL certificate: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get("/certificates/{cert_id}/config-versions")
|
@router.get("/certificates/{cert_id}/config-versions",
|
||||||
|
dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was unauthenticated
|
||||||
async def get_ssl_certificate_config_versions(cert_id: int):
|
async def get_ssl_certificate_config_versions(cert_id: int):
|
||||||
"""Get config version history for specific SSL certificate"""
|
"""Get config version history for specific SSL certificate"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Request, Header
|
from fastapi import APIRouter, HTTPException, Request, Header, Depends
|
||||||
|
from auth_middleware import require_authenticated_user
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -182,7 +183,8 @@ async def get_waf_stats(cluster_id: Optional[int] = None, authorization: str = H
|
|||||||
logger.error(f"Error fetching WAF stats: {e}")
|
logger.error(f"Error fetching WAF stats: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get("/rules", summary="Get WAF Rules", response_description="List of WAF rules")
|
@router.get("/rules", summary="Get WAF Rules", response_description="List of WAF rules",
|
||||||
|
dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): WAF rule definitions aid bypass crafting
|
||||||
async def get_waf_rules(cluster_id: Optional[int] = None):
|
async def get_waf_rules(cluster_id: Optional[int] = None):
|
||||||
"""
|
"""
|
||||||
# Get WAF Rules
|
# Get WAF Rules
|
||||||
|
|||||||
@@ -69,8 +69,14 @@ class ACMEService:
|
|||||||
if cached.get('_fetched_at', 0) > time.time() - 3600:
|
if cached.get('_fetched_at', 0) > time.time() - 3600:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
# SECURITY (GHSA-3vh4-gvxx-wm2p): directory_url can come from a stored
|
||||||
async with session.get(directory_url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
# account row; validate it (https + public IP, no redirects) before the
|
||||||
|
# server-side fetch so it cannot be pointed at internal/metadata targets.
|
||||||
|
from utils.ssrf_guard import assert_public_url, safe_connector
|
||||||
|
await assert_public_url(directory_url)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
||||||
|
async with session.get(directory_url, timeout=aiohttp.ClientTimeout(total=15), allow_redirects=False) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}")
|
raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}")
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
@@ -90,8 +96,16 @@ class ACMEService:
|
|||||||
cached = self._nonce_by_dir.pop(directory_url, None)
|
cached = self._nonce_by_dir.pop(directory_url, None)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
async with aiohttp.ClientSession() as session:
|
# SECURITY (GHSA-3vh4-gvxx-wm2p): newNonce is taken from the (attacker-
|
||||||
async with session.head(directory['newNonce']) as resp:
|
# influenceable) directory JSON and is fetched here BEFORE the guarded
|
||||||
|
# _signed_request POST, so it must be guarded too — otherwise a directory
|
||||||
|
# that returns an internal newNonce (and omits Replay-Nonce) is a live SSRF.
|
||||||
|
# https + public IP only, IPv4-pinned connector, no redirects, bounded timeout.
|
||||||
|
from utils.ssrf_guard import assert_public_url, safe_connector
|
||||||
|
nonce_url = directory['newNonce']
|
||||||
|
await assert_public_url(nonce_url)
|
||||||
|
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
||||||
|
async with session.head(nonce_url, timeout=aiohttp.ClientTimeout(total=15), allow_redirects=False) as resp:
|
||||||
return resp.headers['Replay-Nonce']
|
return resp.headers['Replay-Nonce']
|
||||||
|
|
||||||
def _generate_account_key(self) -> Tuple[str, dict]:
|
def _generate_account_key(self) -> Tuple[str, dict]:
|
||||||
@@ -187,13 +201,21 @@ class ACMEService:
|
|||||||
|
|
||||||
body = self._sign_jws(private_key, protected, payload)
|
body = self._sign_jws(private_key, protected, payload)
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
# SECURITY (GHSA-3vh4-gvxx-wm2p): `url` is taken from the CA directory /
|
||||||
|
# order responses. The directory is already fetched from a validated
|
||||||
|
# public CA, but guard the follow-up POST target too (defence in depth)
|
||||||
|
# so a tampered/malicious directory cannot steer the request internally.
|
||||||
|
from utils.ssrf_guard import assert_public_url, safe_connector
|
||||||
|
await assert_public_url(url)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
async with session.post(
|
async with session.post(
|
||||||
url,
|
url,
|
||||||
json=body,
|
json=body,
|
||||||
headers={"Content-Type": "application/jose+json"},
|
headers={"Content-Type": "application/jose+json"},
|
||||||
timeout=aiohttp.ClientTimeout(total=30),
|
timeout=aiohttp.ClientTimeout(total=30),
|
||||||
|
allow_redirects=False,
|
||||||
) as resp:
|
) as resp:
|
||||||
if 'Replay-Nonce' in resp.headers:
|
if 'Replay-Nonce' in resp.headers:
|
||||||
self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
|
self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Regression tests for the 2026-07 security advisories.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- GHSA-7rhv-c5pc-69r8 (CRITICAL RCE): agent script-template management must
|
||||||
|
require the agents.version permission, not merely authentication.
|
||||||
|
- GHSA-3p5c-m5m4-mjpx (missing auth): agent data-plane endpoints must require a
|
||||||
|
valid X-API-Key, and operator/UI endpoints must require a JWT. An anonymous
|
||||||
|
caller must never get a 200 with sensitive data.
|
||||||
|
|
||||||
|
These are behavioral assertions via FastAPI's TestClient. The auth checks were
|
||||||
|
deliberately moved ahead of any DB access, so an unauthenticated request is
|
||||||
|
rejected without needing a database — the same approach as the existing
|
||||||
|
test_ssl_list_endpoint_auth.py. Accepted rejection statuses are 401/403/422
|
||||||
|
(never 200-with-data).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REJECT = (401, 403, 422)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# GHSA-3p5c: agent data-plane endpoints must reject a missing X-API-Key
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_agent_config_requires_api_key(client):
|
||||||
|
"""GET /api/agents/{name}/config leaked the full haproxy.cfg without a key."""
|
||||||
|
res = client.get("/api/agents/prod-haproxy-1/config")
|
||||||
|
assert res.status_code in REJECT, (
|
||||||
|
f"GHSA-3p5c regression: agent config served without X-API-Key ({res.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_ssl_certificates_requires_api_key(client):
|
||||||
|
"""GET /api/agents/{name}/ssl-certificates leaked SSL private keys without a key."""
|
||||||
|
res = client.get("/api/agents/prod-haproxy-1/ssl-certificates")
|
||||||
|
assert res.status_code in REJECT, (
|
||||||
|
f"GHSA-3p5c regression: SSL certs (private keys!) served without X-API-Key ({res.status_code})"
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
assert "private_key_content" not in res.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_upgrade_status_requires_api_key(client):
|
||||||
|
res = client.get("/api/agents/prod-haproxy-1/upgrade-status")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_pending_requests_requires_api_key(client):
|
||||||
|
res = client.get("/api/configuration/agents/prod-haproxy-1/pending-requests")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_heartbeat_by_name_requires_api_key(client):
|
||||||
|
res = client.post("/api/agents/heartbeat", json={"name": "rogue-poc"})
|
||||||
|
assert res.status_code in REJECT, (
|
||||||
|
f"GHSA-3p5c regression: keyless heartbeat/auto-register accepted ({res.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_heartbeat_by_id_requires_api_key(client):
|
||||||
|
res = client.post("/api/agents/1/heartbeat", json={"name": "spoofed"})
|
||||||
|
assert res.status_code in REJECT, (
|
||||||
|
f"GHSA-3p5c regression: keyless by-id heartbeat state-spoof accepted ({res.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# GHSA-3p5c: operator/UI endpoints must reject a missing JWT
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_agents_inventory_requires_jwt(client):
|
||||||
|
"""GET /api/agents (RCE read-back channel) was served without a JWT."""
|
||||||
|
res = client.get("/api/agents")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", ["/api/health/deep", "/api/health/agents", "/api/health/clusters"])
|
||||||
|
def test_detailed_health_requires_jwt(client, path):
|
||||||
|
res = client.get(path)
|
||||||
|
assert res.status_code in REJECT, f"{path} served without a JWT ({res.status_code})"
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_health_stays_public(client):
|
||||||
|
"""The liveness probe endpoint (/api/health) must remain UNAUTHENTICATED.
|
||||||
|
|
||||||
|
It reports 200 when healthy and 503 when the DB is unreachable (as in this
|
||||||
|
no-DB test env); what matters for the k8s probe is that it is never gated
|
||||||
|
behind auth (401/403). We only added auth to /api/health/{deep,agents,clusters}.
|
||||||
|
"""
|
||||||
|
res = client.get("/api/health")
|
||||||
|
assert res.status_code not in (401, 403), (
|
||||||
|
f"Regression: /api/health liveness probe now requires auth ({res.status_code}) — "
|
||||||
|
f"this breaks k8s liveness/readiness"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_stats_requires_jwt(client):
|
||||||
|
res = client.get("/api/dashboard-stats/stats?cluster_id=1")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssl_config_versions_requires_jwt(client):
|
||||||
|
res = client.get("/api/ssl/certificates/1/config-versions")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# GHSA-7rhv (CRITICAL RCE): script-template management
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_script_template_write_requires_auth(client):
|
||||||
|
"""Anonymous POST must be rejected outright."""
|
||||||
|
res = client.post("/api/agents/script-templates/linux",
|
||||||
|
json={"script_content": "#!/bin/bash\nid", "version": "9.9.9"})
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_template_read_requires_auth(client):
|
||||||
|
res = client.get("/api/agents/script-templates/linux")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# GHSA-3p5c (round 2): sibling endpoints exposing the SAME class of data
|
||||||
|
# (found during post-merge review — must also require a JWT)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", [
|
||||||
|
"/api/haproxy-cluster-pools/1/agents", # full agent inventory — same class as GET /api/agents
|
||||||
|
"/api/pools",
|
||||||
|
"/api/haproxy-cluster-pools",
|
||||||
|
"/api/dashboard/stats",
|
||||||
|
"/api/dashboard/overview", # optional-auth pattern — leaked stats/names/alerts anonymously
|
||||||
|
"/api/haproxy/stats",
|
||||||
|
"/api/waf/rules",
|
||||||
|
"/api/health/errors",
|
||||||
|
])
|
||||||
|
def test_sibling_inventory_endpoints_require_jwt(client, path):
|
||||||
|
res = client.get(path)
|
||||||
|
assert res.status_code in REJECT, (
|
||||||
|
f"GHSA-3p5c (round 2) regression: {path} served without a JWT ({res.status_code}) — "
|
||||||
|
f"anonymous access to inventory/topology/WAF/error data"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pool_agents_no_anonymous_inventory_leak(client):
|
||||||
|
"""The richest bypass: /api/haproxy-cluster-pools/{id}/agents must not leak inventory."""
|
||||||
|
res = client.get("/api/haproxy-cluster-pools/1/agents")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
if res.status_code == 200:
|
||||||
|
assert "ip_address" not in res.text and "hostname" not in res.text
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# GHSA-3p5c (round 2): agent webhooks must return 401 (not a 200 error body)
|
||||||
|
# for anonymous callers — the auth raise must propagate, not be swallowed.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", [
|
||||||
|
"/api/agents/some-agent/config-applied",
|
||||||
|
"/api/agents/some-agent/config-validation-failed",
|
||||||
|
"/api/agents/some-agent/config-sync",
|
||||||
|
])
|
||||||
|
def test_agent_webhooks_reject_anonymous_with_401(client, path):
|
||||||
|
res = client.post(path, json={})
|
||||||
|
assert res.status_code in REJECT, (
|
||||||
|
f"{path} returned {res.status_code} for an anonymous caller — the auth "
|
||||||
|
f"rejection must be a 401/403, not a swallowed 200 error body"
|
||||||
|
)
|
||||||
|
# Specifically must NOT be a 200 "status: error" body.
|
||||||
|
assert res.status_code != 200
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# GHSA-3p5c (round 2): GET /api/agents must accept EITHER a JWT OR an agent
|
||||||
|
# X-API-Key. Anonymous (neither) is still rejected — agents send a key, so a
|
||||||
|
# JWT-only gate would break them (verified end-to-end in the localtest smoke).
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_agents_inventory_still_rejects_fully_anonymous(client):
|
||||||
|
"""No JWT and no X-API-Key -> 401 (the agent-key accept path needs a valid key)."""
|
||||||
|
res = client.get("/api/agents")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_uninstall_script_requires_auth(client):
|
||||||
|
"""Agent-management endpoint must not be anonymously reachable (JWT or agent key)."""
|
||||||
|
res = client.get("/api/agents/generate-uninstall-script/linux")
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", [
|
||||||
|
"/api/config/validate",
|
||||||
|
"/api/config/optimize",
|
||||||
|
"/api/config/templates/default/generate",
|
||||||
|
])
|
||||||
|
def test_config_compute_endpoints_require_auth(client, path):
|
||||||
|
"""Config compute endpoints (run a HAProxy validator on caller input) were
|
||||||
|
optional-auth; now require a JWT. The dependency rejects before body parsing."""
|
||||||
|
res = client.post(path, json={})
|
||||||
|
assert res.status_code in REJECT
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_template_write_enforces_agents_version_permission():
|
||||||
|
"""Static guarantee: the write handler checks agents.version (not just authN).
|
||||||
|
|
||||||
|
A behavioral 403-for-viewer test would need a seeded DB + a minted viewer JWT;
|
||||||
|
instead we assert the permission gate is present in source, mirroring the
|
||||||
|
existing audit-style source tests. This is the core RCE fix (GHSA-7rhv).
|
||||||
|
"""
|
||||||
|
src_path = os.path.join(os.path.dirname(__file__), "..", "routers", "agent.py")
|
||||||
|
with open(src_path, "r") as f:
|
||||||
|
src = f.read()
|
||||||
|
# Isolate the save_agent_script_template handler body.
|
||||||
|
m = re.search(r"async def save_agent_script_template\(.*?\n(.*?)\n@router\.", src, re.DOTALL)
|
||||||
|
assert m, "save_agent_script_template handler not found"
|
||||||
|
body = m.group(1)
|
||||||
|
assert 'check_user_permission' in body and '"agents", "version"' in body, (
|
||||||
|
"GHSA-7rhv regression: script-template WRITE no longer enforces the "
|
||||||
|
"agents.version permission — any JWT holder could poison the root install script"
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Unit tests for the SSRF guard (GHSA-3vh4-gvxx-wm2p).
|
||||||
|
|
||||||
|
The guard protects server-side fetches of ACME `directory_url` values. This
|
||||||
|
project uses only public ACME CAs, so every non-public IP must be rejected.
|
||||||
|
Tests avoid real network/DNS by using IP literals and scheme checks.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.ssrf_guard import is_public_ip, assert_public_url, SSRFValidationError
|
||||||
|
|
||||||
|
|
||||||
|
# ---- is_public_ip -----------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ip", [
|
||||||
|
"8.8.8.8", "1.1.1.1", "93.184.216.34", # public
|
||||||
|
])
|
||||||
|
def test_public_ips_allowed(ip):
|
||||||
|
assert is_public_ip(ip) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ip", [
|
||||||
|
"127.0.0.1", # loopback
|
||||||
|
"10.0.0.5", # RFC1918
|
||||||
|
"172.19.0.1", # RFC1918 (the SSRF PoC docker gateway)
|
||||||
|
"192.168.1.1", # RFC1918
|
||||||
|
"169.254.169.254", # link-local / cloud metadata
|
||||||
|
"0.0.0.0", # unspecified
|
||||||
|
"::1", # IPv6 loopback
|
||||||
|
"fe80::1", # IPv6 link-local
|
||||||
|
"::ffff:127.0.0.1", # IPv4-mapped IPv6 loopback (R18c bypass)
|
||||||
|
"::ffff:169.254.169.254", # IPv4-mapped metadata
|
||||||
|
"not-an-ip", # garbage
|
||||||
|
])
|
||||||
|
def test_non_public_ips_rejected(ip):
|
||||||
|
assert is_public_ip(ip) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- assert_public_url ------------------------------------------------------
|
||||||
|
|
||||||
|
def _raises(url):
|
||||||
|
with pytest.raises(SSRFValidationError):
|
||||||
|
asyncio.run(assert_public_url(url))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_non_https_scheme():
|
||||||
|
# The SSRF PoC used http:// against an internal listener.
|
||||||
|
_raises("http://172.19.0.1:2121/internal-secret")
|
||||||
|
_raises("http://8.8.8.8/") # even a public IP over http is refused
|
||||||
|
_raises("file:///etc/passwd")
|
||||||
|
_raises("gopher://8.8.8.8/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_private_ip_literals():
|
||||||
|
_raises("https://127.0.0.1/")
|
||||||
|
_raises("https://10.0.0.5/")
|
||||||
|
_raises("https://169.254.169.254/latest/meta-data/")
|
||||||
|
_raises("https://[::1]/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_empty_or_hostless():
|
||||||
|
_raises("")
|
||||||
|
_raises("https://")
|
||||||
|
|
||||||
|
|
||||||
|
def test_allows_public_ip_literal_https():
|
||||||
|
# A public IP literal over https must pass (no DNS needed).
|
||||||
|
asyncio.run(assert_public_url("https://8.8.8.8/directory"))
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
SSRF guard for outbound HTTP fetches to user/DB-controlled URLs.
|
||||||
|
|
||||||
|
GHSA-3vh4-gvxx-wm2p: the ACME `directory_url` was fetched server-side with no
|
||||||
|
validation, turning the backend into a request-forwarding primitive against
|
||||||
|
loopback / RFC1918 / link-local / cloud-metadata IP space (and reflecting the
|
||||||
|
upstream JSON keys back to the caller).
|
||||||
|
|
||||||
|
The classification logic mirrors the hardened ACME diagnostics probe
|
||||||
|
(services/acme_diagnostics.py, R18b/R18c audits): unwrap IPv4-mapped IPv6, reject
|
||||||
|
loopback/link-local/private/multicast/reserved/unspecified, resolve DNS off the
|
||||||
|
event loop, and pin the aiohttp connector to IPv4 so the family the guard
|
||||||
|
classifies equals the family the connector dials (no dual-stack AAAA bypass).
|
||||||
|
|
||||||
|
Deployment note: this project uses ONLY public ACME CAs (e.g. Let's Encrypt), so
|
||||||
|
every non-public IP is rejected — there is no internal/private-IP CA to allow.
|
||||||
|
Residual: DNS rebinding between validate-time and fetch-time is not fully closed
|
||||||
|
(fetching by hostname keeps TLS cert validation working); the IPv4 pin +
|
||||||
|
https-only + admin-gating + internal-only exposure keep this residual low.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from typing import List
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
# Only https is legitimate for a public ACME directory URL.
|
||||||
|
_ALLOWED_SCHEMES = {"https"}
|
||||||
|
|
||||||
|
|
||||||
|
class SSRFValidationError(ValueError):
|
||||||
|
"""Raised when a URL fails SSRF validation (bad scheme or non-public host)."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_ip(ip_str: str) -> bool:
|
||||||
|
"""Return True only for globally-routable IPv4/IPv6 addresses.
|
||||||
|
|
||||||
|
Unwraps IPv4-mapped IPv6 (``::ffff:127.0.0.1``) before classification so an
|
||||||
|
attacker-controlled AAAA record cannot smuggle loopback/metadata through the
|
||||||
|
IPv6 checks.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||||
|
ip = ip.ipv4_mapped
|
||||||
|
if ip.is_loopback or ip.is_link_local or ip.is_private:
|
||||||
|
return False
|
||||||
|
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_ips(host: str, *, timeout: float = 5.0) -> List[str]:
|
||||||
|
"""Resolve `host` to IPv4 addresses without blocking the event loop."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
_, _, ips = await asyncio.wait_for(
|
||||||
|
loop.run_in_executor(None, socket.gethostbyname_ex, host),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return ips or []
|
||||||
|
|
||||||
|
|
||||||
|
async def assert_public_url(url: str, *, timeout: float = 5.0) -> None:
|
||||||
|
"""Validate that `url` is safe to fetch server-side.
|
||||||
|
|
||||||
|
Requirements: https scheme, and a host that either is a public IP literal or
|
||||||
|
resolves entirely to public IPv4 addresses. Raises ``SSRFValidationError``
|
||||||
|
otherwise. Intended to be called immediately before the outbound request,
|
||||||
|
which MUST use ``safe_connector()`` and ``allow_redirects=False``.
|
||||||
|
"""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
raise SSRFValidationError("A URL is required")
|
||||||
|
parsed = urlparse(url.strip())
|
||||||
|
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
|
||||||
|
raise SSRFValidationError(f"URL scheme must be https (got '{parsed.scheme or 'none'}')")
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
raise SSRFValidationError("URL has no host")
|
||||||
|
|
||||||
|
# Literal IP host: classify directly, no DNS needed.
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
if not is_public_ip(host):
|
||||||
|
raise SSRFValidationError(f"URL host {host} is not a public IP address")
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
pass # hostname, not an IP literal -> resolve below
|
||||||
|
|
||||||
|
try:
|
||||||
|
ips = await _resolve_ips(host, timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise SSRFValidationError(f"DNS resolution timed out for {host}")
|
||||||
|
except Exception as e: # socket.gaierror etc.
|
||||||
|
raise SSRFValidationError(f"DNS resolution failed for {host}: {e}")
|
||||||
|
|
||||||
|
if not ips:
|
||||||
|
raise SSRFValidationError(f"{host} did not resolve to any address")
|
||||||
|
if not all(is_public_ip(ip) for ip in ips):
|
||||||
|
raise SSRFValidationError(
|
||||||
|
f"{host} resolves to a non-public IP {ips} — refusing to fetch (SSRF guard)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_connector() -> aiohttp.TCPConnector:
|
||||||
|
"""IPv4-pinned aiohttp connector.
|
||||||
|
|
||||||
|
Forces the connect family to match what :func:`assert_public_url` classified
|
||||||
|
(closes the dual-stack AAAA bypass). TLS verification stays ON (default), so
|
||||||
|
the request must target the validated hostname. Always combine with
|
||||||
|
``allow_redirects=False`` at the request call site.
|
||||||
|
"""
|
||||||
|
return aiohttp.TCPConnector(family=socket.AF_INET)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.9",
|
"version": "1.8.10",
|
||||||
"releaseName": "ACL -f pattern-file support (Issue #38 follow-up)",
|
"releaseName": "Security hardening — RCE, missing-auth and SSRF advisories (GHSA-7rhv/3p5c/3vh4)",
|
||||||
"releaseDate": "2026-07-13"
|
"releaseDate": "2026-07-20"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ services:
|
|||||||
|
|
||||||
# Redis Cache
|
# Redis Cache
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:8.8.0-alpine
|
||||||
container_name: haproxy-openmanager-redis
|
container_name: haproxy-openmanager-redis
|
||||||
command: redis-server --maxmemory 2gb --maxmemory-policy volatile-lru --save ""
|
command: redis-server --maxmemory 2gb --maxmemory-policy volatile-lru --save ""
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Generated
+181
-145
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "haproxy-openmanager-frontend",
|
"name": "haproxy-openmanager-frontend",
|
||||||
"version": "1.7.8",
|
"version": "1.8.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "haproxy-openmanager-frontend",
|
"name": "haproxy-openmanager-frontend",
|
||||||
"version": "1.7.8",
|
"version": "1.8.10",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.0.0",
|
"@ant-design/icons": "^5.0.0",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-ace": "^10.1.0",
|
"react-ace": "^10.1.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.8.0",
|
"react-router-dom": "^6.30.4",
|
||||||
"react-window": "^1.8.10",
|
"react-window": "^1.8.10",
|
||||||
"react18-json-view": "^0.2.9",
|
"react18-json-view": "^0.2.9",
|
||||||
"recharts": "^2.5.0"
|
"recharts": "^2.5.0"
|
||||||
@@ -179,18 +179,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.6",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.28.6",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.28.6",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.29.2",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.3",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.28.6",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.0",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.0",
|
||||||
@@ -239,14 +239,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -472,9 +472,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -482,9 +482,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -531,13 +531,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -2274,14 +2274,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -2669,10 +2669,20 @@
|
|||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -2756,6 +2766,20 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^1.0.7",
|
||||||
|
"esprima": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@istanbuljs/schema": {
|
"node_modules/@istanbuljs/schema": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||||
@@ -4260,9 +4284,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.3",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -4654,6 +4678,27 @@
|
|||||||
"url": "https://github.com/sponsors/gregberge"
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@testing-library/dom": {
|
||||||
|
"version": "10.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.10.4",
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/aria-query": "^5.0.1",
|
||||||
|
"aria-query": "5.3.0",
|
||||||
|
"dom-accessibility-api": "^0.5.9",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
|
"picocolors": "1.1.1",
|
||||||
|
"pretty-format": "^27.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/jest-dom": {
|
"node_modules/@testing-library/jest-dom": {
|
||||||
"version": "5.17.0",
|
"version": "5.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz",
|
||||||
@@ -6534,6 +6579,22 @@
|
|||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios/node_modules/form-data": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.4",
|
||||||
|
"mime-types": "^2.1.35"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -9779,10 +9840,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/js-yaml": {
|
"node_modules/eslint/node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -10560,22 +10631,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
|
||||||
"version": "4.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"asynckit": "^0.4.0",
|
|
||||||
"combined-stream": "^1.0.8",
|
|
||||||
"es-set-tostringtag": "^2.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"mime-types": "^2.1.12"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -11082,9 +11137,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -11344,9 +11399,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/http-proxy-middleware": {
|
"node_modules/http-proxy-middleware": {
|
||||||
"version": "2.0.9",
|
"version": "2.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -15165,20 +15220,6 @@
|
|||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
|
||||||
"version": "3.14.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
|
||||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"argparse": "^1.0.7",
|
|
||||||
"esprima": "^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"js-yaml": "bin/js-yaml.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "16.7.0",
|
"version": "16.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz",
|
||||||
@@ -15227,16 +15268,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsdom/node_modules/form-data": {
|
"node_modules/jsdom/node_modules/form-data": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz",
|
||||||
"integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==",
|
"integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.35"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -15431,14 +15472,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/launch-editor": {
|
"node_modules/launch-editor": {
|
||||||
"version": "2.13.2",
|
"version": "2.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
|
||||||
"integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==",
|
"integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
@@ -16685,9 +16726,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -19155,12 +19196,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.3",
|
"version": "6.30.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.2"
|
"@remix-run/router": "1.23.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -19170,13 +19211,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "6.30.3",
|
"version": "6.30.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.2",
|
"@remix-run/router": "1.23.3",
|
||||||
"react-router": "6.30.3"
|
"react-router": "6.30.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -19667,32 +19708,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/resolve-url-loader": {
|
"node_modules/resolve-url-loader": {
|
||||||
"version": "4.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz",
|
||||||
"integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==",
|
"integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adjust-sourcemap-loader": "^4.0.0",
|
"adjust-sourcemap-loader": "^4.0.0",
|
||||||
"convert-source-map": "^1.7.0",
|
"convert-source-map": "^1.7.0",
|
||||||
"loader-utils": "^2.0.0",
|
"loader-utils": "^2.0.0",
|
||||||
"postcss": "^7.0.35",
|
"postcss": "^8.2.14",
|
||||||
"source-map": "0.6.1"
|
"source-map": "0.6.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.9"
|
"node": ">=12"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"rework": "1.0.1",
|
|
||||||
"rework-visit": "1.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"rework": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"rework-visit": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/resolve-url-loader/node_modules/convert-source-map": {
|
"node_modules/resolve-url-loader/node_modules/convert-source-map": {
|
||||||
@@ -19702,31 +19731,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/resolve-url-loader/node_modules/picocolors": {
|
|
||||||
"version": "0.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
|
|
||||||
"integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/resolve-url-loader/node_modules/postcss": {
|
|
||||||
"version": "7.0.39",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
|
|
||||||
"integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"picocolors": "^0.2.1",
|
|
||||||
"source-map": "^0.6.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/postcss/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve-url-loader/node_modules/source-map": {
|
"node_modules/resolve-url-loader/node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -21191,6 +21195,20 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svgo/node_modules/js-yaml": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^1.0.7",
|
||||||
|
"esprima": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/svgo/node_modules/nth-check": {
|
"node_modules/svgo/node_modules/nth-check": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz",
|
||||||
@@ -21315,6 +21333,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss/node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||||
@@ -22332,9 +22368,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webpack-dev-server/node_modules/ws": {
|
"node_modules/webpack-dev-server/node_modules/ws": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -22429,9 +22465,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/websocket-driver": {
|
"node_modules/websocket-driver": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
|
||||||
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
|
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -23010,9 +23046,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "7.5.10",
|
"version": "7.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
|
||||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+17
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "haproxy-openmanager-frontend",
|
"name": "haproxy-openmanager-frontend",
|
||||||
"version": "1.8.9",
|
"version": "1.8.10",
|
||||||
"description": "HAProxy Load Balancer Management UI",
|
"description": "HAProxy Load Balancer Management UI",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.8.0",
|
"react-router-dom": "^6.30.4",
|
||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"antd": "^5.2.0",
|
"antd": "^5.2.0",
|
||||||
"@ant-design/icons": "^5.0.0",
|
"@ant-design/icons": "^5.0.0",
|
||||||
@@ -30,6 +30,21 @@
|
|||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.0"
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.0"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"ws": "7.5.11",
|
||||||
|
"webpack-dev-server": { "ws": "8.21.0" },
|
||||||
|
"form-data": "4.0.6",
|
||||||
|
"jsdom": { "form-data": "3.0.5" },
|
||||||
|
"js-yaml": "3.15.0",
|
||||||
|
"eslint": { "js-yaml": "4.2.0" },
|
||||||
|
"@eslint/eslintrc": { "js-yaml": "4.2.0" },
|
||||||
|
"http-proxy-middleware": "2.0.10",
|
||||||
|
"launch-editor": "2.14.1",
|
||||||
|
"postcss": "8.5.10",
|
||||||
|
"resolve-url-loader": "5.0.0",
|
||||||
|
"@babel/core": "7.29.6",
|
||||||
|
"websocket-driver": "0.7.5"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ spec:
|
|||||||
serviceAccountName: haproxy-openmanager-redis
|
serviceAccountName: haproxy-openmanager-redis
|
||||||
containers:
|
containers:
|
||||||
- name: redis
|
- name: redis
|
||||||
image: redis:7-alpine
|
image: redis:8.8.0-alpine
|
||||||
command:
|
command:
|
||||||
- redis-server
|
- redis-server
|
||||||
- /usr/local/etc/redis/redis.conf
|
- /usr/local/etc/redis/redis.conf
|
||||||
|
|||||||
Reference in New Issue
Block a user