From 01f424e197bf8732ce3404df7af6636e44ae65a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Compagnon?= Date: Fri, 18 Sep 2026 04:08:29 +0200 Subject: [PATCH 1/5] fix(hub): arm the credentials guard on a non-loopback public URL The startup guard only ever looked at the bind address, so a hub on 127.0.0.1 exposed through a Cloudflare Tunnel, ngrok or a local reverse proxy started without a word: no agent key, so agent_ok() passed every caller, and no operator token, so every browser was graded as operator. /register, /peers and /watch-ticket/redeem were open to whoever reached the tunnel. _mount_mcp_http already read a declared public URL as "this hub is remote" when deciding what watch_command hands an agent. The credentials gate now reads it the same way: a --public-url naming a non-loopback host demands both --operator-token and --agent-key, with --allow-insecure-bind as the same escape hatch the bind guard offers. A loopback public URL is just a nicer address for this machine and arms nothing. _insecure_public_url_message carries the refusal; the doors inventory and the openssl snippet both messages share move into _doors_block so the two cannot drift apart on a flag rename. --- src/caucus/hub.py | 156 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 128 insertions(+), 28 deletions(-) diff --git a/src/caucus/hub.py b/src/caucus/hub.py index ac64c91..d683ea2 100644 --- a/src/caucus/hub.py +++ b/src/caucus/hub.py @@ -3084,6 +3084,40 @@ def _collect_allowed_hosts(cli: list[str] | None, port: int) -> list[str]: return hosts +def _doors_block(*, operator: bool, agent: bool, requirement: str) -> str: + """Render the two-doors inventory shared by both credential refusals. + + The flag names, the environment variables and the ``openssl`` lines have to + read identically whether the hub was refused for its bind address or for the + public URL it advertises; spelling them twice is how one of the two ends up + naming a flag that was renamed in the other. + + Args: + operator: Whether an operator token is already configured. + agent: Whether an agent key is already configured. + requirement: The sentence introducing the generation snippet, which + names *why* both credentials are demanded in this particular case. + + Returns: + The doors inventory, the requirement sentence and the two ``export`` + lines, ending with a newline. + """ + mark = {True: "already set", False: "MISSING"} + return ( + " agent door /register and /mcp - join the room, read everything\n" + " said in it\n" + f" --agent-key KEY (env CAUCUS_AGENT_KEY): {mark[agent]}\n" + " operator door /ui - pause, stop, kick, read the whole transcript\n" + " --operator-token TOKEN (env CAUCUS_OPERATOR_TOKEN):" + f" {mark[operator]}\n" + "\n" + f"{requirement} Generate and set them:\n" + "\n" + ' export CAUCUS_AGENT_KEY="$(openssl rand -hex 24)"\n' + ' export CAUCUS_OPERATOR_TOKEN="$(openssl rand -hex 24)"\n' + ) + + def _insecure_bind_message( host: str, *, @@ -3134,18 +3168,12 @@ def _insecure_bind_message( "from other machines, and by default neither of its two doors is locked\n" "nor does it know the address to hand those machines.\n" "\n" - " agent door /register and /mcp - join the room, read everything\n" - " said in it\n" - f" --agent-key KEY (env CAUCUS_AGENT_KEY): {mark[agent]}\n" - " operator door /ui - pause, stop, kick, read the whole transcript\n" - " --operator-token TOKEN (env CAUCUS_OPERATOR_TOKEN):" - f" {mark[operator]}\n" - "\n" - "Both are required on a non-loopback bind. Generate and set them:\n" - "\n" - ' export CAUCUS_AGENT_KEY="$(openssl rand -hex 24)"\n' - ' export CAUCUS_OPERATOR_TOKEN="$(openssl rand -hex 24)"\n' - f" caucus-hub --host {host}" + + _doors_block( + operator=operator, + agent=agent, + requirement="Both are required on a non-loopback bind.", + ) + + f" caucus-hub --host {host}" + (" --public-url https://hub.example.net\n" if wildcard else "\n") + reach + "\n" @@ -3155,6 +3183,52 @@ def _insecure_bind_message( ) +def _insecure_public_url_message( + public_url: str, + *, + operator: bool, + agent: bool, +) -> str: + """Compose the refusal shown when an advertised hub is under-configured. + + The sibling of :func:`_insecure_bind_message` for the deployment where the + socket is *not* the exposure: the hub binds to loopback and something in + front of it (a tunnel, a reverse proxy, a port forward) carries the outside + world in. Nothing about the bind address betrays that, so the only signal + the hub has is the operator declaring a public URL that is not loopback -- + and that declaration has to be taken as seriously as a non-loopback bind, + because the doors behind it are exactly as open. + + Args: + public_url: The non-loopback base URL the operator asked to advertise. + operator: Whether an operator token is already configured. + agent: Whether an agent key is already configured. + + Returns: + The multi-line refusal text, ready to hand to ``parser.error``. + """ + return ( + f"refusing to start: --public-url {public_url} says agents on other\n" + "machines dial this hub, and by default neither of its two doors is\n" + "locked. The bind is loopback, so the socket is not the exposure --\n" + "whatever sits in front of it is, and anything reaching that front\n" + "reaches both of these:\n" + "\n" + + _doors_block( + operator=operator, + agent=agent, + requirement="Both are required once the hub is advertised off-box.", + ) + + f" caucus-hub --host 127.0.0.1 --public-url {public_url}\n" + "\n" + "Not what you meant? A loopback public URL (http://localhost:8765) is\n" + "just a nicer address for this machine and needs none of this.\n" + "Meant it, behind something that already authenticates? " + "--allow-insecure-bind\n" + "starts anyway, with both doors open." + ) + + def _mount_mcp_http( *, host: str, @@ -3347,9 +3421,10 @@ def main() -> None: "--allow-insecure-bind", action="store_true", help=( - "start on a non-loopback address without --operator-token and " - "--agent-key. Both doors stay open to anything that can reach the " - "port; only for a network that already authenticates" + "start without --operator-token and --agent-key on a non-loopback " + "address, or behind a non-loopback --public-url. Both doors stay " + "open to anything that can reach the hub; only for a network that " + "already authenticates" ), ) parser.add_argument( @@ -3420,24 +3495,49 @@ def main() -> None: # anything dials, so without --public-url the hub would advertise the # 127.0.0.1 rewrite and hand every remote agent a watcher command pointing # back at its own machine. Fix that at the source rather than downstream. + # A loopback bind is not proof the hub is unreachable: behind a tunnel or a + # reverse proxy the socket stays on 127.0.0.1 while the world dials the + # front. The one thing the operator tells us in that shape is --public-url, + # and _mount_mcp_http already reads a non-loopback one as "remote" (see its + # `remote=` argument). The credentials gate has to read it the same way, or + # the exact deployment that most needs both doors locked is the one that + # starts without either. A loopback public URL is just a prettier address + # for this machine and arms nothing. wildcard = args.host in _WILDCARD_HOSTS + # Parsed defensively: the value is still unvalidated here, and one that + # urlparse finds no hostname in must fall through to validate_public_url + # below rather than be read as an exposure. A URL that *does* name a + # non-loopback host but fails validation for another reason (a path, say) + # hits this gate first, which is the right order: missing credentials on an + # advertised hub outrank the shape of the address being advertised. + advertised_host = urlparse(public_url).hostname if public_url else None + advertised_remote = ( + public_url + if advertised_host is not None and not is_loopback_host(advertised_host) + else None + ) under_configured = not (args.operator_token and args.agent_key) or ( wildcard and not public_url ) - if ( - not is_loopback_host(args.host) - and not args.allow_insecure_bind - and under_configured - ): - parser.error( - _insecure_bind_message( - args.host, - operator=bool(args.operator_token), - agent=bool(args.agent_key), - public_url=bool(public_url), - wildcard=wildcard, + if not args.allow_insecure_bind and under_configured: + if not is_loopback_host(args.host): + parser.error( + _insecure_bind_message( + args.host, + operator=bool(args.operator_token), + agent=bool(args.agent_key), + public_url=bool(public_url), + wildcard=wildcard, + ) + ) + elif advertised_remote is not None: + parser.error( + _insecure_public_url_message( + advertised_remote, + operator=bool(args.operator_token), + agent=bool(args.agent_key), + ) ) - ) if public_url is not None: try: From 82b1392249bec3fe8d3aab101195194838507c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Compagnon?= Date: Fri, 18 Sep 2026 04:08:37 +0200 Subject: [PATCH 2/5] fix(setup-service): refuse a loopback bind advertised off-box check_bind returned early on any loopback host, so the installer happily wrote a unit for --host 127.0.0.1 --public-url https://hub.example.net with neither credential. The hub now refuses that configuration at startup, which would turn a clean install into a unit that loads and immediately exits. Same predicate as the hub's, same reasoning: a public URL naming a non-loopback host is the operator saying agents on other machines dial this hub, whatever the socket is bound to. --- src/caucus/setup_service.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/caucus/setup_service.py b/src/caucus/setup_service.py index 2c0be99..1ea62ab 100644 --- a/src/caucus/setup_service.py +++ b/src/caucus/setup_service.py @@ -44,6 +44,7 @@ import urllib.request from pathlib import Path from typing import Any, Literal +from urllib.parse import urlparse from .urlguard import is_loopback_host, validate_public_url @@ -377,6 +378,12 @@ def check_bind( operator finds out while installing, not from a unit that loads and then exits. + A loopback bind behind a non-loopback ``--public-url`` is the same exposure + reached a different way -- a tunnel or a reverse proxy carries the world to + a socket that never left this machine -- and ``caucus-hub`` refuses it at + startup for that reason. Refusing it here too is what keeps the installer + from writing a unit that cannot start. + Args: host: Address the hub would bind to. operator_token: Token that would gate operator access, if any. @@ -385,14 +392,32 @@ def check_bind( public_url: Base URL agents would be told to reach the hub at, if any. Raises: - SetupError: For a non-loopback host missing a credential, or a wildcard - host with no public URL. + SetupError: For a non-loopback host missing a credential, a wildcard + host with no public URL, or a loopback host advertised under a + non-loopback public URL without both credentials. """ - if is_loopback_host(host): + advertised_host = urlparse(public_url).hostname if public_url else None + advertised_remote = advertised_host is not None and not is_loopback_host( + advertised_host + ) + if is_loopback_host(host) and not advertised_remote: return if operator_token and agent_key and (public_url or host not in WILDCARD_HOSTS): return wildcard = host in WILDCARD_HOSTS + if is_loopback_host(host): + raise SetupError( + f"refusing to advertise {public_url} without --operator-token and\n" + "--agent-key. The bind stays on loopback, but a public URL says\n" + "agents on other machines dial this hub, and whatever carries them\n" + "in reaches a dashboard that grants full operator rights to any\n" + "browser and a /register any client can walk through.\n" + "Drop --public-url, or run:\n" + f" caucus-setup-service --host {host} \\\n" + ' --operator-token "$(openssl rand -hex 24)" \\\n' + ' --agent-key "$(openssl rand -hex 24)" \\\n' + f" --public-url {public_url}" + ) raise SetupError( f"refusing to bind {host} without --operator-token and --agent-key" + (" and --public-url.\n" if wildcard else ".\n") From eff9f925c3e25da3c24e8f9c5ec9811004247ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Compagnon?= Date: Fri, 18 Sep 2026 04:08:37 +0200 Subject: [PATCH 3/5] test: cover the credentials guard on an advertised loopback hub Both halves of the new gate: the hub CLI (refusal, which credential is named as missing, both credentials starting, --allow-insecure-bind, a loopback public URL arming nothing, the CAUCUS_PUBLIC_URL path, and a public URL with no parseable host still falling through to validate_public_url) and check_bind in the installer. test_an_invalid_public_url_refuses_at_startup now passes both credentials: its URL names a non-loopback host, so without them the new gate answers first and the test no longer exercises validate_public_url. --- tests/test_remote_hub.py | 114 +++++++++++++++++++++++++++++++++++- tests/test_setup_service.py | 25 ++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/tests/test_remote_hub.py b/tests/test_remote_hub.py index fcd11a8..bfaabcc 100644 --- a/tests/test_remote_hub.py +++ b/tests/test_remote_hub.py @@ -772,7 +772,117 @@ def test_a_blank_public_url_is_not_an_advertised_address(run_main: Any) -> None: def test_an_invalid_public_url_refuses_at_startup( run_main: Any, capsys: pytest.CaptureFixture[str] ) -> None: - """A public URL with a path would build unreachable addresses; refuse it.""" + """A public URL with a path would build unreachable addresses; refuse it. + + Both credentials are supplied so the loopback+public-url credentials guard + (below) does not intercept first: this test is about ``validate_public_url`` + rejecting the path, not about the credentials gate. + """ with pytest.raises(SystemExit): - run_main("--public-url", "https://hub.example.net/caucus") + run_main( + "--public-url", + "https://hub.example.net/caucus", + "--operator-token", + "op123", + "--agent-key", + "key123", + ) assert "bare origin" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# A loopback bind advertised under a non-loopback --public-url +# --------------------------------------------------------------------------- + + +def test_loopback_bind_with_remote_public_url_without_credentials_refuses( + run_main: Any, capsys: pytest.CaptureFixture[str] +) -> None: + """A loopback socket behind a public URL is exactly as exposed as a wide bind. + + The bind itself never leaves this machine, but declaring a non-loopback + ``--public-url`` says a tunnel or reverse proxy carries the outside world + in, and ``_mount_mcp_http`` already treats that as ``remote=True``. The + credentials gate has to read it the same way. + """ + with pytest.raises(SystemExit) as excinfo: + run_main("--public-url", "https://hub.example.net") + assert excinfo.value.code == 2 + message = capsys.readouterr().err + for needle in ( + "--public-url", + "https://hub.example.net", + "--agent-key", + "CAUCUS_AGENT_KEY", + "--operator-token", + "CAUCUS_OPERATOR_TOKEN", + ): + assert needle in message, f"refusal does not mention {needle}" + + +def test_loopback_bind_with_remote_public_url_names_which_credential_is_missing( + run_main: Any, capsys: pytest.CaptureFixture[str] +) -> None: + """Half-configured is the common case here too; say which half is done.""" + with pytest.raises(SystemExit): + run_main("--public-url", "https://hub.example.net", "--agent-key", "key123") + message = capsys.readouterr().err + assert "--agent-key KEY (env CAUCUS_AGENT_KEY): already set" in message + assert "--operator-token TOKEN (env CAUCUS_OPERATOR_TOKEN): MISSING" in message + + +def test_loopback_bind_with_remote_public_url_and_both_credentials_starts( + run_main: Any, +) -> None: + """Both doors locked, so declaring the hub reachable elsewhere is allowed.""" + started = run_main( + "--public-url", + "https://hub.example.net", + "--operator-token", + "op123", + "--agent-key", + "key123", + ) + assert started == [("127.0.0.1", 8765)] + + +def test_loopback_bind_with_remote_public_url_allow_insecure_bind_starts( + run_main: Any, +) -> None: + """The same escape hatch as the bind guard covers the public-url guard.""" + started = run_main( + "--public-url", "https://hub.example.net", "--allow-insecure-bind" + ) + assert started == [("127.0.0.1", 8765)] + + +def test_loopback_public_url_arms_nothing(run_main: Any) -> None: + """localhost is just a prettier address for this machine, not an exposure.""" + started = run_main("--public-url", "http://localhost:8765") + assert started == [("127.0.0.1", 8765)] + + +def test_remote_public_url_from_the_environment_arms_the_guard( + run_main: Any, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """``CAUCUS_PUBLIC_URL`` is read the same way ``--public-url`` is.""" + monkeypatch.setenv("CAUCUS_PUBLIC_URL", "https://hub.example.net") + with pytest.raises(SystemExit): + run_main() + assert "https://hub.example.net" in capsys.readouterr().err + + +def test_malformed_public_url_on_a_loopback_bind_is_not_caught_by_the_new_guard( + run_main: Any, capsys: pytest.CaptureFixture[str] +) -> None: + """No parseable hostname arms nothing here; ``validate_public_url`` is the error. + + ``urlparse("garbage").hostname`` is ``None``, so the credentials guard sees + no advertised host at all and stays quiet, leaving the more useful + ``validate_public_url`` error as what the operator sees. + """ + with pytest.raises(SystemExit): + run_main("--public-url", "garbage") + message = capsys.readouterr().err + assert "unsupported public URL scheme" in message + assert "refusing to start" not in message diff --git a/tests/test_setup_service.py b/tests/test_setup_service.py index 0fea355..f129ae0 100644 --- a/tests/test_setup_service.py +++ b/tests/test_setup_service.py @@ -143,6 +143,31 @@ def test_check_bind_treats_the_whole_loopback_range_as_local() -> None: setup_service.check_bind("127.0.0.2", None, None) +def test_check_bind_loopback_host_with_remote_public_url_raises() -> None: + """A tunnel or reverse proxy in front of a loopback bind is the same exposure.""" + with pytest.raises(setup_service.SetupError, match="refusing to advertise"): + setup_service.check_bind( + "127.0.0.1", None, None, "https://hub.example.net" + ) + + +def test_check_bind_loopback_with_remote_url_and_both_credentials_is_ok() -> None: + """Both doors gated, so advertising the loopback bind elsewhere is fine.""" + setup_service.check_bind( + "127.0.0.1", "sometoken123", "somekey123", "https://hub.example.net" + ) + + +def test_check_bind_loopback_host_with_loopback_public_url_is_ok() -> None: + """A loopback ``public_url`` is just a nicer address, not an exposure.""" + setup_service.check_bind("127.0.0.1", None, None, "http://localhost:8765") + + +def test_check_bind_loopback_host_without_public_url_is_unchanged() -> None: + """No advertised address at all keeps the original, credential-free posture.""" + setup_service.check_bind("127.0.0.1", None, None, None) + + def test_validate_tokens_rejects_a_hostile_agent_key() -> None: """The agent key rides the same plist/env plumbing, so same charset bound.""" with pytest.raises(setup_service.SetupError) as excinfo: From 462a424fb53f886b4ef05d5c4683694174e23352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Compagnon?= Date: Fri, 18 Sep 2026 04:08:42 +0200 Subject: [PATCH 4/5] docs(remote-hub): correct the reverse-proxy section on credentials The TLS section told operators that binding to loopback behind a proxy "sidesteps its own non-loopback-bind refusal entirely" and that the two credentials were worth setting anyway. They are now required, for exactly the reason that paragraph described. The flags table row for --allow-insecure-bind names the public-url case too. --- docs/remote-hub.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/remote-hub.md b/docs/remote-hub.md index 251ea04..695b3c0 100644 --- a/docs/remote-hub.md +++ b/docs/remote-hub.md @@ -142,7 +142,7 @@ Hub-side (`caucus-hub`): | `--allowed-origin ORIGIN` (repeatable) | `CAUCUS_ALLOWED_ORIGINS` (comma-separated) | loopback only | A browser console opened from a non-loopback origin gets its `/ui` handshake closed with WebSocket code 1008, and its `/mcp` CORS preflight goes unanswered | | `--public-url URL` | `CAUCUS_PUBLIC_URL` | unset (advertises the bind address) | `watch_command` and every tool's `hub` field hand a remote agent a `127.0.0.1` address it cannot reach | | `--mcp-http` / `--no-mcp-http` | `CAUCUS_MCP_HTTP` | on for a loopback bind, off otherwise | `/mcp` is not mounted at all on a non-loopback bind unless this is passed explicitly | -| `--allow-insecure-bind` | (none) | off | A non-loopback `--host` refuses to start unless both `--operator-token` and `--agent-key` are already set, and a wildcard bind (`0.0.0.0` or `::`) also needs `--public-url` | +| `--allow-insecure-bind` | (none) | off | A non-loopback `--host`, or a non-loopback `--public-url` on any bind, refuses to start unless both `--operator-token` and `--agent-key` are already set, and a wildcard bind (`0.0.0.0` or `::`) also needs `--public-url`. A loopback `--public-url` (`http://localhost:8765`) arms nothing | | `--client-ttl SECONDS` | (none) | `300` | The idle reaper drops a peer sooner or later than expected; a WAN agent slower than this to re-poll loses its slot mid-conversation | Throughout, "loopback" is one definition shared by the whole package @@ -207,10 +207,12 @@ wall. `caucus-hub`'s `uvicorn.run(...)` call takes no TLS arguments: there is no `--tls-cert` flag to reach for. Terminate TLS in front of it instead. Because -the reverse proxy is what faces the network, the hub itself can stay bound -to loopback, which sidesteps its own non-loopback-bind refusal entirely; set -`--agent-key` and `--operator-token` anyway, since the hub is reachable from -the network the moment the proxy is: +the reverse proxy is what faces the network, the hub itself stays bound to +loopback — but it still demands `--agent-key` and `--operator-token`, because +`--public-url https://hub.example.net` is you telling it that agents on other +machines dial it. That is the same exposure a non-loopback bind is, reached +through the proxy instead of the socket, and the hub refuses to start without +both credentials: ```bash export CAUCUS_AGENT_KEY="$(openssl rand -hex 24)" From 26ae4380cfc523506ad0302e946322cf2c5feb09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Compagnon?= Date: Fri, 18 Sep 2026 04:08:43 +0200 Subject: [PATCH 5/5] docs(changelog): record the public-url credentials guard under Unreleased --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01240cf..8ad65c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,20 @@ and rename that heading to the version when you cut the release. ### Security +- **A non-loopback `--public-url` now arms the same credentials guard a + non-loopback bind does.** The guard only ever looked at the bind address, so + a hub on `127.0.0.1` exposed through a Cloudflare Tunnel, ngrok or a local + reverse proxy came up without a word when started with + `--public-url https://hub.example.net` and no `--agent-key`: `/register`, + `/peers` and `/watch-ticket/redeem` open to anyone who reached the tunnel, + and the dashboard granting operator rights to any browser that did. The hub + already read that URL as "this is remote" when deciding what `watch_command` + hands a remote agent; the credentials gate now agrees, and refuses to start + without both `--operator-token` and `--agent-key`. A loopback public URL + (`http://localhost:8765`) is just a nicer address for this machine and arms + nothing, and `--allow-insecure-bind` still starts anyway. `check_bind` in + `caucus-setup-service` applies the same rule, so the installer refuses the + configuration instead of writing a unit that cannot start. - **`/peers`, `/channels`, `/forms` and `/ping` now require the shared agent key when one is configured** (an operator or observer token is accepted too). On a keyed non-loopback hub, these endpoints previously handed anyone