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:
56
api/auth.py
56
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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user