From 70f6b283bd620216c3df32883f388079118bcca3 Mon Sep 17 00:00:00 2001 From: Samuel Laferriere <9342524+samlaf@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:31:50 -0400 Subject: [PATCH] fix(tee)!: restrict the operator-only node ports to the operator's IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only :22 was source-restricted; every other rule in the node NSG got source `*`, tdx-init's :8080 config listener included. That listener is unauthenticated and first-POST-wins, so whoever reached it ahead of the operator enrolled the box into *their* network — their manifest, their measurement policy, their bootnode list, so their root_key. Its "close after first deploy" comment was never achievable either: the conf dir is tmpfs, so the listener comes back for a fresh POST on every boot. OPEN_PORTS now carries a per-port source column instead of special-casing the ssh rule by name. :22 and :8080 are operator-only; the peer-facing ports (:80/:443, the enclave's :7878 root_key fetch, summit consensus, reth devp2p) keep source `*` deliberately — admission there is app-layer pubkey authentication, and an IP allowlist would break nodes that join later. :7879 is reserved and restricted ahead of the service that will use it: a summit key holder serving the node's summit pubkeys plus a TDX quote over them to whoever is assembling a founding validator set. Its restriction is permanent rather than founding-window-only, since the holder mints fresh RAM keys and reopens the quote window on every boot until the keystore exists. `source_ip_cidr` becomes `operator_ip_cidr`: the old name described the Azure field the value lands in, while the reader choosing it needs to know whose address it is — and it now gates three ports, not just ssh. The playground project keeps `source_ip_cidr`, where it means SSH access to a dev box and feeds `ssh_source_cidrs` directly. tee/README.md's footgun entry claimed the :8080 restriction already existed; it now points at where the restriction lives and says plainly that the stack setting is the whole guard. The dev stack still ships 0.0.0.0/0, which opts out of it — both comments now say so. BREAKING: the `source_ip_cidr` stack config key is now `operator_ip_cidr`. Existing seismic_node stacks fail on the next `pulumi up` with a missing-required-variable error until `pulumi config set operator_ip_cidr ` is run. --- tee/README.md | 16 +++++--- tee/pulumi/seismic_node/Pulumi.dev.yaml | 8 +++- tee/pulumi/seismic_node/README.md | 2 +- tee/pulumi/seismic_node/__main__.py | 51 ++++++++++++++++++------- 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/tee/README.md b/tee/README.md index 8b2fef55..1628842c 100644 --- a/tee/README.md +++ b/tee/README.md @@ -302,12 +302,16 @@ only gates against a second POST within the same boot. to start against an already-formatted volume or a live peer) is planned upstream in tdx-init. (The operator `seismic-tee-node configure` can't trigger it: it only joins.) -- **The `:8080` listener is unauthenticated, first-POST-wins.** - An attacker who reaches port 8080 ahead of the operator can - write a malicious bootnode list, redirecting `root_key` fetch - to an attacker endpoint. Mitigation today: cloud firewall - restricts `:8080` to the operator's source IP. Tracked - upstream in tdx-init. +- **The `:8080` listener is unauthenticated, first-POST-wins.** Whoever + reaches it ahead of the operator enrolls the box into *their* network — + their manifest, their measurement policy, their bootnode list, so their + `root_key`. The NSG keeps `:8080` (and the key-holder port) reachable only + from `operator_ip_cidr` (`pulumi/seismic_node/__main__.py`), which makes that + stack setting the whole guard: point it at the address `configure` runs + from, because `0.0.0.0/0` leaves the listener world-reachable on every boot + (the conf dir is tmpfs, so it reopens each time). An in-guest guard — + authenticating the POST rather than firewalling it — is tracked upstream in + tdx-init. ## Attestation verification (currently disabled) diff --git a/tee/pulumi/seismic_node/Pulumi.dev.yaml b/tee/pulumi/seismic_node/Pulumi.dev.yaml index 9922f561..593686c2 100644 --- a/tee/pulumi/seismic_node/Pulumi.dev.yaml +++ b/tee/pulumi/seismic_node/Pulumi.dev.yaml @@ -26,6 +26,10 @@ config: # Keep small for dev deploys: first-boot dm-integrity initialization is slow # on large disks. See README "Local testing: data-disk formatting time". seismic-tee-deploy:data_disk_size_gb: "8" - # Source IP (CIDR) allowed to SSH. Use your public IP to lock down :22. - seismic-tee-deploy:source_ip_cidr: 0.0.0.0/0 + # The operator's own IP (CIDR): the only source allowed to reach the ports + # nobody else needs — :22, tdx-init's config listener, the summit key holder. + # Set it to the public IP you administer these nodes from; the value below + # opts out and leaves the unauthenticated, first-POST-wins config listener + # open to the internet on every boot. + seismic-tee-deploy:operator_ip_cidr: 0.0.0.0/0 encryptionsalt: v1:cgAomGIsWCk=:v1:/sXIE2a9HDrpnOBC:cctZJQ9Ww80lgIXuoQaJ+RJ8ewzu2A== diff --git a/tee/pulumi/seismic_node/README.md b/tee/pulumi/seismic_node/README.md index 74e0a6d6..fb4af9e2 100644 --- a/tee/pulumi/seismic_node/README.md +++ b/tee/pulumi/seismic_node/README.md @@ -63,7 +63,7 @@ along with all compute resources. ```bash cd tee/pulumi/seismic_node pulumi whoami -v # should report Backend URL: file://~ -# Review Pulumi.dev.yaml, especially vhd_blob_url and source_ip_cidr. +# Review Pulumi.dev.yaml, especially vhd_blob_url and operator_ip_cidr. pulumi stack init dev pulumi up ``` diff --git a/tee/pulumi/seismic_node/__main__.py b/tee/pulumi/seismic_node/__main__.py index 540dec3f..3d458f32 100644 --- a/tee/pulumi/seismic_node/__main__.py +++ b/tee/pulumi/seismic_node/__main__.py @@ -50,40 +50,63 @@ def _require_str(value: str | None) -> str: dns_zone_rg = cfg.require("dns_zone_resource_group") dns_zone_name = cfg.require("dns_zone_name") dns_record_name = cfg.require("dns_record_name") -source_ip_cidr = cfg.require("source_ip_cidr") +# Source prefix for the operator-only ports (ssh, tdx-init config, key +# holder — see OPEN_PORTS). It is the whole guard on the window before the +# node is configured, so narrow it to the address the operator administers +# the node from; a `0.0.0.0/0` stack config opts out of that guard. +operator_ip_cidr = cfg.require("operator_ip_cidr") # Keep configurable for local dev/testing. First-boot dm-integrity formatting # performs a full-device initialization, so small disks are much faster for # local testing. See README for the DCedsv6/local-NVMe caveat. data_disk_size_gb = cfg.get_int("data_disk_size_gb") or 32 +# Shorthands for the port table below. +TCP = azure_native.network.SecurityRuleProtocol.TCP +UDP = azure_native.network.SecurityRuleProtocol.UDP +# Any source address — for the peer-facing ports, where the app layer (pubkey +# handshakes, enclave attestation) is the admission control, not the NSG. +ANY_SOURCE = "*" + # Standard ports the TEE node needs open. Each entry is -# (port, name, protocol); the protocol dimension lets a single port be opened -# for TCP and/or UDP independently (reth devp2p needs both). +# (port, name, protocol, source); the protocol dimension lets a single port be +# opened for TCP and/or UDP independently (reth devp2p needs both), and `source` +# is the allowed source prefix: `ANY_SOURCE` for peer-facing ports, +# `operator_ip_cidr` for the ports only the operator ever dials. # # reth's RPC (8545) and WS (8546) are deliberately NOT here: nginx terminates # TLS on 443 and reverse-proxies /rpc + /ws to them over loopback. # Same for metrics — summit's prom + reth's metrics are nginx /metrics/*. OPEN_PORTS = [ # sshd only listening in the devtools image, not in the production image. - (22, "ssh", azure_native.network.SecurityRuleProtocol.TCP), - (80, "http", azure_native.network.SecurityRuleProtocol.TCP), - (443, "https", azure_native.network.SecurityRuleProtocol.TCP), + (22, "ssh", TCP, operator_ip_cidr), + # Reserved for summit-key-holder: serves the node's summit pubkeys, plus a + # TDX quote over them, to the operator collecting a founding validator set. + # Operator-only, and permanently so — the quote window reopens on every + # boot, since the holder mints fresh RAM keys until the keystore exists. + (7879, "summit-key-holder", TCP, operator_ip_cidr), + # tdx-init's config listener: unauthenticated and first-POST-wins, so + # whoever reaches it before the operator enrolls the box into *their* + # network — their manifest, their measurement policy, their root_key. + # Operator-only, and permanently so — the conf dir is tmpfs, so the + # listener comes back up for a fresh POST on every boot. + (8080, "tdx-init-config", TCP, operator_ip_cidr), + (80, "http", TCP, ANY_SOURCE), + (443, "https", TCP, ANY_SOURCE), # Enclave currently exposes too many things on this port: get_attestation_evidence, # get_purpose_keys, getWrappedRootKey. We will eventually split it. - (7878, "enclave", azure_native.network.SecurityRuleProtocol.TCP), - # one-shot listener; close after first deploy - (8080, "tdx-init-config", azure_native.network.SecurityRuleProtocol.TCP), + # Peer-facing: a joining node's root_key fetch dials this on a cohort node. + (7878, "enclave", TCP, ANY_SOURCE), # Summit consensus P2P (commonware-p2p, TCP). Left open like # the other service ports — commonware authenticates peers by pubkey (only # the validator set completes the handshake), so admission is app-layer, and # a static IP allowlist would break validators that join dynamically later. - (18551, "summit-consensus", azure_native.network.SecurityRuleProtocol.TCP), + (18551, "summit-consensus", TCP, ANY_SOURCE), # reth devp2p: RLPx peering over TCP, node discovery (discv4/v5) over UDP. # Same rationale as summit above — peers are authenticated at the app layer, # so the port is left open rather than IP-allowlisted. - (30303, "reth-p2p", azure_native.network.SecurityRuleProtocol.TCP), - (30303, "reth-p2p-udp", azure_native.network.SecurityRuleProtocol.UDP), + (30303, "reth-p2p", TCP, ANY_SOURCE), + (30303, "reth-p2p-udp", UDP, ANY_SOURCE), ] # -------------------------------------------------------------------- @@ -123,12 +146,12 @@ def _require_str(value: str | None) -> str: direction=azure_native.network.SecurityRuleDirection.INBOUND, access=azure_native.network.SecurityRuleAccess.ALLOW, protocol=protocol, - source_address_prefix=(source_ip_cidr if name == "ssh" else "*"), + source_address_prefix=source, source_port_range="*", destination_address_prefix="*", destination_port_range=str(port), ) - for i, (port, name, protocol) in enumerate(OPEN_PORTS) + for i, (port, name, protocol, source) in enumerate(OPEN_PORTS) ] nsg = azure_native.network.NetworkSecurityGroup(