From f3d4fb11bb84579b31c334eebc09051d6bd11ec8 Mon Sep 17 00:00:00 2001 From: taylanbakircioglu Date: Sat, 30 May 2026 19:09:26 +0300 Subject: [PATCH] fix(agent): accept agent token (X-API-Key) on cluster read endpoints (Issue #22) Assigning a HAProxy agent failed with '401: Authorization header missing' on GET /api/clusters. A cluster-read hardening had made GET /api/clusters and GET /api/clusters/{id} accept only a user JWT in the Authorization header; agents authenticate with their agent token in the X-API-Key header, so the token was never read. Both endpoints now accept either a user JWT (Authorization) or an agent token (X-API-Key via validate_agent_api_key), mirroring the existing dual-auth on POST /api/agents/generate-install-script. Anonymous access is still rejected, so the original hardening is preserved. The auth guard is placed before the try block so the failure surfaces as a clean 401 (not the 500-wrapped-401 in the report). Agent install scripts now consistently send the token via X-API-Key (pre-flight cluster check on linux/macos, and macOS get_cluster_paths which previously used the wrong Authorization: Bearer header). Also normalizes the platform in the uninstall-script generator so macOS agents (which report platform 'darwin') no longer get a 400 from GET /api/agents/generate-uninstall-script/darwin. version 1.6.0 -> 1.6.2. --- README.md | 3 + backend/routers/agent.py | 17 ++--- backend/routers/cluster.py | 65 ++++++++++++++------ backend/utils/agent_scripts/linux_install.sh | 2 +- backend/utils/agent_scripts/macos_install.sh | 4 +- frontend/package-lock.json | 25 +------- frontend/package.json | 2 +- version.json | 6 +- 8 files changed, 67 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index faffac3..d4a015b 100644 --- a/README.md +++ b/README.md @@ -2367,6 +2367,9 @@ Developed with ❤️ for the HAProxy community ## Release Notes +- **v1.6.2** (2026-05-30) — Bugfixes: (1) agents (which authenticate with their `X-API-Key` token) could not reach `GET /api/clusters` / `/api/clusters/{id}` after the v1.5.x cluster-read hardening, breaking agent assignment ("401: Authorization header missing"); these endpoints now accept either a user JWT or an agent token (anonymous access is still rejected). (2) The uninstall-script generator returned 400 for macOS agents (which report platform `darwin`); it now normalizes the platform the same way the install generator does. No UI or schema changes. +- **v1.6.1** (2026-05-21) — Security patch: bump `axios` to 1.16.x (prototype-pollution hardening, header-injection fix, keep-alive memory leak fix) and `fast-uri` to 3.1.2 (GHSA-v39h-62p7-jpjc). No functional changes. + For full release notes and the list of features delivered in each version (v1.5.x Site Wizard + ACME Diagnostic Panel, v1.4.0 ACME stability + enterprise audit, v1.3.0, ...) see the [GitHub Releases](https://github.com/taylanbakircioglu/haproxy-openmanager/releases) page. --- diff --git a/backend/routers/agent.py b/backend/routers/agent.py index dcafe3e..6d50991 100644 --- a/backend/routers/agent.py +++ b/backend/routers/agent.py @@ -730,14 +730,15 @@ async def generate_uninstall_script(platform: str, authorization: str = Header(N ``` """ try: - # Validate platform - platform_lower = platform.lower() - if platform_lower not in ['linux', 'macos']: - raise HTTPException( - status_code=400, - detail=f"Invalid platform: {platform}. Must be 'linux' or 'macos'" - ) - + # Normalize platform to a canonical key (always 'linux' or 'macos'). + # macOS agents register with platform 'darwin' (from `uname -s`), so the + # strict ['linux','macos'] check used to 400 on the UI's uninstall flow + # (GET /generate-uninstall-script/darwin). Reuse the same get_platform_key() + # helper the install-script generator uses, so darwin/osx/mac and the + # linux distro variants all resolve correctly. Backward-compatible: + # 'linux'/'macos' still map to themselves. + platform_lower = get_platform_key(platform) + # Read uninstall script from agent_scripts directory (same as install scripts) import os script_filename = f"uninstall-agent-{platform_lower}.sh" diff --git a/backend/routers/cluster.py b/backend/routers/cluster.py index a3a45d8..31426fc 100644 --- a/backend/routers/cluster.py +++ b/backend/routers/cluster.py @@ -517,7 +517,7 @@ async def update_cluster(cluster_id: int, cluster: HAProxyClusterUpdate, authori @router.get("/{cluster_id}", summary="Get Cluster by ID", response_description="Cluster details") -async def get_cluster(cluster_id: int, authorization: str = Header(None)): +async def get_cluster(cluster_id: int, authorization: str = Header(None), x_api_key: Optional[str] = Header(None)): """ # Get Specific HAProxy Cluster @@ -528,8 +528,12 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)): ## Example Request ```bash + # User (UI) authentication: curl -X GET "{BASE_URL}/api/clusters/1" \\ -H "Authorization: Bearer eyJhbGciOiJIUz..." + # Agent authentication (agent token in X-API-Key): + curl -X GET "{BASE_URL}/api/clusters/1" \\ + -H "X-API-Key: hap_..." ``` ## Example Response @@ -554,15 +558,24 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)): - **404**: Cluster not found - **500**: Server error """ - try: - # R18c audit fix (round 6 final convergence): authenticate - # the caller before fetching cluster topology by ID. Pre-fix - # this sibling of GET /api/clusters was anonymous, so an - # attacker could iterate cluster IDs to enumerate the same - # info (stats socket, paths, ACME flags, pool identity) the - # list endpoint just locked down. Closes the asymmetry. + # R18c audit fix (round 6 final convergence): authenticate the caller + # before fetching cluster topology by ID. Pre-fix this sibling of + # GET /api/clusters was anonymous, so an attacker could iterate cluster + # IDs to enumerate the same info (stats socket, paths, ACME flags, pool + # identity) the list endpoint just locked down. + # Issue #22: agents send their token in the X-API-Key header (not a user + # JWT), so accept either credential — mirrors the dual-auth on + # POST /api/agents/generate-install-script. Anonymous is still rejected. + if authorization: from auth_middleware import get_current_user_from_token await get_current_user_from_token(authorization) + elif x_api_key: + from auth_middleware import validate_agent_api_key + if not await validate_agent_api_key(x_api_key): + raise HTTPException(status_code=401, detail="Invalid agent API key") + else: + raise HTTPException(status_code=401, detail="Authorization header or X-API-Key required") + try: conn = await get_database_connection() cluster = await conn.fetchrow(""" @@ -602,7 +615,7 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)): raise HTTPException(status_code=500, detail=str(e)) @router.get("", summary="Get All Clusters", response_description="List of all clusters") -async def get_clusters(authorization: str = Header(None)): +async def get_clusters(authorization: str = Header(None), x_api_key: Optional[str] = Header(None)): """ # Get All HAProxy Clusters @@ -610,8 +623,12 @@ async def get_clusters(authorization: str = Header(None)): ## Example Request ```bash + # User (UI) authentication: curl -X GET "{BASE_URL}/api/clusters" \\ -H "Authorization: Bearer eyJhbGciOiJIUz..." + # Agent authentication (agent token in X-API-Key): + curl -X GET "{BASE_URL}/api/clusters" \\ + -H "X-API-Key: hap_..." ``` ## Example Response @@ -662,18 +679,28 @@ async def get_clusters(authorization: str = Header(None)): ## Error Responses - **500**: Server error """ - try: - # R18c audit fix (round 6 #3 — KRITIK info leak): require an - # authenticated caller. Pre-fix the endpoint accepted - # anonymous GETs and returned cluster topology including - # internal HAProxy paths (stats socket, config path, bin - # path), pool ids, ACME flags, and agent counts. This is - # both reconnaissance for an attacker and the spine of the - # cluster-scoped RBAC the rest of the platform builds on, - # so guarding it at the read layer is essential after R18c - # round 5's roster + role guards. + # R18c audit fix (round 6 #3 — KRITIK info leak): require an authenticated + # caller. Pre-fix the endpoint accepted anonymous GETs and returned cluster + # topology including internal HAProxy paths (stats socket, config path, bin + # path), pool ids, ACME flags, and agent counts. This is both reconnaissance + # for an attacker and the spine of the cluster-scoped RBAC the rest of the + # platform builds on, so guarding it at the read layer is essential. + # Issue #22: agents send their token in the X-API-Key header (not a user + # JWT), so accept either credential — mirrors the dual-auth on + # POST /api/agents/generate-install-script. Anonymous is still rejected. + # (Guard kept OUTSIDE the try below: get_clusters' broad `except Exception` + # re-wraps raised HTTPExceptions into 500, which produced the "500 - 401" + # in the issue log; raising here yields a clean 401.) + if authorization: from auth_middleware import get_current_user_from_token await get_current_user_from_token(authorization) + elif x_api_key: + from auth_middleware import validate_agent_api_key + if not await validate_agent_api_key(x_api_key): + raise HTTPException(status_code=401, detail="Invalid agent API key") + else: + raise HTTPException(status_code=401, detail="Authorization header or X-API-Key required") + try: conn = await get_database_connection() clusters = await conn.fetch(""" diff --git a/backend/utils/agent_scripts/linux_install.sh b/backend/utils/agent_scripts/linux_install.sh index 0ef8906..d94ce85 100644 --- a/backend/utils/agent_scripts/linux_install.sh +++ b/backend/utils/agent_scripts/linux_install.sh @@ -635,7 +635,7 @@ if [[ "$SKIP_TO_DAEMON" != "true" ]]; then # Validate cluster exists by checking management API log "DEBUG" "Validating HAProxy Cluster..." - CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "User-Agent: haproxy-agent-installer" || echo "FAILED") + CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "X-API-Key: $AGENT_TOKEN" -H "User-Agent: haproxy-agent-installer" || echo "FAILED") if [[ "$CLUSTER_CHECK" == "FAILED" ]]; then log "ERROR" "Failed to connect to management API!" echo " Please check your network connection and management URL." diff --git a/backend/utils/agent_scripts/macos_install.sh b/backend/utils/agent_scripts/macos_install.sh index 2f9d581..beece42 100644 --- a/backend/utils/agent_scripts/macos_install.sh +++ b/backend/utils/agent_scripts/macos_install.sh @@ -522,7 +522,7 @@ if [[ "$SKIP_TO_DAEMON" != "true" ]]; then # Validate cluster exists by checking management API log "DEBUG" "Validating HAProxy Cluster..." - CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "User-Agent: haproxy-agent-installer" || echo "FAILED") + CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "X-API-Key: $AGENT_TOKEN" -H "User-Agent: haproxy-agent-installer" || echo "FAILED") if [[ "$CLUSTER_CHECK" == "FAILED" ]]; then log "ERROR" "Failed to connect to management API!" echo " Please check your network connection and management URL." @@ -867,7 +867,7 @@ SSL_SYNC_TIMESTAMP_FILE="/tmp/haproxy-agent-ssl-sync-${AGENT_NAME}" get_cluster_paths() { log "DEBUG" "Fetching cluster paths from management API" local cluster_response=$("$CURL_BIN" -k -s -X GET "$MANAGEMENT_URL/api/clusters" \ - -H "Authorization: Bearer $AGENT_TOKEN") + -H "X-API-Key: $AGENT_TOKEN") if [[ $? -eq 0 ]]; then local _cfg _bin _sock diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64199e3..ce8d100 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "haproxy-openmanager-frontend", - "version": "1.6.0", + "version": "1.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "haproxy-openmanager-frontend", - "version": "1.6.0", + "version": "1.6.2", "license": "AGPL-3.0-or-later", "dependencies": { "@ant-design/icons": "^5.0.0", @@ -4654,27 +4654,6 @@ "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", diff --git a/frontend/package.json b/frontend/package.json index d89a66a..c848540 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "haproxy-openmanager-frontend", - "version": "1.6.0", + "version": "1.6.2", "description": "HAProxy Load Balancer Management UI", "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/version.json b/version.json index 785fd96..c002fa5 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "1.6.0", - "releaseName": "Multi-Factor Authentication (MFA)", - "releaseDate": "2026-05-18" + "version": "1.6.2", + "releaseName": "Agent auth fix (issue #22)", + "releaseDate": "2026-05-30" }