fix(security): sanitize GET /api/config output — exclude filesystem paths to TLS material and prevent plugin secret leakage
Symptom
GET /api/config is a public read endpoint that returns the running AppConfig. It already excludes a few known-sensitive fields, but the exclusion list is incomplete:
# src/rackscope/api/routers/config.py:28-34
enriched_config = app_config.model_dump(
exclude={
"telemetry": {"basic_auth_password"},
"auth": {"password_hash", "secret_key"},
}
)
Three filesystem paths to TLS material are returned in clear:
telemetry.tls_ca_file
telemetry.tls_cert_file
telemetry.tls_key_file
The last one is particularly sensitive — it is the path to the private key used to authenticate to Prometheus when mTLS is configured. Disclosing this path:
- Helps a network-adjacent attacker construct targeted file-read attacks (e.g., via a future path-traversal vulnerability, or via a misconfigured container with shared volumes).
- Reveals filesystem layout of the deployment (
/etc/rackscope/certs/prometheus-client.key style hints).
- Confirms that mTLS is in use, which by itself helps an attacker understand the environment.
Additionally, the endpoint enriches plugin configuration with plugin.config.model_dump() without any sanitization. Today's bundled plugins (simulator, slurm) have no secrets in their config, but future plugins (IPMI credentials, API tokens, third-party DCIM access keys) would silently leak through this endpoint with no warning to the plugin author.
Technical Analysis
Why a denylist is the wrong long-term shape
The current implementation is a model_dump(exclude={...}) denylist. Every time a new sensitive field is added to AppConfig, the author must remember to add it to this list. Forgetting is silent. There is no test that fails when a new sensitive field appears.
The correct long-term shape is an allowlist: explicitly enumerate the fields safe to return, refuse everything else by default. New fields must be opt-in to the public response.
However, an allowlist refactor is more invasive and can break the frontend Settings panel that consumes GET /api/config. This issue ships the immediate denylist extension to close the known leak, and explicitly flags the allowlist refactor as a recommended follow-up (Phase 2 below).
Plugin secret leakage — forward-looking concern
# src/rackscope/api/routers/config.py:37-42
if registry and hasattr(registry, "_plugins"):
for plugin_id, plugin in registry._plugins.items():
if hasattr(plugin, "config") and plugin.config:
enriched_config["plugins"][plugin_id] = plugin.config.model_dump()
Today, simulator and slurm have no secret fields, so no leak occurs in practice. But the contract is wrong: a future plugin that adds, for example, ipmi_admin_password: str would silently leak through this endpoint.
A plugin should declare what its config can safely expose, rather than the core router needing to know about every plugin's internals.
Proposed Fix
Phase 1 — Immediate denylist extension (this PR)
1. Extend the exclude block in src/rackscope/api/routers/config.py
enriched_config = app_config.model_dump(
exclude={
"telemetry": {
"basic_auth_password",
"tls_ca_file",
"tls_cert_file",
"tls_key_file",
},
"auth": {
"password_hash",
"secret_key",
},
}
)
2. Add a sanitized_config() contract on the plugin base class
In src/rackscope/plugins/base.py (or wherever the abstract plugin lives), add a default method on the base class:
class Plugin(ABC):
config: Optional[BaseModel] = None
def sanitized_config(self) -> Optional[dict]:
"""Return a dict safe to expose via /api/config.
Default implementation: same as model_dump(). Plugins with sensitive
fields MUST override this method and exclude those fields explicitly.
"""
if self.config is None:
return None
return self.config.model_dump()
Update the router to use the new contract:
if registry and hasattr(registry, "_plugins"):
for plugin_id, plugin in registry._plugins.items():
sanitized = plugin.sanitized_config() if hasattr(plugin, "sanitized_config") else None
if sanitized is not None:
enriched_config["plugins"][plugin_id] = sanitized
The current bundled plugins (simulator, slurm) keep their existing behaviour — they inherit the default sanitized_config() which returns the full dump (no secrets present). This is non-breaking.
Document the contract in rackscope_documentation/docs/plugins/index.md (or equivalent): "If your plugin config contains secrets, override sanitized_config() to exclude them. The default implementation returns the full dump — fine for plugins with no secrets, dangerous otherwise."
Phase 2 — Recommended follow-up (separate future issue, NOT this PR)
Refactor GET /api/config to use an explicit allowlist (Pydantic include={...} or a dedicated response model PublicAppConfigResponse). The frontend Settings panel is updated to consume this dedicated response shape rather than the raw AppConfig.
This change is non-trivial because the frontend currently treats GET /api/config as a mirror of AppConfig. Track as separate issue with its own review cycle.
Frontend impact
The frontend Settings panel that consumes GET /api/config to show "TLS CA file: /etc/.../ca.pem" must be updated. Replace the displayed value with a boolean indicator: TLS CA: configured / TLS CA: not configured. Same for cert and key.
This is non-breaking from a UX perspective — the user still sees that mTLS is configured, just without revealing the exact path.
Files to update (approximate, verify at implementation time):
frontend/src/app/pages/editors/SettingsPage.tsx or wherever telemetry config is displayed
- Any read-only "current configuration" rendering
Test Checklist
Unit tests
Functional tests
Plugin sanitization tests
Security regression test (the important one)
Add tests/security/test_no_secret_in_config.py that:
- Builds an
AppConfig populated with every known sensitive field set to a unique sentinel string ("SECRET_TLS_CA_PATH_xyz", "SECRET_PASSWORD_HASH_xyz", etc.)
- Calls
GET /api/config
- Asserts none of the sentinel strings appear anywhere in the serialized response
This single test prevents future leaks: when a new sensitive field is added to AppConfig, the author must either add it to the exclude list (test passes) or accept the leak (test fails, PR rejected). It is the safety net that compensates for the denylist shape.
Impact and Severity
- Audience affected: every deployment that uses mTLS to authenticate to Prometheus (HPC clusters with internal CA, on-prem deployments with corporate PKI). The exposure is read-only and requires network access to the API, so the immediate risk is moderate — but for security-sensitive environments, leaking the location of a TLS private key is unacceptable.
- Severity: high. Information disclosure of paths to private key material. Compounded by the fact that
GET /api/config is unauthenticated in the default config (auth.enabled=false), so the leak is reachable anonymously.
- Priority: ship in the v1.0.0 security hardening series alongside Issue A.
Breaking Changes
None at the API contract level. The three TLS path fields disappear from the response, but:
- They were not documented as a stable part of the contract.
- The frontend update (showing "configured" / "not configured" instead of the path) preserves the user-facing information without revealing the path.
- The plugin
sanitized_config() default implementation matches the previous behaviour for existing plugins (simulator, slurm).
Related
- Audit ref: SEC-04 in
AUDIT_ARCHITECTURAL.md
- Companion to Issue A (
require_admin hardening) — both are part of the v1.0.0 security hardening series, but distinct concerns (mutation auth vs. response sanitization). Ship as separate PRs.
- Future: Phase 2 allowlist refactor (separate issue, not blocking this fix).
Out of Scope
- Allowlist refactor of
GET /api/config (Phase 2, separate issue).
- Hiding
auth.username (low-value change, would regress the Settings UI showing "Current admin: admin"; deferred unless a real threat case emerges).
- Auditing every plugin for unsafe
model_dump() patterns in their own endpoints (this issue's contract handles the central GET /api/config; plugin-internal endpoints are reviewed during plugin code reviews).
- Adding a
Cache-Control: no-store header to GET /api/config to prevent caching of formerly-leaked TLS paths in CDNs / proxies. Considered overkill for the threat model; can be added in a follow-up if requested.
fix(security): sanitize
GET /api/configoutput — exclude filesystem paths to TLS material and prevent plugin secret leakageSymptom
GET /api/configis a public read endpoint that returns the runningAppConfig. It already excludes a few known-sensitive fields, but the exclusion list is incomplete:Three filesystem paths to TLS material are returned in clear:
telemetry.tls_ca_filetelemetry.tls_cert_filetelemetry.tls_key_fileThe last one is particularly sensitive — it is the path to the private key used to authenticate to Prometheus when mTLS is configured. Disclosing this path:
/etc/rackscope/certs/prometheus-client.keystyle hints).Additionally, the endpoint enriches plugin configuration with
plugin.config.model_dump()without any sanitization. Today's bundled plugins (simulator,slurm) have no secrets in their config, but future plugins (IPMI credentials, API tokens, third-party DCIM access keys) would silently leak through this endpoint with no warning to the plugin author.Technical Analysis
Why a denylist is the wrong long-term shape
The current implementation is a
model_dump(exclude={...})denylist. Every time a new sensitive field is added toAppConfig, the author must remember to add it to this list. Forgetting is silent. There is no test that fails when a new sensitive field appears.The correct long-term shape is an allowlist: explicitly enumerate the fields safe to return, refuse everything else by default. New fields must be opt-in to the public response.
However, an allowlist refactor is more invasive and can break the frontend Settings panel that consumes
GET /api/config. This issue ships the immediate denylist extension to close the known leak, and explicitly flags the allowlist refactor as a recommended follow-up (Phase 2 below).Plugin secret leakage — forward-looking concern
Today,
simulatorandslurmhave no secret fields, so no leak occurs in practice. But the contract is wrong: a future plugin that adds, for example,ipmi_admin_password: strwould silently leak through this endpoint.A plugin should declare what its config can safely expose, rather than the core router needing to know about every plugin's internals.
Proposed Fix
Phase 1 — Immediate denylist extension (this PR)
1. Extend the exclude block in
src/rackscope/api/routers/config.py2. Add a
sanitized_config()contract on the plugin base classIn
src/rackscope/plugins/base.py(or wherever the abstract plugin lives), add a default method on the base class:Update the router to use the new contract:
The current bundled plugins (
simulator,slurm) keep their existing behaviour — they inherit the defaultsanitized_config()which returns the full dump (no secrets present). This is non-breaking.Document the contract in
rackscope_documentation/docs/plugins/index.md(or equivalent): "If your plugin config contains secrets, overridesanitized_config()to exclude them. The default implementation returns the full dump — fine for plugins with no secrets, dangerous otherwise."Phase 2 — Recommended follow-up (separate future issue, NOT this PR)
Refactor
GET /api/configto use an explicit allowlist (Pydanticinclude={...}or a dedicated response modelPublicAppConfigResponse). The frontend Settings panel is updated to consume this dedicated response shape rather than the rawAppConfig.This change is non-trivial because the frontend currently treats
GET /api/configas a mirror ofAppConfig. Track as separate issue with its own review cycle.Frontend impact
The frontend Settings panel that consumes
GET /api/configto show "TLS CA file: /etc/.../ca.pem" must be updated. Replace the displayed value with a boolean indicator:TLS CA: configured/TLS CA: not configured. Same for cert and key.This is non-breaking from a UX perspective — the user still sees that mTLS is configured, just without revealing the exact path.
Files to update (approximate, verify at implementation time):
frontend/src/app/pages/editors/SettingsPage.tsxor wherever telemetry config is displayedTest Checklist
Unit tests
tests/unit/test_config_sanitization.py: fullAppConfigwith all sensitive fields populated →model_dump(exclude=...)does not containtls_ca_file,tls_cert_file,tls_key_file,basic_auth_password,password_hash,secret_keytests/unit/test_config_sanitization.py: missing TLS fields (None) → response shape unchanged from the existing contract (no new keys appear)Functional tests
tests/integration/test_config_endpoint.py:GET /api/configreturns 200, response JSON does not contain any of the six sensitive field names anywhere in the structure (full recursive scan against a known sensitive-keys list)tests/integration/test_config_endpoint.py:GET /api/configwith auth disabled (default) still works as before — non-regressiontests/integration/test_config_endpoint.py:GET /api/configwith auth enabled and valid JWT still works as before — non-regressionPlugin sanitization tests
tests/unit/plugins/test_sanitized_config.py: a mock plugin withsecret: str = "***"in its config and an overriddensanitized_config()returning{}→GET /api/configdoes not contain***tests/unit/plugins/test_sanitized_config.py: a mock plugin with the defaultsanitized_config()(no override) → returns the full dump (current behaviour, no regression)Security regression test (the important one)
Add
tests/security/test_no_secret_in_config.pythat:AppConfigpopulated with every known sensitive field set to a unique sentinel string ("SECRET_TLS_CA_PATH_xyz","SECRET_PASSWORD_HASH_xyz", etc.)GET /api/configThis single test prevents future leaks: when a new sensitive field is added to
AppConfig, the author must either add it to the exclude list (test passes) or accept the leak (test fails, PR rejected). It is the safety net that compensates for the denylist shape.Impact and Severity
GET /api/configis unauthenticated in the default config (auth.enabled=false), so the leak is reachable anonymously.Breaking Changes
None at the API contract level. The three TLS path fields disappear from the response, but:
sanitized_config()default implementation matches the previous behaviour for existing plugins (simulator,slurm).Related
AUDIT_ARCHITECTURAL.mdrequire_adminhardening) — both are part of the v1.0.0 security hardening series, but distinct concerns (mutation auth vs. response sanitization). Ship as separate PRs.Out of Scope
GET /api/config(Phase 2, separate issue).auth.username(low-value change, would regress the Settings UI showing "Current admin: admin"; deferred unless a real threat case emerges).model_dump()patterns in their own endpoints (this issue's contract handles the centralGET /api/config; plugin-internal endpoints are reviewed during plugin code reviews).Cache-Control: no-storeheader toGET /api/configto prevent caching of formerly-leaked TLS paths in CDNs / proxies. Considered overkill for the threat model; can be added in a follow-up if requested.