diff --git a/client/public/changelog.json b/client/public/changelog.json index ab13b42..3ee6291 100644 --- a/client/public/changelog.json +++ b/client/public/changelog.json @@ -1,5 +1,11 @@ { "sections": [ + { + "date": "March 8, 2026", + "items": [ + "Added a command palette with Shift+K, Shift+F10, or the Applications key to show all available commands." + ] + }, { "date": "February 28, 2026", "items": [ diff --git a/client/public/version.js b/client/public/version.js index 13d3ed5..501b97c 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,6 +1,6 @@ // Maintainer-controlled web client version metadata. window.CHGRID_RELEASE_VERSION = "0.1.0"; -window.CHGRID_BUILD_REVISION = "R344"; +window.CHGRID_BUILD_REVISION = "R345"; window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/session/authController.ts b/client/src/session/authController.ts index 5229b2d..fb9f360 100644 --- a/client/src/session/authController.ts +++ b/client/src/session/authController.ts @@ -156,6 +156,14 @@ export function createAuthController(deps: AuthControllerDeps): { } } + function resetSavedSessionHint(): void { + authUserId = ''; + authUsername = ''; + deps.saveAuthUsername(''); + deps.dom.authUsername.value = ''; + deps.dom.registerUsername.value = ''; + } + function updateConnectAvailability(): void { const hasSavedSessionHint = sanitizeAuthUsername(authUsername).length > 0; const showLogout = deps.isRunning() || hasSavedSessionHint; @@ -256,6 +264,10 @@ export function createAuthController(deps: AuthControllerDeps): { deps.signalingSend(packet); return; } + if (sanitizeAuthUsername(authUsername).length > 0) { + resetSavedSessionHint(); + setAuthMode('login'); + } deps.setConnecting(false); updateConnectAvailability(); } @@ -311,6 +323,7 @@ export function createAuthController(deps: AuthControllerDeps): { deps.dom.registerPassword.value = ''; deps.dom.registerPasswordConfirm.value = ''; if (message.message.toLowerCase().includes('session')) { + resetSavedSessionHint(); void clearHttpOnlySessionCookie(); } applyAuthPermissions('user', []); diff --git a/server/app/server.py b/server/app/server.py index 281d85a..5f04bcb 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -22,6 +22,7 @@ import uuid from pathlib import Path from typing import Literal from urllib.error import URLError +from urllib.parse import urlsplit, urlunsplit from zoneinfo import ZoneInfo from pydantic import ValidationError, TypeAdapter @@ -330,13 +331,26 @@ class SignalingServer: if not self.host_origin: return False raw_origin = str(request.headers.get("Origin", "")).strip() - if not raw_origin: + if raw_origin: + try: + origin = normalize_origin(raw_origin) + except ValueError: + return False + return origin == self.host_origin + + fetch_site = str(request.headers.get("Sec-Fetch-Site", "")).strip().lower() + if fetch_site == "same-origin": + return True + + raw_referer = str(request.headers.get("Referer", "")).strip() + if not raw_referer: return False try: - origin = normalize_origin(raw_origin) + parts = urlsplit(raw_referer) + referer_origin = urlunsplit((parts.scheme, parts.netloc, "", "", "")) + return normalize_origin(referer_origin, field_name="referer") == self.host_origin except ValueError: return False - return origin == self.host_origin @staticmethod def _cookie_value(cookie_header: str, name: str) -> str: diff --git a/server/tests/test_http_session_cookie.py b/server/tests/test_http_session_cookie.py index f1fc8f0..c202314 100644 --- a/server/tests/test_http_session_cookie.py +++ b/server/tests/test_http_session_cookie.py @@ -118,6 +118,23 @@ async def test_session_cookie_helpers_reject_wrong_origin() -> None: assert response.status_code == 403 +@pytest.mark.asyncio +async def test_session_cookie_helpers_accept_same_origin_referer_without_origin() -> None: + server = _server() + request = _request( + server.auth_session_cookie_clear_path, + headers={ + AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", + "Referer": "https://example.com/chgrid/", + }, + ) + + response = await server._process_http_request(SimpleNamespace(), request) + + assert response is not None + assert response.status_code == 200 + + def test_session_token_from_websocket_cookie_reads_named_cookie() -> None: server = SignalingServer("127.0.0.1", 8765, None, None, base_path="/chgrid/") websocket = SimpleNamespace(