Fix session cookie routing and proxy-aware auth throttling

This commit is contained in:
Jage9
2026-03-01 23:57:31 -05:00
parent b8375e82f7
commit 2956fa8083
5 changed files with 90 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.03.01 R332"; window.CHGRID_WEB_VERSION = "2026.03.01 R333";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -270,8 +270,8 @@ const SYSTEM_SOUND_URLS = {
logout: withBase('sounds/logout.ogg'), logout: withBase('sounds/logout.ogg'),
notify: withBase('sounds/notify.ogg'), notify: withBase('sounds/notify.ogg'),
} as const; } as const;
const AUTH_SESSION_COOKIE_SET_URL = withBase('auth/session/set'); const AUTH_SESSION_COOKIE_SET_URL = '/auth/session/set';
const AUTH_SESSION_COOKIE_CLEAR_URL = withBase('auth/session/clear'); const AUTH_SESSION_COOKIE_CLEAR_URL = '/auth/session/clear';
const AUTH_SESSION_COOKIE_CLIENT_HEADER = 'X-Chgrid-Auth-Client'; const AUTH_SESSION_COOKIE_CLIENT_HEADER = 'X-Chgrid-Auth-Client';
const ACTION_SOUND_URL = withBase('sounds/action.ogg'); const ACTION_SOUND_URL = withBase('sounds/action.ogg');
const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`)); const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`));
@@ -1698,7 +1698,7 @@ async function persistHttpOnlySessionCookie(sessionToken: string): Promise<void>
const token = sessionToken.trim(); const token = sessionToken.trim();
if (!token) return; if (!token) return;
try { try {
await fetch(AUTH_SESSION_COOKIE_SET_URL, { const response = await fetch(AUTH_SESSION_COOKIE_SET_URL, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
headers: { headers: {
@@ -1707,15 +1707,19 @@ async function persistHttpOnlySessionCookie(sessionToken: string): Promise<void>
}, },
cache: 'no-store', cache: 'no-store',
}); });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) { } catch (error) {
console.warn('Unable to persist auth cookie.', error); console.warn('Unable to persist auth cookie.', error);
pushChatMessage('Session save failed. You may need to log in again after refresh.');
} }
} }
/** Clears server-managed HttpOnly auth session cookie. */ /** Clears server-managed HttpOnly auth session cookie. */
async function clearHttpOnlySessionCookie(): Promise<void> { async function clearHttpOnlySessionCookie(): Promise<void> {
try { try {
await fetch(AUTH_SESSION_COOKIE_CLEAR_URL, { const response = await fetch(AUTH_SESSION_COOKIE_CLEAR_URL, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
headers: { headers: {
@@ -1723,8 +1727,12 @@ async function clearHttpOnlySessionCookie(): Promise<void> {
}, },
cache: 'no-store', cache: 'no-store',
}); });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) { } catch (error) {
console.warn('Unable to clear auth cookie.', error); console.warn('Unable to clear auth cookie.', error);
pushChatMessage('Session clear failed. Your browser may retain an old login cookie.');
} }
} }

View File

@@ -6,6 +6,8 @@
# Proxy websocket signaling endpoint to local Python service. # Proxy websocket signaling endpoint to local Python service.
ProxyPass /ws ws://127.0.0.1:8765 ProxyPass /ws ws://127.0.0.1:8765
ProxyPassReverse /ws ws://127.0.0.1:8765 ProxyPassReverse /ws ws://127.0.0.1:8765
ProxyPass /auth/session/ http://127.0.0.1:8765/auth/session/
ProxyPassReverse /auth/session/ http://127.0.0.1:8765/auth/session/
# Ensure HTML entrypoint is never cached so version updates are picked up quickly. # Ensure HTML entrypoint is never cached so version updates are picked up quickly.
<LocationMatch "^/chgrid/?$|^/chgrid/index\\.html$"> <LocationMatch "^/chgrid/?$|^/chgrid/index\\.html$">

View File

@@ -9,6 +9,7 @@ from contextlib import suppress
from datetime import datetime, timezone from datetime import datetime, timezone
from getpass import getpass from getpass import getpass
from importlib.metadata import PackageNotFoundError, version as package_version from importlib.metadata import PackageNotFoundError, version as package_version
import ipaddress
import json import json
import logging import logging
import os import os
@@ -456,10 +457,53 @@ class SignalingServer:
address = getattr(client.websocket, "remote_address", None) address = getattr(client.websocket, "remote_address", None)
if isinstance(address, tuple) and address: if isinstance(address, tuple) and address:
return str(address[0]) peer_raw = address[0]
if isinstance(address, str): elif isinstance(address, str):
return address peer_raw = address
return "unknown" else:
peer_raw = None
peer_ip = SignalingServer._normalized_ip(peer_raw)
if not peer_ip:
return "unknown"
# Trust X-Forwarded-For only from a loopback proxy hop (e.g., local Apache/nginx).
try:
peer_addr = ipaddress.ip_address(peer_ip)
except ValueError:
return peer_ip
if not peer_addr.is_loopback:
return peer_ip
request = getattr(client.websocket, "request", None)
headers = getattr(request, "headers", None)
if headers is None:
return peer_ip
forwarded = str(headers.get("X-Forwarded-For", "")).strip()
if not forwarded:
return peer_ip
for candidate in forwarded.split(","):
parsed = SignalingServer._normalized_ip(candidate)
if parsed:
return parsed
return peer_ip
@staticmethod
def _normalized_ip(value: object) -> str | None:
"""Return normalized IP text or None when input is invalid."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
if text.startswith("[") and text.endswith("]"):
text = text[1:-1]
if "%" in text:
text = text.split("%", 1)[0]
try:
return str(ipaddress.ip_address(text))
except ValueError:
return None
@staticmethod @staticmethod
def _prune_failure_window(bucket: deque[float], now_s: float) -> None: def _prune_failure_window(bucket: deque[float], now_s: float) -> None:

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from time import monotonic from time import monotonic
from typing import cast from typing import cast
import uuid import uuid
@@ -23,6 +24,32 @@ def _packet_types(payloads: list[object]) -> list[str]:
return [getattr(packet, "type", "") for packet in payloads] return [getattr(packet, "type", "") for packet in payloads]
def test_client_ip_prefers_forwarded_for_from_loopback_proxy() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None)
ws = cast(
ServerConnection,
SimpleNamespace(
remote_address=("127.0.0.1", 12345),
request=SimpleNamespace(headers={"X-Forwarded-For": "198.51.100.25, 127.0.0.1"}),
),
)
client = ClientConnection(websocket=ws, id="u1", nickname="tester")
assert server._client_ip(client) == "198.51.100.25"
def test_client_ip_ignores_forwarded_for_from_non_loopback_peer() -> None:
server = SignalingServer("127.0.0.1", 8765, None, None)
ws = cast(
ServerConnection,
SimpleNamespace(
remote_address=("203.0.113.20", 12345),
request=SimpleNamespace(headers={"X-Forwarded-For": "198.51.100.25"}),
),
)
client = ClientConnection(websocket=ws, id="u1", nickname="tester")
assert server._client_ip(client) == "203.0.113.20"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_position_rejects_out_of_bounds(monkeypatch: pytest.MonkeyPatch) -> None: async def test_update_position_rejects_out_of_bounds(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41) server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41)