Skip to content

notifications.ntfy.token / .url and notifications.webhooks leak in cleartext through the masked config + dashboard API #848

Description

@VijitSingh97

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.containerVolume=.../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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    appliancePithead OS appliance work — lands on feat/phase2-bakery-imagesecuritySecurity-sensitive issue or hardening

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions