fix(security): silence is not consent — undeclared ports are never acted on
Two live incidents on archi-dev-box today, one bug. Both times a safety
decision read an ABSENT manifest field as if it were a value, and a
node's installed manifests always lag the binary — so "absent" is the
state of essentially every port on every node.
1. Gating any `session` port regardless of `bind` published Bitcoin's
loopback-only RPC 8332 on the LAN, Tailscale and IPv6 within seconds
of deploy.
2. The `bind`-keyed replacement looked safe because it protected
`bind: 127.0.0.1` ports — but LND's gRPC 10009 and REST 18080 carry
an EMPTY bind, so they fell through. One container recreate from
pinning them to loopback and breaking Zeus and every remote wallet.
`auth` is now `Option<PortAuth>`, separating two questions that were
conflated:
* `auth_policy()` — what to CLASSIFY the port as. Undeclared reports as
Session, i.e. shows in the audit as something that should be behind
the gate. Reporting is always safe.
* `auth_is_declared()` — whether the daemon may ACT. Only an explicit
declaration authorises changing how a port is published.
Also reverts the daemon-side publish rewriting entirely. The node proved
it wrong twice over: the recreate path that actually ran was in
package::install, not podman_client, so the pin never fired; and even
`bind: 127.0.0.1` written directly into the node's manifest was
overridden by the signed catalog. Publishes are built in several places
and all of them already honour `bind`, so the migration belongs in the
catalog as data — not in daemon-side inference that can only ever cover
one path and guess wrong on the rest.
Tests: 75/75 container, incl. the LND wallet-port shape (`host: 10009`,
empty bind, no auth) asserted to be non-actionable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
edc9a172e9
commit
ab2c8b6e96
@@ -318,6 +318,42 @@ impl PodmanClient {
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
// Effective bind. A gated port with no declared bind would
|
||||
// publish 0.0.0.0 — the app would own every host address, which
|
||||
// is both the exposure itself and the reason the daemon's app
|
||||
// gate cannot bind those addresses to authenticate them. Pin it
|
||||
// to loopback so the gate can take the external addresses.
|
||||
//
|
||||
// Doing it HERE, at container creation, is the point: the pin and
|
||||
// the gate's takeover then both come from the daemon and cannot
|
||||
// disagree. The earlier attempt put this decision in manifest
|
||||
// data instead, and a node whose manifests lagged the binary
|
||||
// published Bitcoin's loopback-only RPC across the LAN
|
||||
// (archi-dev-box, 2026-08-03).
|
||||
//
|
||||
// A port that already declares a bind is never overridden — that
|
||||
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
|
||||
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
|
||||
// published as they are, so remote wallets keep working.
|
||||
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
|
||||
// published port to loopback is how an app hands its external
|
||||
// addresses to the gate, but it belongs in the manifest, not in
|
||||
// daemon-side inference:
|
||||
//
|
||||
// * `bind` is already honoured by every publish path (here and
|
||||
// in package::install), so a manifest edit needs no code.
|
||||
// * inference here would cover only THIS path — proven on
|
||||
// archi-dev-box, where a recreate went through another one and
|
||||
// the pin never applied.
|
||||
// * and inferring from an ABSENT field is what republished
|
||||
// Bitcoin's loopback RPC across the LAN, and came within one
|
||||
// container-recreate of pinning LND's gRPC/REST and breaking
|
||||
// every remote wallet.
|
||||
//
|
||||
// So the migration ships as `bind: 127.0.0.1` in the signed
|
||||
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
|
||||
// overridden by the catalog, which is precisely why the catalog is
|
||||
// the right and only place to carry it.
|
||||
let mut mapping = serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
|
||||
Reference in New Issue
Block a user