Finding (severity: HIGH)
CONTROL_SECRET_PATHS in pithead (around line 4620) and the mirrored SECRET_PATHS list in
build/dashboard/mining_dashboard/service/control_service.py (line 27) are the single source of
truth for which config.json leaves get replaced with the {"__secret__": true} sentinel before
anything is exposed to the dashboard container:
pithead:4620-4630 — masks dashboard.auth.password, telegram.bot_token,
workers.api_token, monero.node_username/node_password/view_key, tari.view_key,
p2pool.stratum_password, healthchecks.ping_url, xvb.standby.source.
build/dashboard/mining_dashboard/service/control_service.py:27-44 — the same list, used again
in read_config/data_service.py:429 as "defense in depth" for the dashboard's own HTTP API.
Neither list includes notifications.ntfy.token, notifications.ntfy.url, or
notifications.webhooks (config.reference.json:166-173). docs/configuration.md:171
explicitly documents that webhook URLs "are secrets (query strings often carry tokens)" and says
they belong only in an "owner-only .env, never logged or printed" — i.e. the project already
treats them as equivalent to healthchecks.ping_url, which IS masked, but forgot to add these
three paths to the mask list.
Exploit
render_masked_config() (pithead:4651) writes the live, unredacted value of these three
paths into <control-dir>/masked/config.json, which is mounted read-only into the dashboard
container (os/quadlet/dashboard.container — Volume=.../control/masked:/control/masked:ro)
and is documented as "world-readable on purpose (it holds no secret values)" — that comment is
now false. Any RCE in the dashboard container (or anyone who can read that mount) recovers the
live ntfy token/URL and every webhook URL verbatim, even though the whole point of the #33/#440
control-channel design is that a compromised dashboard container "reads masked config, results,
and the audit log, nothing more" (pithead:4646). The dashboard's own read_config path (served
to any authenticated dashboard user) has the identical gap since it uses the same Python list.
An ntfy token can post/read a topic; a webhook URL (Discord/Slack/ntfy/Home Assistant style) IS
the bearer credential to post as the operator's integration — both are real takeover primitives
for whatever they're wired to, not just noise.
Fix
Add ["notifications","ntfy","token"], ["notifications","ntfy","url"], and a mask for each
entry of notifications.webhooks[] to both CONTROL_SECRET_PATHS (pithead:4620) and
SECRET_PATHS (control_service.py:27), matching the array-masking pattern already used for
workers.list[].token in render_masked_config.
Finding (severity: HIGH)
CONTROL_SECRET_PATHSinpithead(around line 4620) and the mirroredSECRET_PATHSlist inbuild/dashboard/mining_dashboard/service/control_service.py(line 27) are the single source oftruth for which
config.jsonleaves get replaced with the{"__secret__": true}sentinel beforeanything is exposed to the dashboard container:
pithead:4620-4630— masksdashboard.auth.password,telegram.bot_token,workers.api_token,monero.node_username/node_password/view_key,tari.view_key,p2pool.stratum_password,healthchecks.ping_url,xvb.standby.source.build/dashboard/mining_dashboard/service/control_service.py:27-44— the same list, used againin
read_config/data_service.py:429as "defense in depth" for the dashboard's own HTTP API.Neither list includes
notifications.ntfy.token,notifications.ntfy.url, ornotifications.webhooks(config.reference.json:166-173).docs/configuration.md:171explicitly documents that webhook URLs "are secrets (query strings often carry tokens)" and says
they belong only in an "owner-only
.env, never logged or printed" — i.e. the project alreadytreats them as equivalent to
healthchecks.ping_url, which IS masked, but forgot to add thesethree paths to the mask list.
Exploit
render_masked_config()(pithead:4651) writes the live, unredacted value of these threepaths into
<control-dir>/masked/config.json, which is mounted read-only into the dashboardcontainer (
os/quadlet/dashboard.container—Volume=.../control/masked:/control/masked:ro)and is documented as "world-readable on purpose (it holds no secret values)" — that comment is
now false. Any RCE in the dashboard container (or anyone who can read that mount) recovers the
live ntfy token/URL and every webhook URL verbatim, even though the whole point of the #33/#440
control-channel design is that a compromised dashboard container "reads masked config, results,
and the audit log, nothing more" (
pithead:4646). The dashboard's ownread_configpath (servedto any authenticated dashboard user) has the identical gap since it uses the same Python list.
An ntfy token can post/read a topic; a webhook URL (Discord/Slack/ntfy/Home Assistant style) IS
the bearer credential to post as the operator's integration — both are real takeover primitives
for whatever they're wired to, not just noise.
Fix
Add
["notifications","ntfy","token"],["notifications","ntfy","url"], and a mask for eachentry of
notifications.webhooks[]to bothCONTROL_SECRET_PATHS(pithead:4620) andSECRET_PATHS(control_service.py:27), matching the array-masking pattern already used forworkers.list[].tokeninrender_masked_config.