Tighten auth helper origin checks
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
# Local config/state
|
# Local config/state
|
||||||
|
server/.env
|
||||||
server/config.toml
|
server/config.toml
|
||||||
server/runtime/
|
server/runtime/
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Chat Grid is designed to be run on a secure server with users connecting via a w
|
|||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
cp config.example.toml config.toml
|
cp config.example.toml config.toml
|
||||||
|
cp .env.sample .env
|
||||||
|
# for local dev set CHGRID_HOST_ORIGIN=http://localhost:5173 in .env
|
||||||
uv run python main.py --allow-insecure-ws
|
uv run python main.py --allow-insecure-ws
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -27,7 +29,8 @@ Notes:
|
|||||||
- Server defaults to `config.toml` when present.
|
- Server defaults to `config.toml` when present.
|
||||||
- Server bind/port defaults are `127.0.0.1:8765` unless changed in config or CLI flags.
|
- Server bind/port defaults are `127.0.0.1:8765` unless changed in config or CLI flags.
|
||||||
- Client dev defaults to Vite local host/port (`localhost:5173`) unless flags override.
|
- Client dev defaults to Vite local host/port (`localhost:5173`) unless flags override.
|
||||||
- Auth requires `CHGRID_AUTH_SECRET` in server environment; `deploy/scripts/install_server.sh` creates `server/.env` with this value automatically if missing.
|
- Server runtime env lives in `server/.env`; `server/.env.sample` shows the required variables.
|
||||||
|
- `deploy/scripts/install_server.sh` creates `server/.env` with `CHGRID_AUTH_SECRET` automatically if missing.
|
||||||
|
|
||||||
Common server overrides:
|
Common server overrides:
|
||||||
- `uv run python main.py --config /path/to/config.toml`
|
- `uv run python main.py --config /path/to/config.toml`
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN`
|
- accepts browser sockets only when websocket `Origin` matches `CHGRID_HOST_ORIGIN`
|
||||||
- reads `chgrid_session_token` from websocket `Cookie` header
|
- reads `chgrid_session_token` from websocket `Cookie` header
|
||||||
- attempts resume before sending `auth_required`
|
- attempts resume before sending `auth_required`
|
||||||
- exposes `GET /auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` required)
|
- exposes `GET /auth/session/clear` to expire the `HttpOnly` cookie (`X-Chgrid-Auth-Client: 1` and matching `Origin` required)
|
||||||
- Server applies auth hardening before accepting login/register/resume:
|
- Server applies auth hardening before accepting login/register/resume:
|
||||||
- login/register PBKDF2 work runs off the event loop in bounded worker concurrency
|
- login/register PBKDF2 work runs off the event loop in bounded worker concurrency
|
||||||
- repeated auth failures are rate-limited by IP and IP+identity windows
|
- repeated auth failures are rate-limited by IP and IP+identity windows
|
||||||
|
|||||||
@@ -288,6 +288,20 @@ class SignalingServer:
|
|||||||
secure = "; Secure" if self._session_cookie_secure(request) else ""
|
secure = "; Secure" if self._session_cookie_secure(request) else ""
|
||||||
return f"{AUTH_SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
|
return f"{AUTH_SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}"
|
||||||
|
|
||||||
|
def _origin_allowed(self, request: HttpRequest) -> bool:
|
||||||
|
"""Return whether one auth helper HTTP request comes from the configured app origin."""
|
||||||
|
|
||||||
|
if not self.host_origin:
|
||||||
|
return False
|
||||||
|
raw_origin = str(request.headers.get("Origin", "")).strip()
|
||||||
|
if not raw_origin:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
origin = normalize_origin(raw_origin)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return origin == self.host_origin
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cookie_value(cookie_header: str, name: str) -> str:
|
def _cookie_value(cookie_header: str, name: str) -> str:
|
||||||
"""Extract one cookie value by name from a Cookie header."""
|
"""Extract one cookie value by name from a Cookie header."""
|
||||||
@@ -311,6 +325,8 @@ class SignalingServer:
|
|||||||
client_header = str(request.headers.get(AUTH_SESSION_COOKIE_CLIENT_HEADER, "")).strip()
|
client_header = str(request.headers.get(AUTH_SESSION_COOKIE_CLIENT_HEADER, "")).strip()
|
||||||
if client_header != "1":
|
if client_header != "1":
|
||||||
return HttpResponse(400, "Bad Request", headers, b"missing client header")
|
return HttpResponse(400, "Bad Request", headers, b"missing client header")
|
||||||
|
if not self._origin_allowed(request):
|
||||||
|
return HttpResponse(403, "Forbidden", headers, b"origin not allowed")
|
||||||
|
|
||||||
if path == AUTH_SESSION_COOKIE_CHECK_PATH:
|
if path == AUTH_SESSION_COOKIE_CHECK_PATH:
|
||||||
cookie_header = str(request.headers.get("Cookie", "")).strip()
|
cookie_header = str(request.headers.get("Cookie", "")).strip()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def _request(path: str, headers: dict[str, str] | None = None) -> Request:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
|
async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
|
||||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
|
||||||
username = f"user_{uuid.uuid4().hex[:8]}"
|
username = f"user_{uuid.uuid4().hex[:8]}"
|
||||||
session = server.auth_service.register(username, "password99")
|
session = server.auth_service.register(username, "password99")
|
||||||
request = _request(
|
request = _request(
|
||||||
@@ -34,6 +34,7 @@ async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
|
|||||||
headers={
|
headers={
|
||||||
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
|
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
|
||||||
"Authorization": f"Bearer {session.token}",
|
"Authorization": f"Bearer {session.token}",
|
||||||
|
"Origin": "https://example.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,8 +50,11 @@ async def test_session_cookie_set_endpoint_sets_httponly_cookie() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
|
async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
|
||||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
|
||||||
request = _request(AUTH_SESSION_COOKIE_CLEAR_PATH, headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1"})
|
request = _request(
|
||||||
|
AUTH_SESSION_COOKIE_CLEAR_PATH,
|
||||||
|
headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
response = await server._process_http_request(SimpleNamespace(), request)
|
response = await server._process_http_request(SimpleNamespace(), request)
|
||||||
|
|
||||||
@@ -64,7 +68,7 @@ async def test_session_cookie_clear_endpoint_expires_cookie() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None:
|
async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None:
|
||||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
|
||||||
username = f"user_{uuid.uuid4().hex[:8]}"
|
username = f"user_{uuid.uuid4().hex[:8]}"
|
||||||
session = server.auth_service.register(username, "password99")
|
session = server.auth_service.register(username, "password99")
|
||||||
request = _request(
|
request = _request(
|
||||||
@@ -72,6 +76,7 @@ async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None:
|
|||||||
headers={
|
headers={
|
||||||
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
|
AUTH_SESSION_COOKIE_CLIENT_HEADER: "1",
|
||||||
"Cookie": f"{AUTH_SESSION_COOKIE_NAME}={session.token}",
|
"Cookie": f"{AUTH_SESSION_COOKIE_NAME}={session.token}",
|
||||||
|
"Origin": "https://example.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,8 +88,11 @@ async def test_session_cookie_check_endpoint_accepts_valid_cookie() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_cookie_check_endpoint_rejects_missing_cookie() -> None:
|
async def test_session_cookie_check_endpoint_rejects_missing_cookie() -> None:
|
||||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
|
||||||
request = _request(AUTH_SESSION_COOKIE_CHECK_PATH, headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1"})
|
request = _request(
|
||||||
|
AUTH_SESSION_COOKIE_CHECK_PATH,
|
||||||
|
headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
response = await server._process_http_request(SimpleNamespace(), request)
|
response = await server._process_http_request(SimpleNamespace(), request)
|
||||||
|
|
||||||
@@ -92,6 +100,20 @@ async def test_session_cookie_check_endpoint_rejects_missing_cookie() -> None:
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_cookie_helpers_reject_wrong_origin() -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None, host_origin="https://example.com")
|
||||||
|
request = _request(
|
||||||
|
AUTH_SESSION_COOKIE_CLEAR_PATH,
|
||||||
|
headers={AUTH_SESSION_COOKIE_CLIENT_HEADER: "1", "Origin": "https://evil.example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await server._process_http_request(SimpleNamespace(), request)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_session_token_from_websocket_cookie_reads_named_cookie() -> None:
|
def test_session_token_from_websocket_cookie_reads_named_cookie() -> None:
|
||||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
websocket = SimpleNamespace(
|
websocket = SimpleNamespace(
|
||||||
|
|||||||
Reference in New Issue
Block a user