Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions api/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()
32 changes: 32 additions & 0 deletions tests/test_issue803.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────

Expand Down