commit
aef15ca559
4 files changed
+174
-14
No files matched your search
+41
@@ -467,6 +467,47 @@ def _session_token_from_cookie_value(cookie_value: str) -> str | None:
|
||||
return token or None
|
||||
|
||||
|
||||
def sign_profile_cookie_value(profile_name: str, session_cookie_value: str | None) -> str:
|
||||
"""Return a profile cookie value authenticated for one WebUI session.
|
||||
|
||||
The active-profile cookie is client-controlled, so when auth is enabled it
|
||||
must not be trusted as a bare profile name. Binding the selected profile to
|
||||
the HttpOnly session token prevents a client from forging
|
||||
``hermes_profile=<other-profile>`` and bypassing profile visibility guards.
|
||||
"""
|
||||
if not session_cookie_value or not verify_session(session_cookie_value):
|
||||
raise ValueError("active auth session is required to sign profile cookie")
|
||||
token = _session_token_from_cookie_value(session_cookie_value)
|
||||
if not token:
|
||||
raise ValueError("active auth session is required to sign profile cookie")
|
||||
sig = hmac.new(
|
||||
_signing_key(),
|
||||
f"profile:{token}:{profile_name}".encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"{profile_name}.{sig}"
|
||||
|
||||
|
||||
def verify_profile_cookie_value(cookie_value: str, session_cookie_value: str | None) -> str | None:
|
||||
"""Verify a session-bound profile cookie and return its profile name."""
|
||||
if not cookie_value or '.' not in cookie_value:
|
||||
return None
|
||||
if not session_cookie_value or not verify_session(session_cookie_value):
|
||||
return None
|
||||
profile_name, sig = cookie_value.rsplit('.', 1)
|
||||
token = _session_token_from_cookie_value(session_cookie_value)
|
||||
if not profile_name or not token or not sig:
|
||||
return None
|
||||
expected = hmac.new(
|
||||
_signing_key(),
|
||||
f"profile:{token}:{profile_name}".encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if hmac.compare_digest(str(sig), expected):
|
||||
return profile_name
|
||||
return None
|
||||
|
||||
|
||||
def csrf_token_for_session(cookie_value: str) -> str | None:
|
||||
"""Return the CSRF token bound to an authenticated WebUI session.
|
||||
|
||||
|
||||
+40
-10
@@ -503,7 +503,14 @@ def get_profile_cookie_name() -> str:
|
||||
|
||||
|
||||
def get_profile_cookie(handler) -> str | None:
|
||||
"""Extract the active-profile cookie value from the request, or None."""
|
||||
"""Extract and authenticate the active-profile cookie value.
|
||||
|
||||
When WebUI auth is enabled, the profile cookie is treated as an
|
||||
authorization input for profile-scoped routes. Require it to be signed for
|
||||
the current auth session so clients cannot forge ``hermes_profile`` to
|
||||
impersonate another profile. In no-auth deployments, keep the historical
|
||||
plain profile-name cookie behavior.
|
||||
"""
|
||||
cookie_header = handler.headers.get('Cookie', '')
|
||||
if not cookie_header:
|
||||
return None
|
||||
@@ -515,16 +522,30 @@ def get_profile_cookie(handler) -> str | None:
|
||||
return None
|
||||
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
|
||||
val = morsel.value
|
||||
if val == 'default' or _PROFILE_ID_RE.fullmatch(val):
|
||||
return val
|
||||
return None
|
||||
if not (morsel and morsel.value):
|
||||
return None
|
||||
|
||||
from api.profiles import _PROFILE_ID_RE
|
||||
|
||||
def _valid_profile_name(val: str) -> bool:
|
||||
return val == 'default' or bool(_PROFILE_ID_RE.fullmatch(val))
|
||||
|
||||
raw_val = morsel.value
|
||||
try:
|
||||
from api.auth import is_auth_enabled, parse_cookie, verify_profile_cookie_value
|
||||
if is_auth_enabled():
|
||||
val = verify_profile_cookie_value(raw_val, parse_cookie(handler))
|
||||
return val if val and _valid_profile_name(val) else None
|
||||
except Exception:
|
||||
logger.warning("Failed to verify active profile cookie", exc_info=True)
|
||||
return None
|
||||
|
||||
# No-auth mode: the cookie is a per-browser UI preference, not an authz
|
||||
# boundary, so retain the legacy plain profile-name format.
|
||||
return raw_val if _valid_profile_name(raw_val) else None
|
||||
|
||||
|
||||
def build_profile_cookie(name: str) -> str:
|
||||
def build_profile_cookie(name: str, handler=None) -> str:
|
||||
"""Build a Set-Cookie header value for the active-profile cookie.
|
||||
|
||||
Always persist the selected profile in the cookie, including 'default'.
|
||||
@@ -539,7 +560,16 @@ def build_profile_cookie(name: str) -> str:
|
||||
import http.cookies as _hc
|
||||
cookie = _hc.SimpleCookie()
|
||||
cookie_name = get_profile_cookie_name()
|
||||
cookie[cookie_name] = name
|
||||
value = name
|
||||
if handler is not None:
|
||||
try:
|
||||
from api.auth import is_auth_enabled, parse_cookie, sign_profile_cookie_value
|
||||
if is_auth_enabled():
|
||||
value = sign_profile_cookie_value(name, parse_cookie(handler))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to sign active profile cookie", exc_info=True)
|
||||
raise RuntimeError("could not sign active profile cookie") from exc
|
||||
cookie[cookie_name] = value
|
||||
cookie[cookie_name]['path'] = '/'
|
||||
cookie[cookie_name]['httponly'] = True
|
||||
cookie[cookie_name]['samesite'] = 'Lax'
|
||||
|
||||
+1
-1
@@ -8362,7 +8362,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
from api.config import invalidate_models_cache
|
||||
invalidate_models_cache()
|
||||
return j(handler, result, extra_headers={
|
||||
'Set-Cookie': build_profile_cookie(name),
|
||||
'Set-Cookie': build_profile_cookie(name, handler),
|
||||
})
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
return bad(handler, _sanitize_error(e), 404)
|
||||
|
||||
+92
-3
@@ -45,21 +45,108 @@ class TestProfileCookieHelpers:
|
||||
handler.headers.get = lambda k, d='': ''
|
||||
assert get_profile_cookie(handler) is None
|
||||
|
||||
def test_get_profile_cookie_extracts_valid_name(self):
|
||||
def test_get_profile_cookie_extracts_valid_name(self, monkeypatch):
|
||||
from api.helpers import get_profile_cookie
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': 'hermes_profile=alice' if k == 'Cookie' else d
|
||||
assert get_profile_cookie(handler) == 'alice'
|
||||
|
||||
def test_get_profile_cookie_accepts_default(self):
|
||||
def test_get_profile_cookie_requires_session_bound_signature_when_auth_enabled(self, monkeypatch):
|
||||
from api.auth import sign_profile_cookie_value
|
||||
from api.helpers import get_profile_cookie
|
||||
|
||||
session_cookie = 'session-token.session-sig'
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
|
||||
monkeypatch.setattr('api.auth.verify_session', lambda cookie: cookie == session_cookie)
|
||||
signed_profile = sign_profile_cookie_value('alice', session_cookie)
|
||||
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': (
|
||||
f'hermes_session={session_cookie}; hermes_profile={signed_profile}' if k == 'Cookie' else d
|
||||
)
|
||||
assert get_profile_cookie(handler) == 'alice'
|
||||
|
||||
def test_get_profile_cookie_rejects_unsigned_profile_when_auth_enabled(self, monkeypatch):
|
||||
from api.helpers import get_profile_cookie
|
||||
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': (
|
||||
'hermes_session=session-token.session-sig; hermes_profile=alice' if k == 'Cookie' else d
|
||||
)
|
||||
assert get_profile_cookie(handler) is None
|
||||
|
||||
def test_get_profile_cookie_rejects_profile_signed_for_another_session(self, monkeypatch):
|
||||
from api.auth import sign_profile_cookie_value
|
||||
from api.helpers import get_profile_cookie
|
||||
|
||||
other_session = 'other-token.other-sig'
|
||||
current_session = 'session-token.session-sig'
|
||||
monkeypatch.setattr('api.auth.verify_session', lambda cookie: cookie in {other_session, current_session})
|
||||
signed_profile = sign_profile_cookie_value('alice', other_session)
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': (
|
||||
f'hermes_session={current_session}; hermes_profile={signed_profile}' if k == 'Cookie' else d
|
||||
)
|
||||
assert get_profile_cookie(handler) is None
|
||||
|
||||
def test_build_profile_cookie_binds_to_auth_session_when_auth_enabled(self, monkeypatch):
|
||||
from api.auth import verify_profile_cookie_value
|
||||
from api.helpers import build_profile_cookie
|
||||
|
||||
session_cookie = 'session-token.session-sig'
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
|
||||
monkeypatch.setattr('api.auth.verify_session', lambda cookie: cookie == session_cookie)
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': f'hermes_session={session_cookie}' if k == 'Cookie' else d
|
||||
|
||||
cookie = build_profile_cookie('alice', handler)
|
||||
value = cookie.split('hermes_profile=', 1)[1].split(';', 1)[0]
|
||||
assert value != 'alice'
|
||||
assert verify_profile_cookie_value(value, session_cookie) == 'alice'
|
||||
|
||||
def test_sign_profile_cookie_requires_active_session(self, monkeypatch):
|
||||
from api.auth import sign_profile_cookie_value
|
||||
|
||||
monkeypatch.setattr('api.auth.verify_session', lambda cookie: False)
|
||||
with pytest.raises(ValueError):
|
||||
sign_profile_cookie_value('alice', 'expired-token.session-sig')
|
||||
|
||||
def test_verify_profile_cookie_rejects_expired_session(self, monkeypatch):
|
||||
from api.auth import sign_profile_cookie_value, verify_profile_cookie_value
|
||||
|
||||
session_cookie = 'session-token.session-sig'
|
||||
monkeypatch.setattr('api.auth.verify_session', lambda cookie: True)
|
||||
signed_profile = sign_profile_cookie_value('alice', session_cookie)
|
||||
|
||||
monkeypatch.setattr('api.auth.verify_session', lambda cookie: False)
|
||||
assert verify_profile_cookie_value(signed_profile, session_cookie) is None
|
||||
|
||||
def test_build_profile_cookie_fails_closed_when_auth_session_missing(self, monkeypatch, caplog):
|
||||
from api.helpers import build_profile_cookie
|
||||
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
|
||||
monkeypatch.setattr('api.auth.verify_session', lambda cookie: False)
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': ''
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
build_profile_cookie('alice', handler)
|
||||
assert 'Failed to sign active profile cookie' in caplog.text
|
||||
|
||||
def test_get_profile_cookie_accepts_default(self, monkeypatch):
|
||||
from api.helpers import get_profile_cookie
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': 'hermes_profile=default' if k == 'Cookie' else d
|
||||
assert get_profile_cookie(handler) == 'default'
|
||||
|
||||
def test_get_profile_cookie_rejects_injection(self):
|
||||
def test_get_profile_cookie_rejects_injection(self, monkeypatch):
|
||||
"""Cookie value must pass _PROFILE_ID_RE fullmatch — rejects traversal/injection."""
|
||||
from api.helpers import get_profile_cookie
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
|
||||
for bad in ('../etc', 'a/b', 'name;DROP', 'WithCaps', 'has space', '.hidden'):
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='', v=bad: f'hermes_profile={v}' if k == 'Cookie' else d
|
||||
@@ -85,6 +172,7 @@ class TestProfileCookieHelpers:
|
||||
from api.helpers import build_profile_cookie, get_profile_cookie
|
||||
|
||||
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_social')
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
|
||||
|
||||
s = build_profile_cookie('writer')
|
||||
assert 'hermes_profile_social=writer' in s
|
||||
@@ -100,6 +188,7 @@ class TestProfileCookieHelpers:
|
||||
from api.helpers import get_profile_cookie
|
||||
|
||||
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_main')
|
||||
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
|
||||
|
||||
handler = MagicMock()
|
||||
handler.headers.get = lambda k, d='': 'hermes_profile=social_profile' if k == 'Cookie' else d
|
||||
|
||||
Reference in new issue
Block a user