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.
This commit is contained in:
taylanbakircioglu
2026-05-30 19:09:26 +03:00
parent 0692f26ebb
commit f3d4fb11bb
8 changed files with 67 additions and 57 deletions
+3
View File
@@ -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.
---
+9 -8
View File
@@ -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"
+46 -19
View File
@@ -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("""
+1 -1
View File
@@ -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."
+2 -2
View File
@@ -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
+2 -23
View File
@@ -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",
+1 -1
View File
@@ -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": {
+3 -3
View File
@@ -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"
}