diff --git a/src/tether/cli.py b/src/tether/cli.py index dca60bd..e2d7a22 100644 --- a/src/tether/cli.py +++ b/src/tether/cli.py @@ -1717,11 +1717,39 @@ def guard( raise typer.Exit(1) +_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} + + +def _warn_insecure_bind(host: str, api_key: str) -> None: + """Loudly warn when binding a non-loopback interface with no api_key. + + The default host is 127.0.0.1 (localhost only). If an operator explicitly + binds 0.0.0.0 (or any non-loopback address) WITHOUT --api-key, the robot is + drivable by anyone on the network — surface that, don't let it pass silent. + """ + if host in _LOOPBACK_HOSTS: + return + if api_key: + return + err_console.print( + f"[bold yellow]⚠ SECURITY:[/bold yellow] serving on [bold]{host}[/bold] " + f"(reachable from the network) with [bold]no --api-key[/bold]. Anyone who " + f"can reach this host can send /act commands to the robot.\n" + f" Set [cyan]--api-key [/cyan], or bind localhost only with " + f"[cyan]--host 127.0.0.1[/cyan] (the default).", + style=None, markup=True, + ) + + @app.command() def serve( export_dir: str = typer.Argument(help="Path to exported model directory"), port: int = typer.Option(8000, help="Server port"), - host: str = typer.Option("0.0.0.0", help="Server host"), + host: str = typer.Option( + "127.0.0.1", + help="Server host. Defaults to localhost-only; pass 0.0.0.0 to expose " + "on the network (use --api-key when you do).", + ), transport: str = typer.Option( "http", "--transport", @@ -2650,6 +2678,8 @@ def _run_mcp_http(): f"(streamable-http)[/bold green]" ) + _warn_insecure_bind(host, api_key) + if transport == "zmq": console.print("[bold green]Starting ZMQ server...[/bold green]") from tether.runtime.transports.zmq.factory import create_zmq_server @@ -4122,7 +4152,11 @@ def go( help="Where to cache weights. Default: ~/.cache/tether/models//", ), port: int = typer.Option(8000, "--port", help="HTTP port for /act + /health"), - host: str = typer.Option("0.0.0.0", "--host"), + host: str = typer.Option( + "127.0.0.1", "--host", + help="Server host. Defaults to localhost-only; pass 0.0.0.0 to expose " + "on the network (use --api-key when you do).", + ), api_key: str = typer.Option("", "--api-key", help="If set, /act requires X-Tether-Key header"), dry_run: bool = typer.Option( False, @@ -4372,6 +4406,7 @@ def go( embodiment_config=embodiment_cfg, api_key=api_key or None, ) + _warn_insecure_bind(host, api_key) import uvicorn uvicorn.run(app_instance, host=host, port=port, log_level="info") diff --git a/tests/test_serve_bind_security.py b/tests/test_serve_bind_security.py new file mode 100644 index 0000000..9d4e161 --- /dev/null +++ b/tests/test_serve_bind_security.py @@ -0,0 +1,47 @@ +"""serve/go bind to localhost by default + warn on insecure public binds.""" +from __future__ import annotations + +import inspect +from unittest.mock import MagicMock + +from tether import cli + + +def _default(func, param: str): + p = inspect.signature(func).parameters[param].default + # typer.Option(...) returns an OptionInfo whose .default holds the value. + return getattr(p, "default", p) + + +def test_serve_and_go_default_host_is_localhost(): + assert _default(cli.serve, "host") == "127.0.0.1" + assert _default(cli.go, "host") == "127.0.0.1" + + +def test_warns_on_public_bind_without_api_key(monkeypatch): + mock = MagicMock() + monkeypatch.setattr(cli, "err_console", mock) + cli._warn_insecure_bind("0.0.0.0", "") + assert mock.print.called + + +def test_no_warn_on_loopback(monkeypatch): + for host in ("127.0.0.1", "localhost", "::1"): + mock = MagicMock() + monkeypatch.setattr(cli, "err_console", mock) + cli._warn_insecure_bind(host, "") + assert not mock.print.called, host + + +def test_no_warn_when_api_key_set(monkeypatch): + mock = MagicMock() + monkeypatch.setattr(cli, "err_console", mock) + cli._warn_insecure_bind("0.0.0.0", "a-secret-key") + assert not mock.print.called + + +def test_warns_on_arbitrary_public_ip(monkeypatch): + mock = MagicMock() + monkeypatch.setattr(cli, "err_console", mock) + cli._warn_insecure_bind("192.168.1.10", "") + assert mock.print.called