From 8385f902537d00b2ad5ec1cbed778ab49a3fe6e5 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 6 May 2026 11:49:26 -0700 Subject: [PATCH] fix: isolate profile cookie per webui instance --- api/helpers.py | 24 ++++++++++++++++-------- tests/test_issue803.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index f6c8b5846..fd0c1983d 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -2,6 +2,7 @@ Hermes Web UI -- HTTP helper functions. """ import json as _json +import os import re as _re from pathlib import Path from api.config import IMAGE_EXTS, MD_EXTS @@ -252,8 +253,13 @@ def read_body(handler) -> dict: PROFILE_COOKIE_NAME = 'hermes_profile' +def get_profile_cookie_name() -> str: + """Return the cookie name used to persist the active WebUI profile.""" + return os.getenv('WEBUI_PROFILE_COOKIE_NAME', PROFILE_COOKIE_NAME) + + def get_profile_cookie(handler) -> str | None: - """Extract the hermes_profile cookie value from the request, or None.""" + """Extract the active-profile cookie value from the request, or None.""" cookie_header = handler.headers.get('Cookie', '') if not cookie_header: return None @@ -263,7 +269,8 @@ def get_profile_cookie(handler) -> str | None: cookie.load(cookie_header) except _hc.CookieError: return None - morsel = cookie.get(PROFILE_COOKIE_NAME) + cookie_name = get_profile_cookie_name() + morsel = cookie.get(cookie_name) if morsel and morsel.value: # Validate against profile-name pattern before trusting from api.profiles import _PROFILE_ID_RE @@ -274,7 +281,7 @@ def get_profile_cookie(handler) -> str | None: def build_profile_cookie(name: str) -> str: - """Build a Set-Cookie header value for the hermes_profile cookie. + """Build a Set-Cookie header value for the active-profile cookie. Always persist the selected profile in the cookie, including 'default'. Clearing the cookie causes the backend to fall back to process-global @@ -287,8 +294,9 @@ def build_profile_cookie(name: str) -> str: """ import http.cookies as _hc cookie = _hc.SimpleCookie() - cookie[PROFILE_COOKIE_NAME] = name - cookie[PROFILE_COOKIE_NAME]['path'] = '/' - cookie[PROFILE_COOKIE_NAME]['httponly'] = True - cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax' - return cookie[PROFILE_COOKIE_NAME].OutputString() + cookie_name = get_profile_cookie_name() + cookie[cookie_name] = name + cookie[cookie_name]['path'] = '/' + cookie[cookie_name]['httponly'] = True + cookie[cookie_name]['samesite'] = 'Lax' + return cookie[cookie_name].OutputString() diff --git a/tests/test_issue803.py b/tests/test_issue803.py index f882e14d2..e1a2698ee 100644 --- a/tests/test_issue803.py +++ b/tests/test_issue803.py @@ -73,6 +73,38 @@ def test_get_profile_cookie_ignores_malformed_header(self): result = get_profile_cookie(handler) assert result is None + def test_profile_cookie_name_defaults_to_hermes_profile(self, monkeypatch): + from api.helpers import build_profile_cookie + + monkeypatch.delenv('WEBUI_PROFILE_COOKIE_NAME', raising=False) + + s = build_profile_cookie('alice') + assert 'hermes_profile=alice' in s + + def test_profile_cookie_name_can_be_isolated_per_webui_instance(self, monkeypatch): + from api.helpers import build_profile_cookie, get_profile_cookie + + monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_social') + + s = build_profile_cookie('writer') + assert 'hermes_profile_social=writer' in s + assert 'hermes_profile=writer' not in s + + handler = MagicMock() + handler.headers.get = lambda k, d='': ( + 'hermes_profile=wrong; hermes_profile_social=writer' if k == 'Cookie' else d + ) + assert get_profile_cookie(handler) == 'writer' + + def test_configured_profile_cookie_ignores_default_cookie_name(self, monkeypatch): + from api.helpers import get_profile_cookie + + monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_main') + + handler = MagicMock() + handler.headers.get = lambda k, d='': 'hermes_profile=social_profile' if k == 'Cookie' else d + assert get_profile_cookie(handler) is None + # ── 2. Thread-local request context ──────────────────────────────────────────