Skip to content

fix(security): bind Grafana to loopback instead of publishing admin/admin on all interfaces - #206

Merged
kuny0707 merged 1 commit into
tronprotocol:developfrom
barbatos2011:security/grafana-exposure
Aug 3, 2026
Merged

fix(security): bind Grafana to loopback instead of publishing admin/admin on all interfaces#206
kuny0707 merged 1 commit into
tronprotocol:developfrom
barbatos2011:security/grafana-exposure

Conversation

@barbatos2011

Copy link
Copy Markdown

trond apply --monitor deployed a Grafana reachable from anywhere that could route to the host, with the image's built-in credentials. Found by an audit of develop at 0c654cd.

The problem

RenderMonitoringCompose emitted the Grafana service with an unconditional - "{{.GrafanaPort}}:3000" and no environment: block at all — no GF_SECURITY_ADMIN_PASSWORD, no auth hardening — so the container kept grafana-oss's built-in admin/admin while docker published it on 0.0.0.0. Nothing else in the repo supplies a password; README.md:371 documents admin/admin.

On a cloud VM with a permissive security group that is the public internet. The payoff is not just metrics: a Grafana admin can create arbitrary datasources and query them through the backend proxy, which turns it into a server-side request primitive into the host's internal network and cloud metadata endpoints.

What changed

Grafana binds to 127.0.0.1 by default.

A new monitoring.grafana.expose restores the all-interfaces bind, but is rejected unless monitoring.grafana.admin_password_env is also set — so the escape hatch cannot recreate the finding. The renderer independently falls back to loopback for an unvalidated intent, so it fails closed rather than honouring a request it cannot verify.

The password is named by env var, not embedded: it renders as a required compose variable reference, so the secret never enters the 0644 compose file, and an unset or empty value fails at interpolation before any container is created.

Verified against real Docker (docker compose config on four rendered configurations): the default resolves to host_ip: 127.0.0.1; expose: true with a password drops the bind address and passes the secret through as a reference; expose: true without a password stays on loopback.

Note the finding's premise that the Prometheus port already had loopback treatment turned out to be wrong — Prometheus is opt-in-published via a conditional, and there was no pre-existing bind-address idiom to follow.

Behaviour changes worth reviewing

  • Breaking: Grafana is no longer reachable off-host by default. An SSH tunnel (ssh -L 3000:127.0.0.1:3000), a reverse proxy, or the new expose opt-in. This mostly affects SSH/remote targets; for local targets trond already advertised grafana_url as http://127.0.0.1:<port>.
  • New validation error for expose: true without admin_password_env — unreachable for any intent written today, since both fields are new.
  • GF_SECURITY_ADMIN_PASSWORD only applies on first initialisation of the grafana_data volume. That is Grafana's behaviour, not ours; setting it for an already-running stack does not rotate an existing password.

Testing

go test ./... -race -count=1, go vet ./... and gofmt clean on the branch. New tests parse the rendered compose as YAML rather than substring-matching, and assert a set password never appears in the rendered file.

Unrelated bug noticed while tracing callers

deployMonitoring and deployNetworkMonitoring build the Grafana datasource URL as http://prometheus:<host port>, which is http://prometheus:0 under the default unexposed-Prometheus config. Pre-existing and untouched here, but worth its own fix.

@barbatos2011
barbatos2011 force-pushed the security/grafana-exposure branch 2 times, most recently from 28f417b to 03b6ba3 Compare August 3, 2026 09:02
…admin password

RenderMonitoringCompose emitted the Grafana service with an unconditional
host port publication and no environment block at all - no
GF_SECURITY_ADMIN_PASSWORD, no auth hardening - so the deployed container
kept grafana-oss's built-in admin/admin while docker published it on
0.0.0.0. On a cloud VM with a permissive security group that is the public
internet: full node metrics, the ability to lock the operator out, and -
because a Grafana admin can add arbitrary datasources and query them
through the backend proxy - a server-side request primitive into the
host's internal network and cloud metadata endpoints.

Grafana is now bound to 127.0.0.1 by default. A new
monitoring.grafana.expose restores the wide bind, but is rejected unless
monitoring.grafana.admin_password_env is also set, so the escape hatch
cannot recreate the finding; the renderer independently falls back to
loopback for an unvalidated intent. The password is rendered as a required
compose variable reference, so the secret never enters the 0644 compose
file and an unset or empty value fails at interpolation before any
container is created.

Verified against real Docker: the default resolves to host_ip 127.0.0.1,
and expose without a password falls back to loopback.

Breaking: Grafana is no longer reachable off-host by default. Use an SSH
tunnel, a reverse proxy, or the new expose opt-in.
@barbatos2011
barbatos2011 force-pushed the security/grafana-exposure branch from 03b6ba3 to b430dc3 Compare August 3, 2026 09:11
@kuny0707
kuny0707 merged commit fbb3658 into tronprotocol:develop Aug 3, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants