feat(auth): make session cookie name configurable via env var

Add HERMES_WEBUI_COOKIE_NAME so multiple WebUI instances sharing a
hostname (different ports) can use distinct auth cookie names. Browsers
scope cookies by host, not host+port (RFC 6265), so same-host instances
otherwise trample each other's `hermes_session` cookie and log users out.

- Resolve the cookie name via _resolve_cookie_name(): env > default,
  mirroring the existing _resolve_session_ttl() pattern.
- Keep `hermes_session` as the default for backwards compatibility.
- Validate against the RFC 6265 token grammar; fall back to the default
  with a logged warning on empty or malformed values.
- Cover default, override, empty, invalid, and Set-Cookie paths in
  tests/test_auth_sessions.py::TestCookieNameResolution.
This commit is contained in:
gakugaku
2026-06-11 07:46:02 +00:00
parent 48860418c9
commit d9c35f58dd
2 changed files with 95 additions and 13 deletions

View File

@@ -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())

View File

@@ -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()