From d3c885f0ed14558fc88743b699a77b0190ea47cd Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Thu, 8 Oct 2026 16:10:42 +0200 Subject: [PATCH] fix(visualize-only): land on /visualizer/ after one-time sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-login view always redirected to the dashboard, so `uvx simpleaudit-studio --visualize-only` dropped the user on the unpopulated dashboard instead of the visualizer. - auto_login_view now honors SIMPLEAUDIT_AUTO_LOGIN_NEXT (server-side env var, relative paths only — no open redirect) as the post-login target, falling back to the dashboard - visualize-only mode sets it to /visualizer/, so both the printed one-time sign-in link and the auto-opened browser land on the visualizer - tests for the new redirect + relative-path guard --- infra/tests/test_minimal_config.py | 28 ++++++++++++++++++++++++++-- infra/ui.py | 6 ++++++ simpleaudit_studio/cli.py | 3 +++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/infra/tests/test_minimal_config.py b/infra/tests/test_minimal_config.py index f74a33bf..0b8147c4 100644 --- a/infra/tests/test_minimal_config.py +++ b/infra/tests/test_minimal_config.py @@ -193,13 +193,14 @@ def setUp(self): def tearDown(self): os.environ.pop("SIMPLEAUDIT_AUTO_LOGIN_TOKEN", None) + os.environ.pop("SIMPLEAUDIT_AUTO_LOGIN_NEXT", None) - def _login(self, token=None): + def _login(self, token=None, follow=True): url = "/auto-login/" if token is not None: url += f"?token={token}" # follow=True: the view 302s to the dashboard after signing in. - return self.client.get(url, follow=True) + return self.client.get(url, follow=follow) @override_settings(MINIMAL_CONFIG=True) def test_valid_token_logs_in_and_consumes(self): @@ -210,6 +211,29 @@ def test_valid_token_logs_in_and_consumes(self): # The token is single-use: it must be gone after the first login. self.assertNotIn("SIMPLEAUDIT_AUTO_LOGIN_TOKEN", os.environ) + @override_settings(MINIMAL_CONFIG=True) + def test_redirects_to_visualizer_when_next_set(self): + # visualize-only mode sets SIMPLEAUDIT_AUTO_LOGIN_NEXT so the + # one-time link lands on the visualizer, not the dashboard. + env = {"SIMPLEAUDIT_AUTO_LOGIN_TOKEN": self.TOKEN, "SIMPLEAUDIT_AUTO_LOGIN_NEXT": "/visualizer/"} + with patch.dict(os.environ, env): + response = self._login(self.TOKEN, follow=False) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], "/visualizer/") + + @override_settings(MINIMAL_CONFIG=True) + def test_next_must_be_a_relative_path(self): + # An absolute (http://) or protocol-relative (//evil) value must be + # ignored so a tampered env var can't turn this into an open redirect. + for bad in ("https://evil.example.com", "//evil.example.com", ""): + with patch.dict( + os.environ, + {"SIMPLEAUDIT_AUTO_LOGIN_TOKEN": self.TOKEN, "SIMPLEAUDIT_AUTO_LOGIN_NEXT": bad}, + ): + response = self._login(self.TOKEN, follow=False) + self.assertEqual(response.status_code, 302) + self.assertNotIn("evil.example.com", response.headers["Location"]) + @override_settings(MINIMAL_CONFIG=True) def test_token_cannot_be_replayed(self): with patch.dict(os.environ, {"SIMPLEAUDIT_AUTO_LOGIN_TOKEN": self.TOKEN}): diff --git a/infra/ui.py b/infra/ui.py index a4341e37..57b95f59 100644 --- a/infra/ui.py +++ b/infra/ui.py @@ -286,6 +286,12 @@ def auto_login_view(request): if user is None: raise Http404 login(request, user) + # visualize-only mode sets this so the signed-in browser lands on the + # visualizer instead of the (unpopulated) dashboard. Trust the server + # env var, not the request: the browser only gets the token URL. + next_path = os.environ.get("SIMPLEAUDIT_AUTO_LOGIN_NEXT", "").strip() + if next_path.startswith("/") and not next_path.startswith("//"): + return redirect(next_path) return redirect("dashboard") diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index d3f0c797..e8d0b2db 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -496,6 +496,9 @@ def _run_visualize_only(args) -> None: auto_login_token = secrets.token_urlsafe(32) os.environ["SIMPLEAUDIT_AUTO_LOGIN_TOKEN"] = auto_login_token + # After the one-time sign-in, land on the visualizer instead of the + # dashboard (which has no useful content in visualize-only mode). + os.environ["SIMPLEAUDIT_AUTO_LOGIN_NEXT"] = "/visualizer/" auto_login_url = f"http://localhost:{port}/auto-login/?token={auto_login_token}" print("┌─────────────────────────────────────────────────────────┐")