diff --git a/README.md b/README.md index b04ecb2ed..956767dc6 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ python -m skillopt_webui.app | `--host` | `127.0.0.1` | Bind address | | `--share` | off | Create a public Gradio share link | -The default host is loopback-only; pass `--host 0.0.0.0` to expose the dashboard. +The default host is loopback-only. Exposing the dashboard (`--host 0.0.0.0` or `--share`) requires +`--auth-user`/`--auth-pass` (or `SKILLOPT_WEBUI_USER`/`SKILLOPT_WEBUI_PASS`). --- diff --git a/docs/guide/first-experiment.md b/docs/guide/first-experiment.md index 1cda96c13..e5eafe35d 100644 --- a/docs/guide/first-experiment.md +++ b/docs/guide/first-experiment.md @@ -127,8 +127,8 @@ python -m skillopt_webui.app ``` Then open `http://localhost:7860` in your browser to configure parameters and -launch training. The default host is `0.0.0.0`; pass `--host 127.0.0.1` for a -local-only dashboard. +launch training. The default host is `127.0.0.1`; exposing it with `--host 0.0.0.0` +requires `--auth-user`/`--auth-pass`. ## Next Steps diff --git a/docs/reference/cli.md b/docs/reference/cli.md index fdf8babf8..773aa0037 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -447,4 +447,5 @@ python -m skillopt_webui.app [--port PORT] [--share] | `--host` | `127.0.0.1` | Server bind address | | `--share` | false | Create public Gradio link | -The default host binds loopback only; pass `--host 0.0.0.0` to expose the dashboard. +The default host binds loopback only. Exposing the dashboard (`--host 0.0.0.0` or `--share`) requires +`--auth-user`/`--auth-pass` (or `SKILLOPT_WEBUI_USER`/`SKILLOPT_WEBUI_PASS`). diff --git a/skillopt_webui/app.py b/skillopt_webui/app.py index 9e7e0a5a7..bf2599643 100644 --- a/skillopt_webui/app.py +++ b/skillopt_webui/app.py @@ -760,12 +760,16 @@ def main(): parser.add_argument("--port", type=int, default=7860) parser.add_argument("--share", action="store_true") parser.add_argument("--host", type=str, default="127.0.0.1", - help="Server host. Default is localhost; use 0.0.0.0 " - "to expose publicly (no auth, use with care).") + help="Server host. Default is localhost; binding any " + "other address requires --auth-user/--auth-pass.") parser.add_argument("--auth-user", type=str, default=None, help="Username for basic auth (or set SKILLOPT_WEBUI_USER).") parser.add_argument("--auth-pass", type=str, default=None, help="Password for basic auth (or set SKILLOPT_WEBUI_PASS).") + parser.add_argument("--allow-unauthenticated", action="store_true", + help="Allow --share or a non-loopback --host without " + "auth. Anyone who can reach the UI can then launch " + "training jobs with your API keys.") args = parser.parse_args() if args.host and args.host not in ("127.0.0.1", "localhost", "::1"): @@ -803,6 +807,19 @@ def main(): ) sys.exit(1) + # The training controls launch jobs with the API keys from .env and + # .secrets/, so never expose them beyond loopback without login unless + # the operator explicitly opts in. + exposed = args.share or args.host not in ("127.0.0.1", "localhost", "::1") + if exposed and not auth and not args.allow_unauthenticated: + print( + "Refusing to start: --share / a non-loopback --host requires " + "--auth-user and --auth-pass (or SKILLOPT_WEBUI_USER and " + "SKILLOPT_WEBUI_PASS). Pass --allow-unauthenticated to override.", + file=sys.stderr, + ) + sys.exit(1) + app = build_ui() launch_kwargs = build_launch_kwargs(args.host, args.port, args.share) if auth: diff --git a/tests/test_webui_security.py b/tests/test_webui_security.py index cd021e95f..88223df89 100644 --- a/tests/test_webui_security.py +++ b/tests/test_webui_security.py @@ -48,7 +48,7 @@ def test_main_warns_on_public_host(webui, monkeypatch, capsys): app_mock = mock.MagicMock() app_mock.launch = launcher monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock) - monkeypatch.setattr(sys, "argv", ["app.py", "--host", "0.0.0.0"]) + monkeypatch.setattr(sys, "argv", ["app.py", "--host", "0.0.0.0", "--allow-unauthenticated"]) webui_mod.main() @@ -65,7 +65,7 @@ def test_main_warns_on_share(webui, monkeypatch, capsys): app_mock = mock.MagicMock() app_mock.launch = launcher monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock) - monkeypatch.setattr(sys, "argv", ["app.py", "--share"]) + monkeypatch.setattr(sys, "argv", ["app.py", "--share", "--allow-unauthenticated"]) webui_mod.main() @@ -74,6 +74,40 @@ def test_main_warns_on_share(webui, monkeypatch, capsys): assert "public" in captured.err.lower() or "tunnel" in captured.err.lower() +@pytest.mark.parametrize("argv", [["--host", "0.0.0.0"], ["--share"]]) +def test_main_refuses_exposed_without_auth(webui, monkeypatch, argv): + """A public bind or share link without auth must fail closed (never launch).""" + webui_mod = webui + launcher = mock.MagicMock() + app_mock = mock.MagicMock() + app_mock.launch = launcher + monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock) + monkeypatch.setattr(sys, "argv", ["app.py", *argv]) + monkeypatch.delenv("SKILLOPT_WEBUI_USER", raising=False) + monkeypatch.delenv("SKILLOPT_WEBUI_PASS", raising=False) + with pytest.raises(SystemExit): + webui_mod.main() + launcher.assert_not_called() + + +def test_main_allows_exposed_with_auth(webui, monkeypatch): + """A public bind with auth configured must launch with that auth.""" + webui_mod = webui + launcher = mock.MagicMock() + app_mock = mock.MagicMock() + app_mock.launch = launcher + monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock) + monkeypatch.setattr( + sys, "argv", ["app.py", "--host", "0.0.0.0", "--auth-user", "admin", "--auth-pass", "s3cret"] + ) + + webui_mod.main() + + _args, kwargs = launcher.call_args + assert kwargs["server_name"] == "0.0.0.0" + assert kwargs.get("auth") == ("admin", "s3cret") + + def test_main_auth_via_cli_args(webui, monkeypatch): """--auth-user and --auth-pass must enable Gradio basic auth.""" webui_mod = webui