pyweb-stack 0.1.0__py3-none-any.whl
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.
- pyweb/__init__.py +71 -0
- pyweb/app.py +37 -0
- pyweb/app_loader.py +139 -0
- pyweb/asgi.py +71 -0
- pyweb/auth.py +701 -0
- pyweb/bench.py +101 -0
- pyweb/browser.py +232 -0
- pyweb/build.py +295 -0
- pyweb/cache.py +201 -0
- pyweb/cli/__init__.py +465 -0
- pyweb/cli/__main__.py +3 -0
- pyweb/compiler/__init__.py +5 -0
- pyweb/compiler/ast.py +45 -0
- pyweb/compiler/codegen/__init__.py +9 -0
- pyweb/compiler/codegen/ir.py +50 -0
- pyweb/compiler/errors.py +29 -0
- pyweb/compiler/lower.py +733 -0
- pyweb/compiler/parser.py +532 -0
- pyweb/compiler/pipeline.py +158 -0
- pyweb/compiler/pyjs.py +1087 -0
- pyweb/compiler/rpc.py +35 -0
- pyweb/context.py +193 -0
- pyweb/css.py +115 -0
- pyweb/db/__init__.py +652 -0
- pyweb/db/migrate.py +143 -0
- pyweb/decorators.py +49 -0
- pyweb/deploy.py +102 -0
- pyweb/forms.py +108 -0
- pyweb/hosting.py +139 -0
- pyweb/jobs.py +248 -0
- pyweb/live.py +88 -0
- pyweb/lsp.py +214 -0
- pyweb/models.py +269 -0
- pyweb/npm.py +270 -0
- pyweb/observability.py +313 -0
- pyweb/platform.py +69 -0
- pyweb/plugins.py +112 -0
- pyweb/py.typed +0 -0
- pyweb/realtime.py +189 -0
- pyweb/rpc.py +137 -0
- pyweb/runtime/browser/runtime.js +1151 -0
- pyweb/runtime/server/__init__.py +445 -0
- pyweb/security.py +162 -0
- pyweb/serve.py +335 -0
- pyweb/ssr.py +260 -0
- pyweb/sync.py +118 -0
- pyweb/testing.py +154 -0
- pyweb/uploads.py +94 -0
- pyweb_stack-0.1.0.dist-info/METADATA +198 -0
- pyweb_stack-0.1.0.dist-info/RECORD +54 -0
- pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
- pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
- pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Server runtime: routing, SSR, typed RPC dispatcher, static assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as _html
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Request:
|
|
13
|
+
def __init__(self, method, path, headers=None, body=b"", cookies=None):
|
|
14
|
+
self.method = method.upper()
|
|
15
|
+
self.path = path
|
|
16
|
+
self.headers = headers or {}
|
|
17
|
+
self.body = body
|
|
18
|
+
if cookies is None:
|
|
19
|
+
cookies = {}
|
|
20
|
+
raw = next((v for k, v in self.headers.items() if k.lower() == "cookie"), "")
|
|
21
|
+
for pair in (raw or "").split(";"):
|
|
22
|
+
name, sep, value = pair.partition("=")
|
|
23
|
+
if sep and name.strip():
|
|
24
|
+
cookies[name.strip()] = value.strip().strip('"')
|
|
25
|
+
self.cookies = cookies
|
|
26
|
+
self.id = uuid.uuid4().hex[:12]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Response:
|
|
30
|
+
def __init__(self, status=200, body="", headers=None):
|
|
31
|
+
self.status = status
|
|
32
|
+
self.body = body
|
|
33
|
+
self.headers = headers or {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_POOL = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _rpc_error():
|
|
40
|
+
from pyweb.rpc import RPCError
|
|
41
|
+
return RPCError
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _awaited(aw):
|
|
45
|
+
return await aw
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rpc_pool():
|
|
49
|
+
global _POOL
|
|
50
|
+
if _POOL is None:
|
|
51
|
+
import concurrent.futures as _fut
|
|
52
|
+
_POOL = _fut.ThreadPoolExecutor(max_workers=32, thread_name_prefix="pyweb-rpc")
|
|
53
|
+
return _POOL
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce(value, ann):
|
|
57
|
+
if ann in ("int", "Integer"):
|
|
58
|
+
return int(value)
|
|
59
|
+
if ann in ("float", "Float", "Decimal"):
|
|
60
|
+
return float(value)
|
|
61
|
+
if ann in ("bool", "Boolean"):
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
return value.lower() in ("1", "true", "yes")
|
|
64
|
+
return bool(value)
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Server:
|
|
69
|
+
#: Default cap for RPC request bodies (1 MiB). Bodies beyond this
|
|
70
|
+
#: are rejected with ``413 body-too-large`` before JSON parsing.
|
|
71
|
+
MAX_BODY = 1_048_576
|
|
72
|
+
|
|
73
|
+
def __init__(self, compiled=None, *, auth_secret=None, csrf_secret=None,
|
|
74
|
+
rate_limit=None, rpc_timeout=None, tracer=None,
|
|
75
|
+
logger=None, max_body=None, app=None, debug=False,
|
|
76
|
+
secure_cookies=False):
|
|
77
|
+
if compiled is None:
|
|
78
|
+
compiled = app.compiled if app is not None else {"pages": {}, "rpc": []}
|
|
79
|
+
self.app = app
|
|
80
|
+
self.debug = debug
|
|
81
|
+
self.secure_cookies = secure_cookies
|
|
82
|
+
self.compiled = compiled
|
|
83
|
+
self.rpc_impls: dict[str, object] = {}
|
|
84
|
+
self.routes: list[tuple[re.Pattern, str]] = []
|
|
85
|
+
for name, page in compiled["pages"].items():
|
|
86
|
+
pat = "^" + re.sub(r"\{(\w+)\}", r"(?P<\1>[^/]+)", page["route"]) + "$"
|
|
87
|
+
self.routes.append((re.compile(pat), name))
|
|
88
|
+
# Production RPC controls (all optional; secure defaults when set).
|
|
89
|
+
self.auth_secret = auth_secret
|
|
90
|
+
self.csrf_secret = csrf_secret
|
|
91
|
+
self.rate_limit = rate_limit # RateLimiter or None
|
|
92
|
+
self.rpc_timeout = rpc_timeout # seconds or None
|
|
93
|
+
self.max_body = self.MAX_BODY if max_body is None else max_body
|
|
94
|
+
self.tracer = tracer
|
|
95
|
+
self.logger = logger
|
|
96
|
+
if app is not None:
|
|
97
|
+
for fn in app.rpc.values():
|
|
98
|
+
self.register_rpc(fn)
|
|
99
|
+
|
|
100
|
+
def register_rpc(self, fn):
|
|
101
|
+
self.rpc_impls[fn.__name__] = fn
|
|
102
|
+
|
|
103
|
+
def _trace_ctx(self, req: Request):
|
|
104
|
+
from pyweb import rpc as _rpc
|
|
105
|
+
parsed = _rpc.parse_traceparent(req.headers.get("traceparent"))
|
|
106
|
+
trace_id = parsed[0] if parsed else uuid.uuid4().hex
|
|
107
|
+
span_id = uuid.uuid4().hex[:16]
|
|
108
|
+
return trace_id, _rpc.make_traceparent(trace_id, span_id)
|
|
109
|
+
|
|
110
|
+
def _err(self, code, message, *, details=None, trace_id=None,
|
|
111
|
+
traceparent=None, retry_after=None):
|
|
112
|
+
from pyweb import rpc as _rpc
|
|
113
|
+
if isinstance(code, str):
|
|
114
|
+
body = {"error": {"code": code, "message": message,
|
|
115
|
+
"details": details or {}}}
|
|
116
|
+
status = _rpc.status_for(code)
|
|
117
|
+
else: # numeric status with the same envelope shape
|
|
118
|
+
body = {"error": {"code": f"http_{code}", "message": message,
|
|
119
|
+
"details": details or {}}}
|
|
120
|
+
status = code
|
|
121
|
+
headers = {"Content-Type": "application/json"}
|
|
122
|
+
if trace_id:
|
|
123
|
+
headers["X-Request-Id"] = trace_id
|
|
124
|
+
if traceparent:
|
|
125
|
+
headers["traceparent"] = traceparent
|
|
126
|
+
if retry_after:
|
|
127
|
+
headers["Retry-After"] = str(retry_after)
|
|
128
|
+
return Response(status, json.dumps(body), headers)
|
|
129
|
+
|
|
130
|
+
def _check_rpc_access(self, req: Request, fn):
|
|
131
|
+
"""Auth + CSRF + rate-limit gate. Returns None (allow) or Response."""
|
|
132
|
+
from pyweb import rpc as _rpc
|
|
133
|
+
need_auth = getattr(fn, "__pyweb_auth__", False)
|
|
134
|
+
need = getattr(fn, "__pyweb_permissions__", [])
|
|
135
|
+
csrf_exempt = getattr(fn, "__pyweb_csrf_exempt__", False)
|
|
136
|
+
if need_auth or need:
|
|
137
|
+
secret = self.auth_secret
|
|
138
|
+
if not secret:
|
|
139
|
+
return None # no secret configured: decorators are advisory
|
|
140
|
+
from pyweb import auth as _auth
|
|
141
|
+
session = _auth.session_from_request(req, secret)
|
|
142
|
+
if session is None or "sub" not in session:
|
|
143
|
+
return self._err(_rpc.Code.AUTH, "authentication required")
|
|
144
|
+
if need and not _auth.can(session.get("roles", []), fn):
|
|
145
|
+
return self._err(_rpc.Code.FORBIDDEN,
|
|
146
|
+
f"missing permission: {need}")
|
|
147
|
+
if self.csrf_secret and not csrf_exempt:
|
|
148
|
+
token = req.headers.get("X-CSRF-Token", "")
|
|
149
|
+
if not _auth.verify_csrf(self.csrf_secret,
|
|
150
|
+
session.get("sid", session.get("sub", "")),
|
|
151
|
+
token):
|
|
152
|
+
return self._err(_rpc.Code.CSRF, "invalid CSRF token")
|
|
153
|
+
if self.rate_limit is not None:
|
|
154
|
+
key = req.headers.get("X-Forwarded-For",
|
|
155
|
+
req.cookies.get("pyweb_session", "anon"))
|
|
156
|
+
ok, retry = self.rate_limit.allow(
|
|
157
|
+
f"{fn.__name__}:{key}")
|
|
158
|
+
if not ok:
|
|
159
|
+
return self._err(_rpc.Code.RATE_LIMIT, "rate limit exceeded",
|
|
160
|
+
retry_after=retry)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _validate(self, fn, args: dict):
|
|
164
|
+
sig = inspect.signature(fn)
|
|
165
|
+
out = {}
|
|
166
|
+
for pname, param in sig.parameters.items():
|
|
167
|
+
ann = getattr(param.annotation, "__name__", str(param.annotation)) if param.annotation is not inspect.Parameter.empty else "Any"
|
|
168
|
+
if pname in args:
|
|
169
|
+
try:
|
|
170
|
+
out[pname] = _coerce(args[pname], ann)
|
|
171
|
+
except (ValueError, TypeError):
|
|
172
|
+
raise TypeError(f"{fn.__name__}.{pname} expects {ann}")
|
|
173
|
+
if "Email" in ann and "@" not in str(out[pname]):
|
|
174
|
+
raise ValueError(f"{fn.__name__}.{pname} must be a valid email")
|
|
175
|
+
elif param.default is inspect.Parameter.empty:
|
|
176
|
+
raise TypeError(f"{fn.__name__} missing required argument {pname!r}")
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
def handle_rpc(self, req: Request):
|
|
180
|
+
from pyweb import rpc as _rpc
|
|
181
|
+
m = re.match(r"^/__pyweb/rpc/(\w+)$", req.path)
|
|
182
|
+
if not m:
|
|
183
|
+
return None
|
|
184
|
+
trace_id, traceparent = self._trace_ctx(req)
|
|
185
|
+
span = None
|
|
186
|
+
if self.tracer is not None:
|
|
187
|
+
span = self.tracer.start(f"rpc.{m.group(1)}", trace_id=trace_id)
|
|
188
|
+
try:
|
|
189
|
+
name = m.group(1)
|
|
190
|
+
fn = self.rpc_impls.get(name)
|
|
191
|
+
if fn is None:
|
|
192
|
+
return self._err(_rpc.Code.NOT_FOUND, f"unknown rpc {name}",
|
|
193
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
194
|
+
gate = self._check_rpc_access(req, fn)
|
|
195
|
+
if gate is not None:
|
|
196
|
+
gate.headers.setdefault("X-Request-Id", trace_id)
|
|
197
|
+
gate.headers["traceparent"] = traceparent
|
|
198
|
+
return gate
|
|
199
|
+
hdr = {k.lower(): v for k, v in (req.headers or {}).items()}
|
|
200
|
+
ctype = hdr.get("content-type", "")
|
|
201
|
+
if ctype and "json" not in ctype.lower():
|
|
202
|
+
# Browsers can only send cross-site requests without a CORS
|
|
203
|
+
# preflight using form encodings; requiring JSON blocks them.
|
|
204
|
+
return self._err(415, "RPC requests must be application/json",
|
|
205
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
206
|
+
origin = hdr.get("origin")
|
|
207
|
+
host = hdr.get("x-forwarded-host") or hdr.get("host")
|
|
208
|
+
if origin and host and origin != "null":
|
|
209
|
+
from urllib.parse import urlparse as _urlparse
|
|
210
|
+
if _urlparse(origin).netloc != host:
|
|
211
|
+
return self._err(_rpc.Code.CSRF, "cross-origin RPC call rejected",
|
|
212
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
213
|
+
if self.max_body is not None and len(req.body or b"") > self.max_body:
|
|
214
|
+
return self._err(413, "request body too large",
|
|
215
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
216
|
+
try:
|
|
217
|
+
payload = json.loads(req.body or b"{}")
|
|
218
|
+
except json.JSONDecodeError:
|
|
219
|
+
return self._err(_rpc.Code.VALIDATION, "invalid JSON",
|
|
220
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
221
|
+
args = payload.get("args", {}) if isinstance(payload, dict) else {}
|
|
222
|
+
try:
|
|
223
|
+
clean = self._validate(fn, args if isinstance(args, dict) else {})
|
|
224
|
+
except (TypeError, ValueError) as exc:
|
|
225
|
+
return self._err(_rpc.Code.VALIDATION, str(exc),
|
|
226
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
227
|
+
try:
|
|
228
|
+
if self.rpc_timeout:
|
|
229
|
+
result = self._call_with_timeout(fn, clean)
|
|
230
|
+
else:
|
|
231
|
+
result = fn(**clean)
|
|
232
|
+
if inspect.isawaitable(result):
|
|
233
|
+
import asyncio
|
|
234
|
+
result = asyncio.run(_awaited(result))
|
|
235
|
+
except TimeoutError:
|
|
236
|
+
return self._err(_rpc.Code.TIMEOUT,
|
|
237
|
+
f"{name} exceeded {self.rpc_timeout}s",
|
|
238
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
239
|
+
except _rpc.RPCError as exc:
|
|
240
|
+
return self._err(exc.code, str(exc), details=exc.details,
|
|
241
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
242
|
+
except (TypeError, ValueError) as exc:
|
|
243
|
+
return self._err(_rpc.Code.VALIDATION, str(exc),
|
|
244
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
245
|
+
except Exception as exc: # noqa: BLE001
|
|
246
|
+
if self.logger is not None:
|
|
247
|
+
self.logger.error(f"rpc {name} failed: {exc}",
|
|
248
|
+
request_id=trace_id, rpc=name)
|
|
249
|
+
return self._err(_rpc.Code.INTERNAL,
|
|
250
|
+
"internal server error",
|
|
251
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
252
|
+
headers = {"Content-Type": "application/json",
|
|
253
|
+
"X-Request-Id": trace_id, "traceparent": traceparent}
|
|
254
|
+
if isinstance(result, dict) and result.get("__pyweb_stream__"):
|
|
255
|
+
return self._stream_response(result["chunks"], headers)
|
|
256
|
+
from pyweb.ssr import to_jsonable
|
|
257
|
+
try:
|
|
258
|
+
payload = json.dumps({"result": to_jsonable(result)})
|
|
259
|
+
except TypeError as exc:
|
|
260
|
+
if self.logger is not None:
|
|
261
|
+
self.logger.error(f"rpc {name} returned unserializable value: {exc}",
|
|
262
|
+
request_id=trace_id, rpc=name)
|
|
263
|
+
return self._err(_rpc.Code.INTERNAL, "internal server error",
|
|
264
|
+
trace_id=trace_id, traceparent=traceparent)
|
|
265
|
+
return Response(200, payload, headers)
|
|
266
|
+
finally:
|
|
267
|
+
if span is not None and self.tracer is not None:
|
|
268
|
+
try:
|
|
269
|
+
self.tracer.finish(span)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
def _call_with_timeout(self, fn, clean):
|
|
274
|
+
"""Run ``fn`` but stop waiting after ``rpc_timeout`` seconds.
|
|
275
|
+
|
|
276
|
+
Python cannot kill a thread, so a timed-out call keeps running in
|
|
277
|
+
the background pool; the client gets ``timeout`` immediately.
|
|
278
|
+
"""
|
|
279
|
+
import concurrent.futures as _fut
|
|
280
|
+
import contextvars
|
|
281
|
+
ctx = contextvars.copy_context()
|
|
282
|
+
fut = _rpc_pool().submit(ctx.run, fn, **clean)
|
|
283
|
+
try:
|
|
284
|
+
return fut.result(timeout=self.rpc_timeout)
|
|
285
|
+
except _fut.TimeoutError as exc:
|
|
286
|
+
raise TimeoutError() from exc
|
|
287
|
+
|
|
288
|
+
def _stream_response(self, chunks, headers):
|
|
289
|
+
"""NDJSON stream: one {"chunk": ...} object per line."""
|
|
290
|
+
headers = dict(headers)
|
|
291
|
+
headers["Content-Type"] = "application/x-ndjson"
|
|
292
|
+
lines = "".join(json.dumps({"chunk": c}) + "\n" for c in chunks)
|
|
293
|
+
return Response(200, lines, headers)
|
|
294
|
+
|
|
295
|
+
def handle(self, req: Request):
|
|
296
|
+
"""Dispatch one request inside a :mod:`pyweb.context` request scope."""
|
|
297
|
+
from pyweb import context as _ctx
|
|
298
|
+
rc = _ctx.RequestContext(req, auth_secret=self.auth_secret,
|
|
299
|
+
secure_cookies=self.secure_cookies)
|
|
300
|
+
token = _ctx.activate(rc)
|
|
301
|
+
try:
|
|
302
|
+
resp = self._dispatch(req)
|
|
303
|
+
finally:
|
|
304
|
+
_ctx.deactivate(token)
|
|
305
|
+
if resp is not None and rc.set_cookies:
|
|
306
|
+
existing = resp.headers.get("Set-Cookie")
|
|
307
|
+
cookies = ([existing] if isinstance(existing, str) else list(existing or [])) + rc.set_cookies
|
|
308
|
+
resp.headers["Set-Cookie"] = cookies
|
|
309
|
+
return resp
|
|
310
|
+
|
|
311
|
+
def _dispatch(self, req: Request):
|
|
312
|
+
if req.path.startswith("/__pyweb/rpc/"):
|
|
313
|
+
if req.method != "POST":
|
|
314
|
+
return self._err(405, "RPC endpoints accept POST only")
|
|
315
|
+
return self.handle_rpc(req)
|
|
316
|
+
if req.path == "/__pyweb/events" or req.path.startswith("/__pyweb/events?"):
|
|
317
|
+
return self.handle_events(req)
|
|
318
|
+
if req.path == "/__pyweb/poll" or req.path.startswith("/__pyweb/poll?"):
|
|
319
|
+
return self.handle_poll(req)
|
|
320
|
+
path = req.path.split("?")[0]
|
|
321
|
+
for pat, name in self.routes:
|
|
322
|
+
m = pat.match(path)
|
|
323
|
+
if not m:
|
|
324
|
+
continue
|
|
325
|
+
if req.method not in ("GET", "HEAD"):
|
|
326
|
+
return Response(405, "method not allowed", {"Allow": "GET, HEAD",
|
|
327
|
+
"Content-Type": "text/plain"})
|
|
328
|
+
if self.app is None:
|
|
329
|
+
page = self.compiled["pages"][name]
|
|
330
|
+
return Response(200, page["html"], {"Content-Type": "text/html; charset=utf-8",
|
|
331
|
+
"X-Request-Id": req.id})
|
|
332
|
+
return self._render_page(req, name, m.groupdict())
|
|
333
|
+
return self.error_page(404, req)
|
|
334
|
+
|
|
335
|
+
def _render_page(self, req, name, params):
|
|
336
|
+
from pyweb.context import NotFound, Redirect
|
|
337
|
+
from urllib.parse import unquote
|
|
338
|
+
try:
|
|
339
|
+
result = self.app.render(name, {k: unquote(v) for k, v in params.items()})
|
|
340
|
+
except NotFound:
|
|
341
|
+
return self.error_page(404, req)
|
|
342
|
+
except _rpc_error() as exc:
|
|
343
|
+
status = {"not_found": 404, "forbidden": 403, "unauthenticated": 401}.get(exc.code)
|
|
344
|
+
if status is None:
|
|
345
|
+
raise
|
|
346
|
+
return self.error_page(status, req, message=str(exc))
|
|
347
|
+
except Exception as exc: # noqa: BLE001
|
|
348
|
+
import traceback
|
|
349
|
+
if self.logger is not None:
|
|
350
|
+
self.logger.error(f"page {name} failed: {exc}", request_id=req.id, page=name)
|
|
351
|
+
if self.debug:
|
|
352
|
+
return self.error_page(500, req, title=f"{type(exc).__name__} in {name}()",
|
|
353
|
+
message=traceback.format_exc())
|
|
354
|
+
return self.error_page(500, req)
|
|
355
|
+
if isinstance(result, Redirect):
|
|
356
|
+
return Response(result.status, "", {"Location": result.url, "X-Request-Id": req.id})
|
|
357
|
+
return Response(200, result, {"Content-Type": "text/html; charset=utf-8",
|
|
358
|
+
"X-Request-Id": req.id,
|
|
359
|
+
"Cache-Control": "no-store"})
|
|
360
|
+
|
|
361
|
+
def error_page(self, status, req=None, *, title=None, message=None):
|
|
362
|
+
"""Branded HTML error shell. Apps override via ``error_pages`` on
|
|
363
|
+
the compiled dict (``{404: html, 500: html}``) or ``register_error``.
|
|
364
|
+
|
|
365
|
+
Overrides may use ``{{path}}`` and ``{{request_id}}`` placeholders.
|
|
366
|
+
"""
|
|
367
|
+
overrides = (self.compiled.get("error_pages") or {})
|
|
368
|
+
template = overrides.get(status)
|
|
369
|
+
request_id = getattr(req, "id", "") if req is not None else ""
|
|
370
|
+
path = getattr(req, "path", "") if req is not None else ""
|
|
371
|
+
if template is not None:
|
|
372
|
+
body = template.replace("{{path}}", _html.escape(path)).replace(
|
|
373
|
+
"{{request_id}}", _html.escape(request_id))
|
|
374
|
+
return Response(status, body,
|
|
375
|
+
{"Content-Type": "text/html",
|
|
376
|
+
"X-Request-Id": request_id})
|
|
377
|
+
default_title = {404: "Page not found", 500: "Something went wrong"}
|
|
378
|
+
heading = _html.escape(title or default_title.get(status, f"Error {status}"))
|
|
379
|
+
hint = _html.escape(message or ("The page you're looking for doesn't exist. "
|
|
380
|
+
if status == 404 else
|
|
381
|
+
"Please try again; the error has been logged. "))
|
|
382
|
+
body = (
|
|
383
|
+
"<!doctype html><html lang=en><meta charset=utf-8>"
|
|
384
|
+
"<meta name=viewport content='width=device-width,initial-scale=1'>"
|
|
385
|
+
f"<title>{status} {heading}</title>"
|
|
386
|
+
"<body style='font-family:system-ui,sans-serif;max-width:640px;"
|
|
387
|
+
"margin:10vh auto;padding:0 20px;color:#111'>"
|
|
388
|
+
f"<h1>{status} — {heading}</h1>"
|
|
389
|
+
+ (f"<pre style='white-space:pre-wrap;background:#f6f6f6;padding:12px;font-size:13px'>{hint}</pre>"
|
|
390
|
+
if message and "\n" in (message or "") else f"<p>{hint}</p>")
|
|
391
|
+
+ (f"<p><a href='/'>Back home</a> · "
|
|
392
|
+
f"<code>{_html.escape(path)}</code></p>" if status == 404 else "")
|
|
393
|
+
+ (f"<p style='color:#666;font-size:13px'>request id: "
|
|
394
|
+
f"<code>{_html.escape(request_id)}</code></p>" if request_id else "")
|
|
395
|
+
+ "</body></html>")
|
|
396
|
+
return Response(status, body, {"Content-Type": "text/html",
|
|
397
|
+
"X-Request-Id": request_id})
|
|
398
|
+
|
|
399
|
+
def register_error(self, status, html_template):
|
|
400
|
+
"""Register a custom error shell, e.g. ``register_error(404, ...)``."""
|
|
401
|
+
pages = self.compiled.setdefault("error_pages", {})
|
|
402
|
+
pages[status] = html_template
|
|
403
|
+
|
|
404
|
+
def handle_events(self, req: Request):
|
|
405
|
+
"""SSE stream: GET /__pyweb/events?channel=NAME replays missed frames
|
|
406
|
+
(via Last-Event-ID) then emits a live snapshot. Real servers hold the
|
|
407
|
+
connection open; this runtime returns buffered frames so tests and
|
|
408
|
+
simple deployments work without streaming infrastructure."""
|
|
409
|
+
from urllib.parse import urlparse, parse_qs
|
|
410
|
+
from pyweb import realtime as _rt
|
|
411
|
+
qs = parse_qs(urlparse(req.path).query)
|
|
412
|
+
channel_name = (qs.get("channel") or [""])[0]
|
|
413
|
+
if not channel_name:
|
|
414
|
+
return Response(400, json.dumps({"error": "missing ?channel="}),
|
|
415
|
+
{"Content-Type": "application/json"})
|
|
416
|
+
last_id = int(req.headers.get("Last-Event-ID", "0") or 0)
|
|
417
|
+
bus = getattr(self, "bus", None) or _rt._default_bus
|
|
418
|
+
frames = "".join(
|
|
419
|
+
_rt.sse_format(seq, channel_name, msg)
|
|
420
|
+
for seq, msg in bus.since(channel_name, last_id))
|
|
421
|
+
return Response(200, frames, {
|
|
422
|
+
"Content-Type": "text/event-stream",
|
|
423
|
+
"Cache-Control": "no-cache",
|
|
424
|
+
"X-Channel": channel_name,
|
|
425
|
+
"X-Last-Id": str(bus._seq),
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
def handle_poll(self, req: Request):
|
|
429
|
+
"""Polling fallback: GET /__pyweb/poll?channel=NAME&since=ID."""
|
|
430
|
+
from urllib.parse import urlparse, parse_qs
|
|
431
|
+
from pyweb import realtime as _rt
|
|
432
|
+
qs = parse_qs(urlparse(req.path).query)
|
|
433
|
+
channel_name = (qs.get("channel") or [""])[0]
|
|
434
|
+
if not channel_name:
|
|
435
|
+
return Response(400, json.dumps({"error": "missing ?channel="}),
|
|
436
|
+
{"Content-Type": "application/json"})
|
|
437
|
+
try:
|
|
438
|
+
since = int((qs.get("since") or ["0"])[0])
|
|
439
|
+
except ValueError:
|
|
440
|
+
since = 0
|
|
441
|
+
bus = getattr(self, "bus", None) or _rt._default_bus
|
|
442
|
+
messages = [{"id": seq, "channel": channel_name, "data": msg}
|
|
443
|
+
for seq, msg in bus.since(channel_name, since)]
|
|
444
|
+
return Response(200, json.dumps({"messages": messages, "last_id": bus._seq}),
|
|
445
|
+
{"Content-Type": "application/json"})
|
pyweb/security.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Security: HTML escaping, CSRF, taint checks, secret-flow analysis,
|
|
2
|
+
path traversal, redirects, error sanitizing, app scanner."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
import html as _html
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import traceback
|
|
11
|
+
import urllib.parse
|
|
12
|
+
|
|
13
|
+
SECRET_RE = re.compile(r"(SECRET|PASSWORD|API_KEY|TOKEN|PRIVATE)", re.I)
|
|
14
|
+
DANGER_SQL = re.compile(r"f[\"'].*(SELECT|INSERT|UPDATE|DELETE)", re.I)
|
|
15
|
+
DANGER_CMD = re.compile(r"(os\.system|subprocess\.(call|run|Popen)|eval\(|exec\()")
|
|
16
|
+
DANGER_PATH = re.compile(r"open\(\s*[A-Za-z_][\w.]*\s*[,)]")
|
|
17
|
+
|
|
18
|
+
STATIC_DOC = (
|
|
19
|
+
"Static serving MUST confine reads to the static root. "
|
|
20
|
+
"Use safe_join(root, user_path): on PathTraversalError respond "
|
|
21
|
+
"403/404 without revealing the filesystem layout or tracebacks."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_CONCAT_RE = re.compile(r"['\"]\s*\+\s*[a-zA-Z_]")
|
|
25
|
+
_MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SecurityError(Exception):
|
|
29
|
+
"""Base class for security rejections."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PathTraversalError(SecurityError):
|
|
33
|
+
"""Raised when a user path escapes the static root."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def escape(text):
|
|
37
|
+
return _html.escape(str(text))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def escape_html(text):
|
|
41
|
+
return _html.escape(str(text), quote=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def escape_attr(value):
|
|
45
|
+
return _html.escape(str(value), quote=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ssr(template, **context):
|
|
49
|
+
"""Render ``{{ name }}`` placeholders with ALL values escaped."""
|
|
50
|
+
out = template
|
|
51
|
+
for key, value in context.items():
|
|
52
|
+
out = out.replace("{{ " + key + " }}", escape_html(value))
|
|
53
|
+
out = out.replace("{{" + key + "}}", escape_html(value))
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def safe_join(root, user_path):
|
|
58
|
+
"""Join ``user_path`` onto ``root``; reject escapes with PathTraversalError."""
|
|
59
|
+
root_abs = os.path.abspath(root)
|
|
60
|
+
joined = os.path.abspath(os.path.join(root_abs, user_path))
|
|
61
|
+
if joined != root_abs and not joined.startswith(root_abs + os.sep):
|
|
62
|
+
raise PathTraversalError(f"path escapes static root: {user_path!r}")
|
|
63
|
+
return joined
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_safe_redirect(target):
|
|
67
|
+
"""Allow only relative same-origin paths (no scheme, no //host)."""
|
|
68
|
+
if not target or not target.startswith("/") or target.startswith("//"):
|
|
69
|
+
return False
|
|
70
|
+
if "\\" in target:
|
|
71
|
+
return False
|
|
72
|
+
parsed = urllib.parse.urlparse(target)
|
|
73
|
+
return not parsed.scheme and not parsed.netloc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def safe_next(next_param, default="/"):
|
|
77
|
+
if next_param and is_safe_redirect(next_param):
|
|
78
|
+
return next_param
|
|
79
|
+
return default
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def safe_error(exc):
|
|
83
|
+
"""Render a generic error page that never leaks tracebacks or secrets."""
|
|
84
|
+
_ = traceback.format_exception(exc)
|
|
85
|
+
return ("<html><body><h1>Something went wrong</h1>"
|
|
86
|
+
"<p>Please try again later.</p></body></html>")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _handler_source(route):
|
|
90
|
+
handler = route.get("handler")
|
|
91
|
+
if handler is not None and hasattr(handler, "__source__"):
|
|
92
|
+
return handler.__source__
|
|
93
|
+
return route.get("handler_source", "")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def scan(app):
|
|
97
|
+
"""Scan an app descriptor for secret-leak / xss-risk /
|
|
98
|
+
missing-auth-on-mutating-rpc findings."""
|
|
99
|
+
findings = []
|
|
100
|
+
for route in app.get("routes", []):
|
|
101
|
+
path = route.get("path", "?")
|
|
102
|
+
src = _handler_source(route)
|
|
103
|
+
lowered = src.lower()
|
|
104
|
+
if ("os.environ" in src and any(
|
|
105
|
+
k in lowered for k in ("secret", "password", "token",
|
|
106
|
+
"api_key", "apikey", "credential"))):
|
|
107
|
+
findings.append({
|
|
108
|
+
"kind": "secret-leak",
|
|
109
|
+
"path": path,
|
|
110
|
+
"detail": "handler reads a secret from the environment; "
|
|
111
|
+
"ensure it never reaches client bundles/logs",
|
|
112
|
+
})
|
|
113
|
+
if ("+" in src and "<div" in src and _CONCAT_RE.search(src)
|
|
114
|
+
and "escape" not in lowered):
|
|
115
|
+
findings.append({
|
|
116
|
+
"kind": "xss-risk",
|
|
117
|
+
"path": path,
|
|
118
|
+
"detail": "handler concatenates HTML without escaping; "
|
|
119
|
+
"use security.ssr/escape_html",
|
|
120
|
+
})
|
|
121
|
+
methods = {m.upper() for m in route.get("methods", ["GET"])}
|
|
122
|
+
if methods & _MUTATING and not route.get("auth", False):
|
|
123
|
+
findings.append({
|
|
124
|
+
"kind": "missing-auth-on-mutating-rpc",
|
|
125
|
+
"path": path,
|
|
126
|
+
"detail": f"mutating methods {sorted(methods & _MUTATING)} "
|
|
127
|
+
"without auth guard",
|
|
128
|
+
})
|
|
129
|
+
return findings
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def check_source(source, filename="<pyweb>"):
|
|
133
|
+
"""Return a list of {'kind', 'line', 'message'} findings."""
|
|
134
|
+
findings = []
|
|
135
|
+
for i, line in enumerate(source.splitlines(), start=1):
|
|
136
|
+
if DANGER_SQL.search(line):
|
|
137
|
+
findings.append({"kind": "sql-injection", "line": i,
|
|
138
|
+
"message": "possible SQL string interpolation; use parameterized queries"})
|
|
139
|
+
if DANGER_CMD.search(line):
|
|
140
|
+
findings.append({"kind": "command-injection", "line": i,
|
|
141
|
+
"message": "dangerous call (os.system/subprocess/eval/exec)"})
|
|
142
|
+
try:
|
|
143
|
+
tree = ast.parse(source, filename=filename)
|
|
144
|
+
except SyntaxError:
|
|
145
|
+
return findings
|
|
146
|
+
for node in ast.walk(tree):
|
|
147
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
148
|
+
decos = [ast.unparse(d) if hasattr(ast, "unparse") else "" for d in node.decorator_list]
|
|
149
|
+
if any("browser" in d for d in decos):
|
|
150
|
+
for sub in ast.walk(node):
|
|
151
|
+
if isinstance(sub, ast.Name) and SECRET_RE.search(sub.id):
|
|
152
|
+
findings.append({"kind": "secret-leak", "line": getattr(sub, "lineno", 0),
|
|
153
|
+
"message": f"server secret {sub.id!r} referenced from @browser code"})
|
|
154
|
+
return findings
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def assert_browser_safe(source, filename="<pyweb>"):
|
|
158
|
+
leaks = [f for f in check_source(source, filename) if f["kind"] == "secret-leak"]
|
|
159
|
+
if leaks:
|
|
160
|
+
f = leaks[0]
|
|
161
|
+
raise ValueError(f"ERROR: server secret referenced from browser-executed code ({filename}:{f['line']})")
|
|
162
|
+
return True
|