diff --git a/api/auth.py b/api/auth.py index 048b0f40..7960e954 100644 --- a/api/auth.py +++ b/api/auth.py @@ -9,6 +9,7 @@ import http.cookies import json import logging import os +import re import secrets import tempfile import threading @@ -57,6 +58,33 @@ PUBLIC_PATHS = frozenset({ COOKIE_NAME = 'hermes_session' CSRF_HEADER_NAME = 'X-Hermes-CSRF-Token' + +# RFC 6265 cookie-name token: a non-empty run of token chars +# (no controls, whitespace, or separators such as ';', '=', ','). +_COOKIE_NAME_RE = re.compile(r"^[-!#$%&'*+.^_`|~0-9A-Za-z]+$") + + +def _resolve_cookie_name() -> str: + """Resolve the auth session cookie name from env > default. + + Honours ``HERMES_WEBUI_COOKIE_NAME`` so multiple WebUI instances sharing a + hostname (different ports) can use distinct cookie names instead of + trampling each other's session — browsers scope cookies by host, not + host+port (RFC 6265). Falls back to ``COOKIE_NAME`` when the env var is + unset, empty, or not a valid RFC 6265 token. + """ + name = os.getenv('HERMES_WEBUI_COOKIE_NAME', '').strip() + if not name: + return COOKIE_NAME + if _COOKIE_NAME_RE.match(name): + return name + logger.warning( + 'Ignoring invalid HERMES_WEBUI_COOKIE_NAME=%r; falling back to %r ' + '(name must be a valid RFC 6265 token)', name, COOKIE_NAME, + ) + return COOKIE_NAME + + _SESSIONS_FILE = STATE_DIR / '.sessions.json' @@ -482,7 +510,7 @@ def parse_cookie(handler) -> str | None: cookie.load(cookie_header) except http.cookies.CookieError: return None - morsel = cookie.get(COOKIE_NAME) + morsel = cookie.get(_resolve_cookie_name()) return morsel.value if morsel else None @@ -589,21 +617,23 @@ def _is_secure_context(handler=None) -> bool: def set_auth_cookie(handler, cookie_value) -> None: """Set the auth cookie on the response.""" cookie = http.cookies.SimpleCookie() - cookie[COOKIE_NAME] = cookie_value - cookie[COOKIE_NAME]['httponly'] = True - cookie[COOKIE_NAME]['samesite'] = 'Lax' - cookie[COOKIE_NAME]['path'] = '/' - cookie[COOKIE_NAME]['max-age'] = str(_resolve_session_ttl()) + name = _resolve_cookie_name() + cookie[name] = cookie_value + cookie[name]['httponly'] = True + cookie[name]['samesite'] = 'Lax' + cookie[name]['path'] = '/' + cookie[name]['max-age'] = str(_resolve_session_ttl()) if _is_secure_context(handler): - cookie[COOKIE_NAME]['secure'] = True - handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString()) + cookie[name]['secure'] = True + handler.send_header('Set-Cookie', cookie[name].OutputString()) def clear_auth_cookie(handler) -> None: """Clear the auth cookie on the response.""" cookie = http.cookies.SimpleCookie() - cookie[COOKIE_NAME] = '' - cookie[COOKIE_NAME]['httponly'] = True - cookie[COOKIE_NAME]['path'] = '/' - cookie[COOKIE_NAME]['max-age'] = '0' - handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString()) + name = _resolve_cookie_name() + cookie[name] = '' + cookie[name]['httponly'] = True + cookie[name]['path'] = '/' + cookie[name]['max-age'] = '0' + handler.send_header('Set-Cookie', cookie[name].OutputString()) diff --git a/tests/test_auth_sessions.py b/tests/test_auth_sessions.py index 725e336b..81da0d9c 100644 --- a/tests/test_auth_sessions.py +++ b/tests/test_auth_sessions.py @@ -254,3 +254,55 @@ class TestSessionTtlResolution(unittest.TestCase): break else: self.fail("Session token not found in _sessions") + + +class TestCookieNameResolution(unittest.TestCase): + """Verify the auth cookie name resolution (env > default).""" + + def setUp(self): + self._saved = os.environ.get("HERMES_WEBUI_COOKIE_NAME") + os.environ.pop("HERMES_WEBUI_COOKIE_NAME", None) + + def tearDown(self): + if self._saved is None: + os.environ.pop("HERMES_WEBUI_COOKIE_NAME", None) + else: + os.environ["HERMES_WEBUI_COOKIE_NAME"] = self._saved + + def test_default_when_unset(self): + """With the env var unset the legacy default name is used.""" + self.assertEqual(auth._resolve_cookie_name(), auth.COOKIE_NAME) + + def test_env_var_overrides_default(self): + """A valid env var name overrides the default.""" + os.environ["HERMES_WEBUI_COOKIE_NAME"] = "hermes_session_alt" + self.assertEqual(auth._resolve_cookie_name(), "hermes_session_alt") + + def test_empty_env_falls_back(self): + """Whitespace-only env var falls back to the default.""" + os.environ["HERMES_WEBUI_COOKIE_NAME"] = " " + self.assertEqual(auth._resolve_cookie_name(), auth.COOKIE_NAME) + + def test_invalid_name_falls_back(self): + """A name with characters illegal in an RFC 6265 token is rejected.""" + os.environ["HERMES_WEBUI_COOKIE_NAME"] = "bad name=x" + self.assertEqual(auth._resolve_cookie_name(), auth.COOKIE_NAME) + + def test_set_cookie_uses_resolved_name(self): + """set_auth_cookie emits the resolved name in the Set-Cookie header.""" + os.environ["HERMES_WEBUI_COOKIE_NAME"] = "hermes_session_alt" + sent = {} + + class _H: + request = object() # no getpeercert -> not a TLS socket + headers: dict = {} + + def send_header(self, k, v): + sent[k] = v + + auth.set_auth_cookie(_H(), "tok.sig") + self.assertIn("hermes_session_alt=", sent["Set-Cookie"]) + + +if __name__ == "__main__": + unittest.main()