create-caspian-app 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/main.py +76 -27
- package/dist/src/app/layout.py +17 -20
- package/dist/src/lib/websocket/sockets.py +740 -0
- package/package.json +1 -1
- package/dist/src/lib/websocket/websocket_security.py +0 -192
package/package.json
CHANGED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
from typing import Any
|
|
5
|
-
from urllib.parse import urlparse
|
|
6
|
-
|
|
7
|
-
from fastapi import WebSocket, status
|
|
8
|
-
|
|
9
|
-
from casp.auth import Auth
|
|
10
|
-
from casp.runtime_security import is_production_environment
|
|
11
|
-
|
|
12
|
-
# ====
|
|
13
|
-
# WebSocket security: ONE guard that reuses Caspian's `Auth` as the source of
|
|
14
|
-
# truth, so socket auth lines up with HTTP route auth instead of duplicating it.
|
|
15
|
-
#
|
|
16
|
-
# Why this lives here and not in `AuthMiddleware`:
|
|
17
|
-
# HTTP middleware in `main.py` early-returns on every non-`http` scope, so a
|
|
18
|
-
# WebSocket handshake (`scope["type"] == "websocket"`) is never seen by
|
|
19
|
-
# `AuthMiddleware`. Each socket endpoint must therefore authorize itself. We do
|
|
20
|
-
# that by delegating to the same `Auth` instance that powers page privacy.
|
|
21
|
-
# ====
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _is_production() -> bool:
|
|
25
|
-
# Shared fail-closed resolution: an unset or misspelled APP_ENV must not
|
|
26
|
-
# silently enable the development handshake relaxations below.
|
|
27
|
-
return is_production_environment()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _normalized_origin(value: str) -> str:
|
|
31
|
-
return (value or "").strip().rstrip("/")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _configured_websocket_origins() -> set[str]:
|
|
35
|
-
raw_values: list[str] = []
|
|
36
|
-
for env_name in (
|
|
37
|
-
"WEBSOCKET_ALLOWED_ORIGINS",
|
|
38
|
-
"CORS_ALLOWED_ORIGINS",
|
|
39
|
-
"APP_BASE_URL",
|
|
40
|
-
):
|
|
41
|
-
raw_values.extend(os.getenv(env_name, "").split(","))
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
_normalized_origin(origin)
|
|
45
|
-
for origin in raw_values
|
|
46
|
-
if _normalized_origin(origin)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _websocket_same_origin(websocket: WebSocket) -> str:
|
|
51
|
-
scheme = "https" if websocket.url.scheme == "wss" else "http"
|
|
52
|
-
return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def is_websocket_origin_allowed(websocket: WebSocket) -> bool:
|
|
56
|
-
"""Anti-CSWSH origin check. NOT authentication.
|
|
57
|
-
|
|
58
|
-
A browser cannot forge the `Origin` header, so this blocks cross-site
|
|
59
|
-
script-driven handshakes. A raw client (wscat, python websockets) can send
|
|
60
|
-
any origin, which is exactly why authentication is a separate gate.
|
|
61
|
-
"""
|
|
62
|
-
origin = _normalized_origin(websocket.headers.get("origin", ""))
|
|
63
|
-
if not origin:
|
|
64
|
-
# No Origin header: tolerate local tooling in dev, reject in production.
|
|
65
|
-
return not _is_production()
|
|
66
|
-
|
|
67
|
-
parsed_origin = urlparse(origin)
|
|
68
|
-
if not parsed_origin.scheme or not parsed_origin.netloc:
|
|
69
|
-
return False
|
|
70
|
-
|
|
71
|
-
if not _is_production() and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
|
|
72
|
-
return parsed_origin.scheme == "http"
|
|
73
|
-
|
|
74
|
-
allowed_origins = _configured_websocket_origins()
|
|
75
|
-
|
|
76
|
-
# The same-origin fallback is derived from the Host header, which a client
|
|
77
|
-
# controls directly and a misconfigured proxy will forward verbatim: sending
|
|
78
|
-
# `Host: evil.tld` with `Origin: https://evil.tld` would otherwise satisfy
|
|
79
|
-
# this check against itself. It is a convenience for development only --
|
|
80
|
-
# production must name its origins explicitly.
|
|
81
|
-
if not _is_production():
|
|
82
|
-
allowed_origins.add(_websocket_same_origin(websocket))
|
|
83
|
-
|
|
84
|
-
return origin in allowed_origins
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
async def authorize_websocket(
|
|
88
|
-
websocket: WebSocket,
|
|
89
|
-
*,
|
|
90
|
-
require_auth: bool,
|
|
91
|
-
roles: list[str] | None = None,
|
|
92
|
-
) -> dict[str, Any] | None:
|
|
93
|
-
"""Single entry point for socket authorization.
|
|
94
|
-
|
|
95
|
-
Returns the connecting identity on success, or `None` after closing the
|
|
96
|
-
socket on failure. The return value gates the connection only; do not echo
|
|
97
|
-
it to other clients (it may contain the authenticated user's payload).
|
|
98
|
-
|
|
99
|
-
- `require_auth=True` -> authenticated session required, else close 1008.
|
|
100
|
-
- `require_auth=False` -> guests allowed; authenticated users keep identity.
|
|
101
|
-
- `roles` -> RBAC via `Auth.check_role`, same rule as HTTP routes.
|
|
102
|
-
|
|
103
|
-
Auth is read from the session cookie that `SessionMiddleware` (the one
|
|
104
|
-
middleware that runs on websocket scopes) exposes as `websocket.session`.
|
|
105
|
-
Treat this as read-only: session writes are not persisted back to the cookie
|
|
106
|
-
over a WebSocket, so this guard must not rely on `refresh_session`.
|
|
107
|
-
"""
|
|
108
|
-
# 1. Origin / CSWSH check runs before accept so rejected handshakes never
|
|
109
|
-
# upgrade.
|
|
110
|
-
if not is_websocket_origin_allowed(websocket):
|
|
111
|
-
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
112
|
-
return None
|
|
113
|
-
|
|
114
|
-
# 2. Reuse Caspian `Auth` as the single source of truth. `Auth` only reads
|
|
115
|
-
# `request.session`, and a WebSocket exposes `.session`, so binding the
|
|
116
|
-
# socket as the request context lets us reuse the exact HTTP auth logic.
|
|
117
|
-
Auth.set_request(websocket) # type: ignore[arg-type]
|
|
118
|
-
auth = Auth.get_instance()
|
|
119
|
-
|
|
120
|
-
if auth.is_authenticated():
|
|
121
|
-
payload = auth.get_payload() or {}
|
|
122
|
-
if roles and not auth.check_role(payload, roles):
|
|
123
|
-
await _reject(websocket, "Forbidden.")
|
|
124
|
-
return None
|
|
125
|
-
return payload
|
|
126
|
-
|
|
127
|
-
if require_auth:
|
|
128
|
-
await _reject(websocket, "Authentication required.")
|
|
129
|
-
return None
|
|
130
|
-
|
|
131
|
-
return {"guest": True, "scope": "public"}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
async def _reject(websocket: WebSocket, message: str) -> None:
|
|
135
|
-
"""Accept, surface a JSON error so the browser UI can react, then close."""
|
|
136
|
-
await websocket.accept()
|
|
137
|
-
await websocket.send_json({"type": "error", "message": message})
|
|
138
|
-
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# Ceiling on simultaneous sockets in one pool. Every open connection is a live
|
|
142
|
-
# task plus a broadcast target, so an unbounded pool lets cheap clients grow
|
|
143
|
-
# server memory and turn each broadcast into an amplification.
|
|
144
|
-
MAX_WEBSOCKET_CONNECTIONS = max(
|
|
145
|
-
1, int(os.getenv("MAX_WEBSOCKET_CONNECTIONS", 200))
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
class WebSocketConnectionManager:
|
|
150
|
-
def __init__(self, max_connections: int = MAX_WEBSOCKET_CONNECTIONS) -> None:
|
|
151
|
-
self._connections: set[WebSocket] = set()
|
|
152
|
-
self._max_connections = max_connections
|
|
153
|
-
|
|
154
|
-
@property
|
|
155
|
-
def connection_count(self) -> int:
|
|
156
|
-
return len(self._connections)
|
|
157
|
-
|
|
158
|
-
async def connect(self, websocket: WebSocket) -> bool:
|
|
159
|
-
"""Accept and register the socket. Returns False when the pool is full.
|
|
160
|
-
|
|
161
|
-
The capacity check runs before `accept()`, so a refused client is closed
|
|
162
|
-
during the handshake and never reaches an open state. The caller must
|
|
163
|
-
stop on a False result rather than run a read loop on a socket that was
|
|
164
|
-
never added to the pool.
|
|
165
|
-
"""
|
|
166
|
-
if len(self._connections) >= self._max_connections:
|
|
167
|
-
await websocket.close(code=status.WS_1013_TRY_AGAIN_LATER)
|
|
168
|
-
return False
|
|
169
|
-
|
|
170
|
-
await websocket.accept()
|
|
171
|
-
self._connections.add(websocket)
|
|
172
|
-
return True
|
|
173
|
-
|
|
174
|
-
def disconnect(self, websocket: WebSocket) -> None:
|
|
175
|
-
self._connections.discard(websocket)
|
|
176
|
-
|
|
177
|
-
async def broadcast_json(self, payload: dict[str, Any]) -> None:
|
|
178
|
-
stale: list[WebSocket] = []
|
|
179
|
-
for websocket in list(self._connections):
|
|
180
|
-
try:
|
|
181
|
-
await websocket.send_json(payload)
|
|
182
|
-
except RuntimeError:
|
|
183
|
-
stale.append(websocket)
|
|
184
|
-
|
|
185
|
-
for websocket in stale:
|
|
186
|
-
self.disconnect(websocket)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
# Separate pools keep authenticated traffic isolated from guest traffic, so a
|
|
190
|
-
# private broadcast can never fan out to a public (guest) connection.
|
|
191
|
-
websocket_connections = WebSocketConnectionManager()
|
|
192
|
-
public_websocket_connections = WebSocketConnectionManager()
|