mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-04 12:31:31 +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 jose import jwt
|
||||
import logging
|
||||
@@ -92,6 +92,18 @@ async def get_current_user_from_token(authorization: Optional[str] = None) -> Op
|
||||
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]]:
|
||||
"""
|
||||
Get current user from JWT token without raising HTTPException.
|
||||
|
||||
+141
-84
@@ -251,7 +251,7 @@ def calculate_agent_health(status, last_seen):
|
||||
return "offline"
|
||||
|
||||
@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
|
||||
|
||||
@@ -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
|
||||
- **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:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
try:
|
||||
if pool_id:
|
||||
agents = await conn.fetch("""
|
||||
@@ -763,6 +776,14 @@ async def generate_uninstall_script(platform: str, authorization: str = Header(N
|
||||
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:
|
||||
# Normalize platform to a canonical key (always 'linux' or 'macos').
|
||||
# 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
|
||||
|
||||
@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."""
|
||||
# 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:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE agents
|
||||
SET status = 'online',
|
||||
UPDATE agents
|
||||
SET status = 'online',
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
hostname = COALESCE($2, hostname),
|
||||
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)
|
||||
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:
|
||||
logger.error(f"Failed to process config applied notification from agent '{agent_name}': {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"}
|
||||
|
||||
except HTTPException:
|
||||
raise # let auth 401/403 propagate (do not turn it into a 200 error body)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process validation error notification from agent '{agent_name}': {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")
|
||||
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:
|
||||
logger.error(f"Failed to process config sync from agent '{agent_name}': {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}")
|
||||
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
|
||||
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()
|
||||
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)
|
||||
|
||||
# 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")
|
||||
async def get_agent_config(agent_name: str, x_api_key: Optional[str] = Header(None)):
|
||||
"""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:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
# Get agent info first to check pool
|
||||
# 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
|
||||
@@ -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
|
||||
WHERE a.name = $1
|
||||
""", agent_name)
|
||||
|
||||
|
||||
if not agent_info:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
||||
|
||||
# Validate agent API key
|
||||
# API key is global - can be used for multiple agents
|
||||
if x_api_key:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
|
||||
if not agent_auth:
|
||||
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}'")
|
||||
|
||||
|
||||
# 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']:
|
||||
await close_database_connection(conn)
|
||||
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")
|
||||
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"""
|
||||
# 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:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
# Get agent and cluster info first
|
||||
agent_info = await conn.fetchrow("""
|
||||
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
|
||||
WHERE a.name = $1
|
||||
""", agent_name)
|
||||
|
||||
|
||||
if not agent_info:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
||||
|
||||
# Validate agent API key
|
||||
# API key is global - can be used for multiple agents
|
||||
if x_api_key:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
|
||||
if not agent_auth:
|
||||
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}'")
|
||||
|
||||
# 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']:
|
||||
await close_database_connection(conn)
|
||||
@@ -2440,16 +2478,24 @@ async def get_latest_script_version(platform: str = "macos"):
|
||||
@router.get("/{agent_name}/upgrade-status")
|
||||
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"""
|
||||
# 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:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
# Check if agent has upgrade pending (include platform and pool for validation)
|
||||
agent = await conn.fetchrow("""
|
||||
SELECT status, version as current_version, platform, pool_id
|
||||
FROM agents
|
||||
FROM agents
|
||||
WHERE name = $1
|
||||
""", agent_name)
|
||||
|
||||
|
||||
if not agent:
|
||||
await close_database_connection(conn)
|
||||
return {
|
||||
@@ -2457,26 +2503,15 @@ async def get_agent_upgrade_status(agent_name: str, x_api_key: Optional[str] = H
|
||||
"target_version": "",
|
||||
"message": "Agent not found"
|
||||
}
|
||||
|
||||
# Validate agent API key
|
||||
# API key is global - can be used for multiple agents
|
||||
if x_api_key:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
|
||||
if not agent_auth:
|
||||
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}'")
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
# 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"""
|
||||
try:
|
||||
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()
|
||||
|
||||
# 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"""
|
||||
try:
|
||||
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', '')
|
||||
version = template_data.get('version', '')
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Configuration Management and Validation API
|
||||
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 typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
@@ -18,7 +18,7 @@ from utils.config_templates import (
|
||||
)
|
||||
from utils.haproxy_config_parser import parse_haproxy_config
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["Configuration Management"])
|
||||
@@ -62,7 +62,7 @@ class ConfigOptimizationRequest(BaseModel):
|
||||
optimization_level: str = "balanced" # conservative, balanced, aggressive
|
||||
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(
|
||||
request: ConfigValidationRequest,
|
||||
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)}")
|
||||
|
||||
@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(
|
||||
template_id: str,
|
||||
request: TemplateGenerationRequest,
|
||||
@@ -271,7 +271,7 @@ async def generate_configuration(
|
||||
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(
|
||||
request: ConfigOptimizationRequest,
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
if x_api_key and not agent_auth:
|
||||
logger.warning(f"Invalid API key provided by agent '{agent_name}' for pending requests")
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
if not agent_auth:
|
||||
logger.warning(f"Missing/invalid API key from '{agent_name}' for pending requests")
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
conn = await get_database_connection()
|
||||
|
||||
# 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 datetime import datetime
|
||||
import logging
|
||||
@@ -11,7 +12,7 @@ from agent_notifications import get_cluster_agents_status
|
||||
router = APIRouter(prefix="/api", tags=["dashboard", "pools"])
|
||||
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)):
|
||||
"""Get dashboard overview with comprehensive statistics, optionally filtered by cluster"""
|
||||
try:
|
||||
@@ -238,7 +239,7 @@ async def get_dashboard_overview(cluster_id: Optional[int] = None, authorization
|
||||
logger.error(f"Error fetching dashboard overview: {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():
|
||||
"""Get dashboard statistics"""
|
||||
try:
|
||||
@@ -279,7 +280,7 @@ async def get_dashboard_stats():
|
||||
except Exception as 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():
|
||||
"""Get all HAProxy cluster pools"""
|
||||
try:
|
||||
@@ -350,7 +351,7 @@ async def get_pools():
|
||||
logger.error(f"Error fetching pools: {e}")
|
||||
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():
|
||||
"""Get all HAProxy cluster pools (legacy 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}")
|
||||
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):
|
||||
"""Get all agents for a specific pool"""
|
||||
try:
|
||||
@@ -561,7 +562,7 @@ async def get_pool_agents(pool_id: int):
|
||||
logger.error(f"Error fetching pool agents: {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):
|
||||
"""Get HAProxy statistics"""
|
||||
try:
|
||||
|
||||
@@ -3,13 +3,22 @@ Dashboard Stats Router
|
||||
API endpoints for HAProxy statistics dashboard
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ Production-Ready Health Check and Monitoring Endpoints
|
||||
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 auth_middleware import require_authenticated_user
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
@@ -74,7 +75,7 @@ async def readiness_probe():
|
||||
logger.error(f"Readiness probe failed: {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():
|
||||
"""Comprehensive health check with detailed system information"""
|
||||
global _health_cache
|
||||
@@ -197,7 +198,7 @@ async def deep_health_check():
|
||||
|
||||
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():
|
||||
"""Monitor agent connectivity and health status"""
|
||||
try:
|
||||
@@ -261,7 +262,7 @@ async def agents_health():
|
||||
logger.error(f"Agent health check failed: {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():
|
||||
"""Monitor HAProxy cluster health and configuration status"""
|
||||
try:
|
||||
@@ -329,7 +330,7 @@ async def clusters_health():
|
||||
logger.error(f"Cluster health check failed: {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():
|
||||
"""Get application error statistics and metrics"""
|
||||
try:
|
||||
|
||||
@@ -87,6 +87,32 @@ class AccountCreate(BaseModel):
|
||||
raise ValueError("eab_hmac_key is not valid base64; copy it exactly from your CA account.")
|
||||
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')
|
||||
def _require_provider_for_dns01(self):
|
||||
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:
|
||||
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:
|
||||
import aiohttp
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(str(directory_url), timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
||||
async with session.get(
|
||||
directory_url,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
allow_redirects=False,
|
||||
) as resp:
|
||||
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 {
|
||||
"success": True,
|
||||
"directory": str(directory_url),
|
||||
"endpoints": list(data.keys()) if isinstance(data, dict) else []
|
||||
"directory": directory_url,
|
||||
"endpoints": present,
|
||||
}
|
||||
else:
|
||||
return {"success": False, "error": f"HTTP {resp.status} from directory URL"}
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
|
||||
# Import database and models
|
||||
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 utils.ssl_parser import parse_ssl_certificate, validate_private_key, validate_certificate_chain, format_certificate_info
|
||||
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}")
|
||||
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):
|
||||
"""Get config version history for specific SSL certificate"""
|
||||
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
|
||||
import logging
|
||||
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}")
|
||||
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):
|
||||
"""
|
||||
# Get WAF Rules
|
||||
|
||||
@@ -69,8 +69,14 @@ class ACMEService:
|
||||
if cached.get('_fetched_at', 0) > time.time() - 3600:
|
||||
return cached
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(directory_url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
# SECURITY (GHSA-3vh4-gvxx-wm2p): directory_url can come from a stored
|
||||
# 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:
|
||||
raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}")
|
||||
data = await resp.json()
|
||||
@@ -90,8 +96,16 @@ class ACMEService:
|
||||
cached = self._nonce_by_dir.pop(directory_url, None)
|
||||
if cached:
|
||||
return cached
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.head(directory['newNonce']) as resp:
|
||||
# SECURITY (GHSA-3vh4-gvxx-wm2p): newNonce is taken from the (attacker-
|
||||
# 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']
|
||||
|
||||
def _generate_account_key(self) -> Tuple[str, dict]:
|
||||
@@ -187,13 +201,21 @@ class ACMEService:
|
||||
|
||||
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):
|
||||
async with session.post(
|
||||
url,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/jose+json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
allow_redirects=False,
|
||||
) as resp:
|
||||
if 'Replay-Nonce' in resp.headers:
|
||||
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",
|
||||
"releaseName": "ACL -f pattern-file support (Issue #38 follow-up)",
|
||||
"releaseDate": "2026-07-13"
|
||||
"version": "1.8.10",
|
||||
"releaseName": "Security hardening — RCE, missing-auth and SSRF advisories (GHSA-7rhv/3p5c/3vh4)",
|
||||
"releaseDate": "2026-07-20"
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ services:
|
||||
|
||||
# Redis Cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: redis:8.8.0-alpine
|
||||
container_name: haproxy-openmanager-redis
|
||||
command: redis-server --maxmemory 2gb --maxmemory-policy volatile-lru --save ""
|
||||
ports:
|
||||
|
||||
Generated
+181
-145
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.7.8",
|
||||
"version": "1.8.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.7.8",
|
||||
"version": "1.8.10",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.0.0",
|
||||
@@ -20,7 +20,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-ace": "^10.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-window": "^1.8.10",
|
||||
"react18-json-view": "^0.2.9",
|
||||
"recharts": "^2.5.0"
|
||||
@@ -179,18 +179,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
|
||||
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/helpers": "^7.29.2",
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
@@ -239,14 +239,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -472,9 +472,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -482,9 +482,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -531,13 +531,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -2274,14 +2274,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2669,10 +2669,20 @@
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -2756,6 +2766,20 @@
|
||||
"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": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
@@ -4260,9 +4284,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -4654,6 +4678,27 @@
|
||||
"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": {
|
||||
"version": "5.17.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -9779,10 +9840,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -10560,22 +10631,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": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -11082,9 +11137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -11344,9 +11399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15165,20 +15220,6 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"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": {
|
||||
"version": "16.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz",
|
||||
@@ -15227,16 +15268,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/form-data": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz",
|
||||
"integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz",
|
||||
"integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15431,14 +15472,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/launch-editor": {
|
||||
"version": "2.13.2",
|
||||
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz",
|
||||
"integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==",
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
|
||||
"integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.1.1",
|
||||
"shell-quote": "^1.8.3"
|
||||
"shell-quote": "^1.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
@@ -16685,9 +16726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -19155,12 +19196,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
"@remix-run/router": "1.23.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -19170,13 +19211,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
"@remix-run/router": "1.23.3",
|
||||
"react-router": "6.30.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -19667,32 +19708,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-url-loader": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz",
|
||||
"integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz",
|
||||
"integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adjust-sourcemap-loader": "^4.0.0",
|
||||
"convert-source-map": "^1.7.0",
|
||||
"loader-utils": "^2.0.0",
|
||||
"postcss": "^7.0.35",
|
||||
"postcss": "^8.2.14",
|
||||
"source-map": "0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rework": "1.0.1",
|
||||
"rework-visit": "1.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rework": {
|
||||
"optional": true
|
||||
},
|
||||
"rework-visit": {
|
||||
"optional": true
|
||||
}
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-url-loader/node_modules/convert-source-map": {
|
||||
@@ -19702,31 +19731,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -21191,6 +21195,20 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"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": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||
@@ -22332,9 +22368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -22429,9 +22465,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/websocket-driver": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
||||
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
|
||||
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -23010,9 +23046,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
|
||||
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+17
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.8.9",
|
||||
"version": "1.8.10",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"axios": "^1.16.0",
|
||||
"antd": "^5.2.0",
|
||||
"@ant-design/icons": "^5.0.0",
|
||||
@@ -30,6 +30,21 @@
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@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": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
serviceAccountName: haproxy-openmanager-redis
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
image: redis:8.8.0-alpine
|
||||
command:
|
||||
- redis-server
|
||||
- /usr/local/etc/redis/redis.conf
|
||||
|
||||
Reference in New Issue
Block a user