From 067887ceafe39f5c10a0cebbfdd227acb30220da Mon Sep 17 00:00:00 2001 From: Lesnak1 Date: Sun, 11 Oct 2026 23:40:19 +0300 Subject: [PATCH] fix(rpc): keep public node RPC loopback-only --- .github/workflows/lint.yml | 2 ++ .gitignore | 4 ++++ README.md | 4 +++- mainnet/rpc/.env.example | 6 +++--- mainnet/rpc/docker-compose.yml | 4 +++- testnet/rpc/.env.example | 6 +++--- testnet/rpc/docker-compose.yml | 5 +++-- tests/test_rpc_loopback.py | 36 ++++++++++++++++++++++++++++++++++ 8 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 tests/test_rpc_loopback.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8590100..085cb0a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,4 +13,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" + - name: Check public RPC bind addresses + run: python -m unittest discover --start-directory tests --verbose - uses: pre-commit/action@v3.0.1 diff --git a/.gitignore b/.gitignore index 19c6e42..144513d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ origin.key # Chain-spec generation intermediates **/spec-plain.json **/spec-customized.json + +# Python test artifacts +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index d2a72e1..27c16da 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,9 @@ cp /path/to/origin.key origin.key docker compose up -d # builds the Caddy image from ../../common on first run ``` -Caddy proxies HTTPS/WSS on your domain to the node on `localhost:9944`. +Caddy proxies HTTPS/WSS on your domain to the node on `localhost:9944`. The +node RPC binds to loopback (`--rpc-external` is intentionally omitted), leaving +Caddy as the only network-facing RPC entry point. ### TLS diff --git a/mainnet/rpc/.env.example b/mainnet/rpc/.env.example index aa0df39..7d49d5b 100644 --- a/mainnet/rpc/.env.example +++ b/mainnet/rpc/.env.example @@ -66,9 +66,9 @@ P2P_PORT=30333 # both read this one variable, so they cannot drift apart. # # Internal only: public traffic arrives on 443 at RPC_DOMAIN and Caddy forwards -# it here, so changing this does NOT change the public endpoint. Keep it -# firewalled (`ufw deny `) — clients must go through Caddy, which is what -# applies the rate limits. +# it here, so changing this does NOT change the public endpoint. The node binds +# to loopback because --rpc-external is omitted; keep the firewall deny as +# defense in depth so clients go through Caddy and its rate limits. RPC_PORT=9944 # With host networking this binds on the public NIC — firewall it to the diff --git a/mainnet/rpc/docker-compose.yml b/mainnet/rpc/docker-compose.yml index 669e525..4f0f558 100644 --- a/mainnet/rpc/docker-compose.yml +++ b/mainnet/rpc/docker-compose.yml @@ -3,6 +3,9 @@ # Same shape as testnet/rpc, pointed at the mainnet image and chain spec: the # node behind Caddy for TLS, `--rpc-methods Safe` because the endpoint is # public, archive pruning so historical queries answer. +# Caddy shares the host network and reaches the node over loopback; keep +# --rpc-external absent so the RPC port cannot bypass Caddy if the firewall +# is misconfigured. # # The image tag differs on purpose (`mainnet-latest`): a stable `vX.Y.Z` tag # publishes it, a prerelease never does, so an rc can never reach mainnet by @@ -27,7 +30,6 @@ services: ${TELEMETRY_URL---telemetry-url "wss://telemetry.orbinum.io/submit/ 0"} --rpc-port ${RPC_PORT:-9944} --rpc-cors all - --rpc-external --rpc-methods Safe --rpc-max-connections 5000 --state-pruning archive diff --git a/testnet/rpc/.env.example b/testnet/rpc/.env.example index 5a539b4..5b07ba7 100644 --- a/testnet/rpc/.env.example +++ b/testnet/rpc/.env.example @@ -74,9 +74,9 @@ P2P_PORT=30333 # both read this one variable, so they cannot drift apart. # # Internal only: public traffic arrives on 443 at RPC_DOMAIN and Caddy forwards -# it here, so changing this does NOT change the public endpoint. Keep it -# firewalled (`ufw deny `) — clients must go through Caddy, which is what -# applies the rate limits. +# it here, so changing this does NOT change the public endpoint. The node binds +# to loopback because --rpc-external is omitted; keep the firewall deny as +# defense in depth so clients go through Caddy and its rate limits. RPC_PORT=9944 # With host networking this binds on the public NIC — firewall it to the diff --git a/testnet/rpc/docker-compose.yml b/testnet/rpc/docker-compose.yml index a07f510..9212683 100644 --- a/testnet/rpc/docker-compose.yml +++ b/testnet/rpc/docker-compose.yml @@ -3,7 +3,9 @@ # What the public endpoint (rpc-1, rpc-2, …) serves: wallets, the explorer and # anyone querying the chain. Caddy terminates HTTPS/WSS in front of it with a # Cloudflare Origin Certificate, so the node itself speaks plain HTTP on -# RPC_PORT and is never exposed directly. +# RPC_PORT and is never exposed directly. Keep --rpc-external absent: Caddy +# shares the host network and reaches the node over loopback, so firewall +# mistakes cannot expose an RPC endpoint that bypasses Caddy's rate limits. # # `--rpc-methods Safe`: a public endpoint must not offer author_rotateKeys, # offchain storage or the other unsafe namespaces. Archive on both axes @@ -32,7 +34,6 @@ services: ${TELEMETRY_URL---telemetry-url "wss://telemetry.orbinum.io/submit/ 0"} --rpc-port ${RPC_PORT:-9944} --rpc-cors all - --rpc-external --rpc-methods Safe --rpc-max-connections 5000 --in-peers 64 diff --git a/tests/test_rpc_loopback.py b/tests/test_rpc_loopback.py new file mode 100644 index 0000000..864c28c --- /dev/null +++ b/tests/test_rpc_loopback.py @@ -0,0 +1,36 @@ +"""Guard the public RPC stacks against bypassing their Caddy proxy.""" + +from pathlib import Path +import re +import unittest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +RPC_COMPOSE_FILES = ( + Path("testnet/rpc/docker-compose.yml"), + Path("mainnet/rpc/docker-compose.yml"), +) +EXTERNAL_RPC_FLAG = re.compile(r"(?m)^\s+--(?:unsafe-)?rpc-external(?:\s|$)") + + +class PublicRpcLoopbackTests(unittest.TestCase): + def test_rpc_nodes_do_not_bind_all_interfaces(self) -> None: + for relative_path in RPC_COMPOSE_FILES: + with self.subTest(network=relative_path.parts[0]): + compose_file = REPOSITORY_ROOT / relative_path + compose = compose_file.read_text(encoding="utf-8") + service_start = compose.index(" orbinum-rpc-node:\n") + service_end = compose.find("\n caddy:\n", service_start) + self.assertNotEqual(service_end, -1, "expected a Caddy service") + rpc_service = compose[service_start:service_end] + + self.assertRegex(rpc_service, r"(?m)^ network_mode: host$") + self.assertNotRegex( + rpc_service, + EXTERNAL_RPC_FLAG, + "public RPC must stay on loopback so it cannot bypass Caddy", + ) + + +if __name__ == "__main__": + unittest.main()