From 77c862ed9b278ca6bf1a1276c540993cf9d4444c Mon Sep 17 00:00:00 2001 From: skypank Date: Sun, 27 Sep 2026 13:51:04 +0530 Subject: [PATCH] fix(web): no-store per-user auth/user response (was cacheable PII) --- application/tests/web_main_test.py | 29 +++++++++++++++++++++++++++++ application/web/web_main.py | 10 ++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index dbd716708..b790b0d03 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -1813,3 +1813,32 @@ def test_openapi_yaml_endpoint_serves_spec(self) -> None: body = response.data.decode() self.assertIn("openapi:", body) self.assertIn("/rest/v1/", body) + + def test_per_user_endpoints_are_no_store(self) -> None: + """Per-user endpoints must be no-store, never publicly cacheable. + + /rest/v1/auth/user returns the logged-in user's email; served with + Cache-Control: max-age=300 (no no-store/private/Vary:Cookie) it could be + stored by a shared/browser cache and disclosed to another user. This is + the regression test for the after_request no-store fix — it also guards + the alias /rest/v1/user and the already-covered /rest/v1/user/resources. + """ + with patch.dict(os.environ, {"NO_LOGIN": "1"}): + with self.app.test_client() as client: + for path in ( + "/rest/v1/auth/user", + "/rest/v1/user", + "/rest/v1/user/resources", + ): + response = client.get(path) + cache_control = response.headers.get("Cache-Control", "") + self.assertIn( + "no-store", + cache_control, + msg=f"{path} must be no-store, got {cache_control!r}", + ) + self.assertNotIn( + "max-age=300", + cache_control, + msg=f"{path} must not be publicly cacheable, got {cache_control!r}", + ) diff --git a/application/web/web_main.py b/application/web/web_main.py index ea9ce90a7..45cba60b5 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -898,8 +898,14 @@ def before_request(): @app.after_request def add_header(response): # Per-user endpoints must never be shared-cached; no-store wins over the - # default max-age for them (this hook runs after the view). - if request.path == "/rest/v1/user/resources": + # default max-age for them (this hook runs after the view). Covers the + # resource selection AND the identity endpoint (returns the user's email) + # plus its deprecated alias. + if request.path in ( + "/rest/v1/user/resources", + "/rest/v1/auth/user", + "/rest/v1/user", + ): response.cache_control.no_store = True return response response.cache_control.max_age = 300