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("┌─────────────────────────────────────────────────────────┐")