The security policy for Pithead: supported versions, how to report a vulnerability, and the stack's default security posture.
Pithead runs a Monero full node, P2Pool, Tari merge-mining, and a dashboard on your hardware, and it handles wallet payout addresses.
Security fixes land on the latest main. There are no long-lived release branches.
Make sure you're running an up-to-date checkout before reporting an issue.
| Version | Supported |
|---|---|
latest main |
✅ |
| anything older | ❌ (please update) |
Please do not open a public issue for security problems.
Use GitHub's private vulnerability reporting instead: go to the Security tab and click Report a vulnerability. This opens a private advisory visible only to the maintainers, for triage and coordinated disclosure.
Include:
- A description of the issue and its impact.
- Steps to reproduce, and the affected component (node, P2Pool, proxy, dashboard, Tor,
pitheadscript, etc.). - Any relevant logs or configuration (redact wallet addresses and secrets).
The stack's defaults:
- Least-privilege containers: every service runs as a non-root user (not uid 0); leaf services
run with
no-new-privilegesand drop all Linux capabilities; internet-facing and Docker-socket-facing services also use a read-only root filesystem. - SHA256-verified, version-pinned binaries.
- Digest-pinned images, unsigned by default
(#376): the release bundle pins
every first-party image to an immutable
@sha256digest, so a tampered registry can't swap what gets pulled, and the bundle itself is fetched over TLS from GitHub Releases. Cosign signing is opt-in and off for every release shipped so far (scripts/release.sh) — it turns on only when a signing key is present on the release box andcosign.pubis committed alongside it, and fails closed once it is. Today, with nocosign.pubnext topithead, the script warns and proceeds unverified rather than blocking the pull — the digest pins above are the actual protection, not a signature. Limits: a compromise of the release box itself, which would hold the signing key, is outside what a signature can prove even once signing is on. See Releasing › Signed releases for the verification mechanics. - Localhost-only RPC.
- LAN-scoped (and narrowable) stratum port.
- Scoped Docker socket proxies.
- Tor for all node networking.
- A one-way host-control boundary for dashboard config editing and upgrades (
dashboard.control, default off): the dashboard container can only ask — it writes typed JSON intents into a spool directory whose other legs (staged configs, results, the audit log) are host-owned and mounted read-only. A root systemd unit re-validates every intent with pithead's own config validation and dispatches a fixed, small set of actions (apply --dry-runfor a preview,apply -yfor a config commit, a release upgrade, and — for the Telegram control commands (#338) — a stackrestartand a config re-apply); no string from the container is ever executed. The upgrade intent carries only the version the operator confirmed: the runner re-derives the target itself from the GitHub release API (over the stack's Tor SOCKS), refuses any mismatch or non-release tag, and limits attempts to one per 10 minutes — the container cannot choose an image, tag, or registry. Enabling the channel without a dashboard password is a validation error, on a published onion it additionally requires Tor client authorization, and every mutation is audited host-side. Commits are default-denied against an explicit allowlist of operational settings: a commit that changes any env key off that list — in every direction (enabling, changing, or disabling) — is refused, as is anything the change preview flags destructive. Wallets, dashboard auth and onion exposure, the control channel itself, the Tor egress firewall, clearnet toggles, node endpoints, binds, and every credential are off the list, and a key added in the future stays un-committable until deliberately listed. Those edits must be applied from the host CLI; out-of-band approval is tracked in #338. - Attack visibility (#349): Caddy writes a JSON access log for every dashboard vhost (LAN and onion), and the control channel's host-side audit log records who changed what (setting names only, never values). The dashboard surfaces both read-only — a burst of 401s is the rotate-the-password signal — and treats every logged field as hostile input: strings are whitelisted to a safe character set before display, because the access log echoes attacker-chosen bytes (request paths, attempted usernames) and rendering them raw would hand an anonymous prober stored XSS against the operator. Both logs are size-bounded (Caddy's native rolling; a trim-before-append cap in the audit writer). Neither ever records a secret: Caddy redacts credential headers by default, and the audit writer logs key names only.
The Telegram bot can accept two control commands, /restart and /apply, gated behind
telegram.control (default off). This is a remotely-reachable control surface — a Telegram message
is untrusted pre-auth input — so it fails closed at every step and adds no new privileged path:
it is a thin client of the host-control channel above.
- Allow-list, not the chat. A control command is honoured only from the numeric Telegram user
ids in
telegram.control.allowed_ids; being in the configured chat is not enough, and the bot token being known is not authorization. Any other sender is refused, logged, and dropped silently (no reply — no oracle for who is authorised), and never earns a write into the host spool. An empty allow-list disables the feature. - Per-action confirmation, deny-on-timeout. Each command requires an explicit in-chat confirmation (an inline button carrying a one-time token) from the same operator that issued it, within a timeout — transaction-signing semantics, so even a fully compromised dashboard session cannot restart or re-apply without a human approving the exact action. An unconfirmed command is denied, never queued; prompts are rate-limited so a compromised host can't fatigue the operator into tapping approve.
- Bounded verbs, shared channel. A message only selects one of two fixed verbs — there is no
arbitrary execution. Confirmed, the verb rides the same request spool the config editor uses; the
root runner validates and runs it and records the actor (
tg-<user-id>) and outcome in the host-side audit log.telegram.controltherefore requiresdashboard.control(the spool + runner) and the read-only command bot;applyrefuses to enable it otherwise. A config-changing apply is deliberately not a Telegram command — config edits still go through the editor's default-deny allowlist;/applyonly re-applies the config already on the host.
The dashboard container never mounts the raw config.json (#440). When dashboard.control is
on, the host renders a pre-masked copy of the config into the control spool — every set
secret leaf (node credentials, the stratum and dashboard passwords, the Telegram token, the
Healthchecks ping URL) already replaced by a sentinel — and the editor form prefills from that
copy, mounted read-only. An untouched secret rides back to the host as the same sentinel, and the
host swaps it for the live value when it stages the intent, so the container never holds a secret
the operator didn't just type into the form. A full backend compromise of the dashboard container
can therefore read masked config, results, and the audit log, and ask to change an allowlisted
key — nothing else. Host-side staged copies, which do carry the merged secrets, live outside
every mount and are written mode 600. Still treat the container as semi-trusted and keep the
onion behind Tor client authorization: the request spool remains a mutation-request surface.
Report any gap in these.