Fix session cookie routing and proxy-aware auth throttling
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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$">
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user