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.
Files changed (54) hide show
  1. pyweb/__init__.py +71 -0
  2. pyweb/app.py +37 -0
  3. pyweb/app_loader.py +139 -0
  4. pyweb/asgi.py +71 -0
  5. pyweb/auth.py +701 -0
  6. pyweb/bench.py +101 -0
  7. pyweb/browser.py +232 -0
  8. pyweb/build.py +295 -0
  9. pyweb/cache.py +201 -0
  10. pyweb/cli/__init__.py +465 -0
  11. pyweb/cli/__main__.py +3 -0
  12. pyweb/compiler/__init__.py +5 -0
  13. pyweb/compiler/ast.py +45 -0
  14. pyweb/compiler/codegen/__init__.py +9 -0
  15. pyweb/compiler/codegen/ir.py +50 -0
  16. pyweb/compiler/errors.py +29 -0
  17. pyweb/compiler/lower.py +733 -0
  18. pyweb/compiler/parser.py +532 -0
  19. pyweb/compiler/pipeline.py +158 -0
  20. pyweb/compiler/pyjs.py +1087 -0
  21. pyweb/compiler/rpc.py +35 -0
  22. pyweb/context.py +193 -0
  23. pyweb/css.py +115 -0
  24. pyweb/db/__init__.py +652 -0
  25. pyweb/db/migrate.py +143 -0
  26. pyweb/decorators.py +49 -0
  27. pyweb/deploy.py +102 -0
  28. pyweb/forms.py +108 -0
  29. pyweb/hosting.py +139 -0
  30. pyweb/jobs.py +248 -0
  31. pyweb/live.py +88 -0
  32. pyweb/lsp.py +214 -0
  33. pyweb/models.py +269 -0
  34. pyweb/npm.py +270 -0
  35. pyweb/observability.py +313 -0
  36. pyweb/platform.py +69 -0
  37. pyweb/plugins.py +112 -0
  38. pyweb/py.typed +0 -0
  39. pyweb/realtime.py +189 -0
  40. pyweb/rpc.py +137 -0
  41. pyweb/runtime/browser/runtime.js +1151 -0
  42. pyweb/runtime/server/__init__.py +445 -0
  43. pyweb/security.py +162 -0
  44. pyweb/serve.py +335 -0
  45. pyweb/ssr.py +260 -0
  46. pyweb/sync.py +118 -0
  47. pyweb/testing.py +154 -0
  48. pyweb/uploads.py +94 -0
  49. pyweb_stack-0.1.0.dist-info/METADATA +198 -0
  50. pyweb_stack-0.1.0.dist-info/RECORD +54 -0
  51. pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
  52. pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. 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