messagefoundry-webconsole 0.2.15__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 (40) hide show
  1. messagefoundry_webconsole/__init__.py +128 -0
  2. messagefoundry_webconsole/_auth.py +841 -0
  3. messagefoundry_webconsole/_html.py +539 -0
  4. messagefoundry_webconsole/_security.py +253 -0
  5. messagefoundry_webconsole/_service.py +22 -0
  6. messagefoundry_webconsole/_static.py +78 -0
  7. messagefoundry_webconsole/mount.py +103 -0
  8. messagefoundry_webconsole/pages/__init__.py +25 -0
  9. messagefoundry_webconsole/pages/_common.py +21 -0
  10. messagefoundry_webconsole/pages/account.py +771 -0
  11. messagefoundry_webconsole/pages/admin.py +480 -0
  12. messagefoundry_webconsole/pages/audit.py +66 -0
  13. messagefoundry_webconsole/pages/config.py +113 -0
  14. messagefoundry_webconsole/pages/connections.py +519 -0
  15. messagefoundry_webconsole/pages/messages.py +689 -0
  16. messagefoundry_webconsole/pages/monitoring.py +853 -0
  17. messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
  18. messagefoundry_webconsole/routes/__init__.py +6 -0
  19. messagefoundry_webconsole/routes/_common.py +45 -0
  20. messagefoundry_webconsole/routes/account.py +451 -0
  21. messagefoundry_webconsole/routes/admin.py +505 -0
  22. messagefoundry_webconsole/routes/audit.py +39 -0
  23. messagefoundry_webconsole/routes/config.py +67 -0
  24. messagefoundry_webconsole/routes/connection_writes.py +248 -0
  25. messagefoundry_webconsole/routes/core.py +1077 -0
  26. messagefoundry_webconsole/routes/monitoring.py +94 -0
  27. messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
  28. messagefoundry_webconsole/routes/oidc.py +172 -0
  29. messagefoundry_webconsole/routes/search.py +204 -0
  30. messagefoundry_webconsole/routes/sso.py +88 -0
  31. messagefoundry_webconsole/routes/status.py +178 -0
  32. messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
  33. messagefoundry_webconsole/static/app.css +345 -0
  34. messagefoundry_webconsole/static/app.js +1506 -0
  35. messagefoundry_webconsole/static/csp-probe.js +9 -0
  36. messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
  37. messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
  38. messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
  39. messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
  40. messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
@@ -0,0 +1,253 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Per-response /ui browser-security hardening (ADR 0065 §hardening / BACKLOG #192, ASVS 5.0 L3).
4
+
5
+ A self-contained, /ui-scoped ASGI middleware the web console installs on the mounted app (in
6
+ :func:`.mount.mount_ui`, before uvicorn serves). It OWNS the browser-security response headers for the
7
+ /ui HTML surface WITHOUT touching the engine (``api/app.py`` is out of this lane's scope):
8
+
9
+ * a **per-response nonce CSP** — ``script-src 'nonce-<random>' 'strict-dynamic'`` (3.4.7/3.4.8), minted
10
+ fresh per response and stamped into the ``<script>`` tag via the :mod:`._html` nonce ContextVar so the
11
+ tag and header always match;
12
+ * **Cross-Origin-Opener-Policy: same-origin** (process isolation) and
13
+ **Cross-Origin-Resource-Policy: same-origin**;
14
+ * **CSP reporting** — ``report-to``/``report-uri`` pointing at ``POST /ui/csp-report`` plus the modern
15
+ ``Reporting-Endpoints`` header (3.7.5).
16
+
17
+ **Secure-context engagement (loopback + effective-https).** All of the above is NEW behavior. It
18
+ engages in an effective-https context (scheme https/wss OR the operator's ``exposure_protected``
19
+ declaration — :func:`._auth.effective_https`, read-only) **OR a loopback secure-context**
20
+ (``http://127.0.0.1`` — a W3C *potentially-trustworthy* origin, signalled by ``app.state.loopback``;
21
+ ADR 0143), and only while the org opt-out (:func:`._auth.browser_hardening_enabled`) is unset — the
22
+ combined gate is :func:`._auth.security_headers_context`. On loopback the http-safe headers (nonce-CSP /
23
+ COOP / CORP / Reporting) engage, but the session cookie's Secure / ``__Host-`` prefix still requires
24
+ real https (:func:`._auth.effective_https`) so login is not broken over cleartext loopback (Chrome /
25
+ Safari reject a Secure / ``__Host-`` cookie over http); HSTS likewise stays off on loopback (the engine
26
+ emits it only over real https / ``exposure_protected``). Where the middleware is a strict no-op — the
27
+ org opt-out, or a cleartext NON-loopback context with no ``exposure_protected`` — it binds no nonce and
28
+ mutates no header, so the engine's existing static ``app.state.ui_csp`` response is emitted
29
+ byte-for-byte. This is why the engine's ``app.state.ui_csp`` seam is left set (option (b) in the lane
30
+ brief): the middleware only OVERRIDES it in a secure context and defers to it otherwise — no per-request
31
+ engine-side switch exists, so the console must own the conditional here.
32
+
33
+ **Proxy-TLS keying.** This middleware reads ``scope['scheme']`` at the OUTERMOST layer, which precedes
34
+ any inner proxy-headers scheme rewrite. Exactly like the engine's ``_cookie_secure``, a proxy that
35
+ terminates TLS and forwards cleartext to the engine must therefore declare
36
+ ``app.state.exposure_protected`` to engage the nonce CSP (a forwarded ``X-Forwarded-Proto=https`` alone
37
+ is NOT seen here). When ``exposure_protected`` is unset in such a deployment the engine's inner static
38
+ self-CSP (``app.state.ui_csp``) remains the floor on every /ui response — the surface is never left
39
+ unprotected, only un-upgraded — so the cookie-and-CSP posture stays consistent with the engine's own
40
+ exposure model rather than diverging from it.
41
+
42
+ **Middleware ordering.** ``mount_ui`` adds this AFTER the engine's ``@app.middleware("http")``
43
+ security-headers middleware, so Starlette makes it the OUTERMOST layer (``add_middleware`` inserts at
44
+ index 0): on the response path its ``send`` wrapper runs LAST and thus overrides the engine's static CSP
45
+ with the nonce CSP for effective-https /ui responses. It is a PURE ASGI middleware (not
46
+ ``BaseHTTPMiddleware``) specifically so the nonce ContextVar it binds before calling downstream
47
+ propagates into the route that renders the page — ``BaseHTTPMiddleware`` runs the endpoint in a detached
48
+ task that a var set inside its own ``dispatch`` would not reach, but a var set by an OUTER pure-ASGI
49
+ middleware before the base layer runs is copied into that task and IS visible.
50
+
51
+ **Browser-support contract (defined fallback).** These are all defense-in-depth headers a conformant
52
+ modern browser honors; an older client that ignores ``Cross-Origin-Opener-Policy`` /
53
+ ``Cross-Origin-Resource-Policy`` / ``Reporting-Endpoints`` / nonce sources / ``SameSite`` simply
54
+ DEGRADES to the prior same-origin posture — the ``SameSite=Strict`` session cookie,
55
+ ``frame-ancestors 'none'``, and the ``Sec-Fetch`` / ``Origin`` checks in :mod:`._auth` — and never
56
+ hard-fails a request. There is deliberately NO ``Cross-Origin-Embedder-Policy: require-corp``: COEP
57
+ gates EVERY subresource on an explicit CORP/CORS opt-in and would break the same-origin ``/ui/static``
58
+ assets and the ``data:`` images the /ui CSP already allows, for no isolation gain on a surface that
59
+ embeds no cross-origin content.
60
+
61
+ **Which relied-on features are actively DETECTED, and which degrade silently (ASVS 3.7.5).** The
62
+ contract above is only testable if it says, per feature, what the console does when the feature is
63
+ absent. Three sets are enumerated below, each entry in exactly one bucket — detected-and-warned, or
64
+ degrades-silently-with-a-named compensating control:
65
+
66
+ 1. every **browser-security response header** that reaches a ``/ui`` response — including the ones
67
+ emitted by the ENGINE's own security-headers middleware (``api/app.py``) rather than by this one;
68
+ 2. every **client-side browser security feature the console feature-detects** in its own scripts
69
+ (``static/app.js`` and the nonce'd shell scripts in :mod:`._html`);
70
+ 3. the **session cookie's security attributes** (``__Host-`` prefix / ``Secure`` / ``HttpOnly`` /
71
+ ``SameSite``), which no page script can observe at all.
72
+
73
+ This list is the in-code source of truth the runbook's operator-facing copy mirrors, and a CI guard
74
+ (``test_ui_csp_canary.py``) derives all three sets from the CODE — the header writes, the
75
+ ``window.<Feature>`` reads, and the ``set_cookie`` attributes — and fails if any member is missing
76
+ here or from the runbook, so a newly-shipped header, detect or cookie attribute cannot slip in
77
+ unbucketed. Anything OUTSIDE those three sets is outside the claim.
78
+
79
+ * **Secure transport context** — DETECTED and WARNED. The nonce'd page-shell script reads
80
+ ``window.isSecureContext``; false raises a visible ``role="alert"`` banner
81
+ (:mod:`._html`). Correctly inert on the loopback posture, where ``http://127.0.0.1`` IS a secure
82
+ context; it exists for the proxy-TLS mismatch the engine cannot see server-side.
83
+ * **CSP enforcement** (``Content-Security-Policy`` honoured at all) — DETECTED and WARNED. The shell
84
+ loads an UN-NONCED external canary (``/ui/static/csp-probe.js``); an enforcing browser refuses it
85
+ under ``script-src 'nonce-…' 'strict-dynamic'`` so its global stays undefined, while a browser that
86
+ does not enforce CSP runs it and the nonce'd detect raises a second ``role="alert"`` banner. This is
87
+ an ACTIVE detect that fires on the default deployment: the flag can only be set when the policy is
88
+ not being applied. Its expected violation reports are filtered out of the log by SAME-ORIGIN
89
+ blocked-URL path, per report entry — never per batch — so a real violation delivered in the same
90
+ Reporting-API POST still warns (:mod:`.routes.core`).
91
+ * **CSP nonce-source support / script execution** — DETECTED and WARNED, by the INVERSE detect. A
92
+ browser that ENFORCES CSP but does not understand ``'nonce-…'``/``'strict-dynamic'`` has no valid
93
+ script source and blocks EVERY script — app.js, both detects above, and the ASVS 14.3.1 session
94
+ watchdog — so no script-raised banner could render (the same is true with JavaScript disabled). The
95
+ shell therefore SERVER-renders a ``role="alert"`` banner (id ``mf-scripts-blocked-banner``) that a
96
+ nonce'd mark script removes from view (via an ``<html>`` class app.css keys on) on a healthy
97
+ client: fail-visible, no flash, and it stands exactly when scripts do not run. **Named residual:**
98
+ the ASVS 14.3.1 session watchdog is one of the blocked scripts, so a timed-out tab keeps its
99
+ rendered PHI page until the operator navigates. Compensating: everything the blocked scripts carry
100
+ is client-side BELT — the server still refuses every subsequent request (idle + absolute expiry,
101
+ RBAC and auditing are untouched), the expiry redirect still carries ``Clear-Site-Data``, and every
102
+ /ui HTML response is ``Cache-Control: no-store``.
103
+ * **``window.PublicKeyCredential`` (WebAuthn passkeys)** — DETECTED and WARNED. ``static/app.js``
104
+ reads it before wiring either passkey ceremony; when it is absent the button is DISABLED and the
105
+ status line says so in place ("This browser does not support passkeys." on enrolment, "…— use your
106
+ password/code." on the step-up leg), so the operator is told rather than left clicking a control
107
+ that silently does nothing. Compensating: a passkey is an ALTERNATIVE factor, never the only one —
108
+ the TOTP and password legs are untouched, so a browser without WebAuthn can still enroll MFA,
109
+ complete a step-up and sign in. (The RP-configuration failures — the ``[webauthn]`` extra absent, or
110
+ no resolvable RP id — are a SERVER-side fail-closed notice, not a browser-support case.)
111
+ * **Session-cookie security attributes — ``__Host-`` prefix / ``Secure`` / ``HttpOnly``** — DEGRADE
112
+ SILENTLY, necessarily: ``HttpOnly`` is precisely what stops a page script from reading the cookie,
113
+ so none of the three is observable client-side (``SameSite`` has its own row below for the same
114
+ reason). Compensating: they are only ever SET where a browser will honour them — the ``__Host-``
115
+ prefix and ``Secure`` key on :func:`._auth.effective_https`, so cleartext loopback keeps the plain
116
+ ``mf_session`` rather than a cookie the browser would silently reject and thereby break login —
117
+ session termination is SERVER-side (revoke + ``Clear-Site-Data``, never cookie deletion alone), and
118
+ every state-changing /ui POST carries the server-side ``Sec-Fetch-Site``/``Origin`` check, so a
119
+ browser that ignores the attributes still cannot be driven cross-site with the cookie.
120
+ * **``Cross-Origin-Opener-Policy``** — DEGRADES SILENTLY, by necessity. No browser API exposes COOP
121
+ enforcement to the page. ``window.crossOriginIsolated`` is NOT a COOP detect — it additionally
122
+ requires COEP, which is deliberately not set (above), so reading it would render a false "degraded"
123
+ banner in every browser. The compensating posture is that COOP is pure defense-in-depth here: /ui
124
+ opens no cross-origin windows and embeds no cross-origin content, so its absence costs process
125
+ isolation only, and ``frame-ancestors 'none'`` still blocks framing.
126
+ * **``Cross-Origin-Resource-Policy``** — DEGRADES SILENTLY, same rationale: no client-observable API,
127
+ and /ui serves no resource intended for cross-origin embedding.
128
+ * **``Reporting-Endpoints``** — DEGRADES SILENTLY. It only routes violation reports, so a client that
129
+ ignores it costs telemetry, never a control; the legacy ``report-uri`` directive is emitted
130
+ alongside it, so most such clients still deliver reports.
131
+ * **``SameSite=Strict`` on the session cookie** — DEGRADES SILENTLY, with a compensating control that
132
+ makes the degradation non-fatal rather than merely unwarned: a page script cannot read an HttpOnly
133
+ cookie's SameSite attribute, so the absence is undetectable client-side, but every state-changing
134
+ ``/ui`` POST — including the unauthenticated ``/ui/login`` and the gate-less ``/ui/logout`` — carries
135
+ an explicit server-side ``Sec-Fetch-Site``/``Origin`` check (:func:`._auth.assert_same_origin`,
136
+ ASVS 3.5.1). A browser that ignores SameSite therefore still cannot mount CSRF against /ui.
137
+ * **``Clear-Site-Data``** (ASVS 14.3.1; emitted by :mod:`._auth` on every login redirect and by
138
+ :mod:`.routes.core` on logout and the post-termination login render) — DEGRADES SILENTLY; Safari
139
+ has no support. Compensating: it is only the Back/bfcache belt. The session is revoked SERVER-side,
140
+ the cookie is explicitly deleted, every /ui HTML response carries ``Cache-Control: no-store``, and
141
+ the 14.3.1 watchdog blanks the rendered document synchronously before navigating away.
142
+ * **``Cache-Control: no-store``** on /ui HTML and PHI JSON (engine middleware) — DEGRADES SILENTLY: a
143
+ page script cannot observe another response's cache treatment. Compensating: ``Clear-Site-Data`` on
144
+ session termination, the watchdog's document blanking, and the server refusing every request the
145
+ resurrected page would make.
146
+ * **``X-Content-Type-Options: nosniff``** (engine middleware) — DEGRADES SILENTLY. Compensating: the
147
+ /ui static mount serves ONLY ``.css``/``.js`` from a fixed directory with correct MIME types (ASVS
148
+ 13.4.7, :mod:`._static`), and no user-supplied file is ever served from the /ui origin.
149
+ * **``X-Frame-Options: DENY``** (engine middleware) — DEGRADES SILENTLY, and is pure legacy
150
+ redundancy: the CSP's ``frame-ancestors 'none'`` is the modern control and every browser that
151
+ honours the nonce CSP honours it.
152
+ * **``Referrer-Policy: no-referrer``** (engine middleware) — DEGRADES SILENTLY. Compensating: the same
153
+ policy is ALSO carried in-document by ``<meta name="referrer" content="no-referrer">`` in every page
154
+ shell (:func:`._html.page`), /ui URLs carry opaque ids only (never PHI), and ``/ui`` links off-site
155
+ nowhere.
156
+ * **``Strict-Transport-Security``** (engine middleware, effective-https only) — DEGRADES SILENTLY.
157
+ Compensating: TLS is terminated by the documented reverse proxy, which is configured to redirect
158
+ cleartext, and the ``window.isSecureContext`` banner above makes a cleartext hop visible to the
159
+ operator.
160
+ """
161
+
162
+ from __future__ import annotations
163
+
164
+ import secrets
165
+
166
+ from starlette.datastructures import MutableHeaders
167
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
168
+
169
+ from ._auth import browser_hardening_enabled, security_headers_context
170
+ from ._html import reset_csp_nonce, set_csp_nonce
171
+
172
+ #: The route (registered in :mod:`.routes.core`) the browser POSTs CSP violation reports to, and the
173
+ #: ``Reporting-Endpoints`` group name that references it.
174
+ CSP_REPORT_PATH = "/ui/csp-report"
175
+ CSP_REPORT_GROUP = "mf-csp"
176
+
177
+ #: Cross-origin isolation headers set on every effective-https /ui HTML response.
178
+ COOP_VALUE = "same-origin"
179
+ CORP_VALUE = "same-origin"
180
+
181
+ #: Every browser-security response header :class:`UiSecurityHeadersMiddleware` writes. Declared here
182
+ #: so the ASVS 3.7.5 degrade-contract guard can read the emitted set from CODE instead of a literal in
183
+ #: the test; the guard also re-derives it from the ``send_wrapper`` source, so this tuple cannot drift
184
+ #: away from what is actually emitted (see ``test_ui_csp_canary.py``).
185
+ #:
186
+ #: Deliberately a DECLARATION with no runtime consumer — the middleware writes the headers directly so
187
+ #: the emitting code stays readable in one place. Its consumer is the contract guard, and its job is
188
+ #: to be the thing that guard compares the source against; do not "clean it up" into the send_wrapper.
189
+ SECURITY_HEADER_NAMES: tuple[str, ...] = (
190
+ "Content-Security-Policy",
191
+ "Cross-Origin-Opener-Policy",
192
+ "Cross-Origin-Resource-Policy",
193
+ "Reporting-Endpoints",
194
+ )
195
+
196
+ _NONCE_BYTES = 16 # secrets.token_urlsafe(16) -> 22 url-safe chars, ample CSP nonce entropy
197
+
198
+
199
+ def build_ui_csp(nonce: str) -> str:
200
+ """The /ui CSP for an effective-https response: the static self-only base with ``script-src``
201
+ upgraded to a per-response nonce + ``strict-dynamic`` (3.4.7/3.4.8) and CSP reporting wired to
202
+ :data:`CSP_REPORT_PATH`. ``strict-dynamic`` intentionally drops the host allowlist for scripts —
203
+ only the nonce'd first-party ``app.js`` (and anything it loads) runs; there is no inline script."""
204
+ return (
205
+ f"default-src 'self'; script-src 'nonce-{nonce}' 'strict-dynamic'; style-src 'self'; "
206
+ "img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; "
207
+ "base-uri 'none'; form-action 'self'; object-src 'none'; "
208
+ f"report-uri {CSP_REPORT_PATH}; report-to {CSP_REPORT_GROUP}"
209
+ )
210
+
211
+
212
+ def _is_ui_html_path(path: str) -> bool:
213
+ """The engine's exact /ui-HTML scope: a /ui path that is not a /ui/static asset."""
214
+ return (path == "/ui" or path.startswith("/ui/")) and not path.startswith("/ui/static")
215
+
216
+
217
+ class UiSecurityHeadersMiddleware:
218
+ """Pure-ASGI /ui browser-security hardening (see the module docstring)."""
219
+
220
+ def __init__(self, app: ASGIApp) -> None:
221
+ self.app = app
222
+
223
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
224
+ if scope["type"] != "http" or not _is_ui_html_path(scope.get("path", "")):
225
+ await self.app(scope, receive, send)
226
+ return
227
+ app_state = getattr(scope.get("app"), "state", None)
228
+ if not browser_hardening_enabled() or not security_headers_context(
229
+ app_state, scope.get("scheme", "http")
230
+ ):
231
+ # Opt-out, or a cleartext NON-loopback context with no exposure_protected: strict no-op ->
232
+ # the engine's static /ui CSP response stands byte-for-byte (the org opt-out reverts every
233
+ # scheme; a loopback secure-context now ENGAGES the http-safe headers via
234
+ # security_headers_context, ADR 0143 — see the module docstring).
235
+ await self.app(scope, receive, send)
236
+ return
237
+ nonce = secrets.token_urlsafe(_NONCE_BYTES)
238
+ csp = build_ui_csp(nonce)
239
+
240
+ async def send_wrapper(message: Message) -> None:
241
+ if message["type"] == "http.response.start":
242
+ headers = MutableHeaders(scope=message)
243
+ headers["Content-Security-Policy"] = csp
244
+ headers["Cross-Origin-Opener-Policy"] = COOP_VALUE
245
+ headers["Cross-Origin-Resource-Policy"] = CORP_VALUE
246
+ headers["Reporting-Endpoints"] = f'{CSP_REPORT_GROUP}="{CSP_REPORT_PATH}"'
247
+ await send(message)
248
+
249
+ token = set_csp_nonce(nonce)
250
+ try:
251
+ await self.app(scope, receive, send_wrapper)
252
+ finally:
253
+ reset_csp_nonce(token)
@@ -0,0 +1,22 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """The console's own :class:`AuthService` provider dependency.
4
+
5
+ A trivial re-implementation of ``api.auth_routes._service`` (get + enabled-check reading
6
+ ``app.state``) so the package never imports ``auth_routes`` — which would form a
7
+ package → auth_routes → package cycle. Identical semantics: absent/disabled auth → 503.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from fastapi import HTTPException, Request, status
13
+
14
+ from messagefoundry.api.security import get_auth
15
+ from messagefoundry.auth.service import AuthService
16
+
17
+
18
+ def _service(request: Request) -> AuthService:
19
+ auth = get_auth(request)
20
+ if auth is None or not auth.enabled:
21
+ raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "authentication is not enabled")
22
+ return auth
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Extension-allowlisting static file serving for ``/ui/static`` (ASVS 13.4.7).
4
+
5
+ Starlette's :class:`~starlette.staticfiles.StaticFiles` serves **any regular file** under its
6
+ directory. Its only containment is path-traversal defence (a ``commonpath`` check plus symlink
7
+ refusal) — there is no extension policy, no dotfile rule and no backup-suffix rule anywhere in it.
8
+ Verified empirically against the shipped Starlette: a bare mount happily serves ``.env``,
9
+ ``app.js.bak``, ``secrets.map``, ``x.py``, extension-less files and files in subdirectories.
10
+
11
+ That matters here because the console's asset directory IS the served surface: the wheel
12
+ force-includes the whole package tree with no exclude list, and dev/CI install it editable, so
13
+ ``STATIC_DIR`` resolves to the live working tree with no build step standing between a stray file and
14
+ an UNAUTHENTICATED 200 (the ``/ui/static`` prefix is excluded from the /ui auth and no-store paths).
15
+ The worst instance is invisible to review: a ``.env`` dropped in there is matched by the repo
16
+ ``.gitignore``, so ``git status``, diffs and PR review all skip it while it is served.
17
+
18
+ :class:`AllowlistedStaticFiles` closes that by construction rather than by curation: a request is
19
+ resolved only when its path's final component has exactly ONE suffix and that suffix is in
20
+ :data:`ALLOWED_STATIC_EXTENSIONS`. Everything else 404s *before* the filesystem is touched. It is the
21
+ second of three layers — the packaging manifest test (asset directory contents) and the planted-file
22
+ runtime test are the others; the runtime allowlist alone would still serve an ALLOWED-extension
23
+ accident such as a stray second ``.js``.
24
+
25
+ **Any future static mount must reuse this class**, not ``StaticFiles``: the allowlist governs the
26
+ mount it is installed on, not the process.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ from pathlib import PurePosixPath
33
+
34
+ from starlette.staticfiles import StaticFiles
35
+
36
+ __all__ = ["ALLOWED_STATIC_EXTENSIONS", "AllowlistedStaticFiles", "static_path_allowed"]
37
+
38
+ #: The ONLY file extensions ``/ui/static`` will serve — exactly the kinds of asset the console ships
39
+ #: (stylesheet, script). Lowercase, leading dot. Adding one is a reviewed security decision: it widens
40
+ #: an unauthenticated, uncached, same-origin file-serving surface.
41
+ ALLOWED_STATIC_EXTENSIONS = frozenset({".css", ".js"})
42
+
43
+
44
+ def static_path_allowed(path: str) -> bool:
45
+ """Whether ``path`` (relative, as Starlette hands it to ``lookup_path``) may be served.
46
+
47
+ Allowed only when the final component has exactly one suffix and that suffix is allow-listed.
48
+ That single rule rejects each named class at once:
49
+
50
+ * **dotfiles** — ``.env`` / ``.htpasswd``: no stem, so ``PurePosixPath.suffixes`` is empty;
51
+ * **multi-suffix backup/derived forms** — ``app.js.bak``, ``app.css.map``: two suffixes, refused
52
+ even when the LAST one is allow-listed (``notes.bak.js`` too), because a backup of a served
53
+ asset is a different artifact from the asset;
54
+ * **extension-less files** — ``notes``, ``Dockerfile``: no suffix;
55
+ * **anything else** — ``x.py``, ``connections.toml``, ``deep.key``, in this directory or any
56
+ subdirectory of it, since the rule is applied to the final component of the WHOLE relative path.
57
+ """
58
+ normalized = PurePosixPath(path.replace(os.sep, "/"))
59
+ name = normalized.name
60
+ if not name or name in (".", ".."):
61
+ return False
62
+ suffixes = PurePosixPath(name).suffixes
63
+ return len(suffixes) == 1 and suffixes[0].lower() in ALLOWED_STATIC_EXTENSIONS
64
+
65
+
66
+ class AllowlistedStaticFiles(StaticFiles):
67
+ """:class:`StaticFiles` restricted to :data:`ALLOWED_STATIC_EXTENSIONS`.
68
+
69
+ The filter lands in ``lookup_path`` — the one funnel every resolution goes through — so a
70
+ disallowed name is refused BEFORE any ``os.stat``, and returning the miss sentinel makes the base
71
+ class raise its own ``404`` (``html=False``, so there is no index/404 fallback that could resolve
72
+ a second path behind our back).
73
+ """
74
+
75
+ def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
76
+ if not static_path_allowed(path):
77
+ return "", None
78
+ return super().lookup_path(path)
@@ -0,0 +1,103 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """``mount_ui(app, deps)`` — the single entrypoint ``create_app`` calls to graft the web console onto
4
+ the engine's FastAPI app, same-origin (Option B, ADR 0065).
5
+
6
+ It (1) re-asserts the engine seam (belt-and-suspenders — the engine already asserted before building
7
+ ``deps``), (2) installs the three always-on app.state hooks the JSON engine reads when serve_ui is on
8
+ (the /ui CSP, the browser-cookie WS authorizer, the server-rendered connections fragment), (3) mounts
9
+ the package's own static assets, and (4) registers every /ui route in a fixed, test-pinned order.
10
+
11
+ The route modules are imported at THIS module's import time (eager), so every module-level
12
+ ``register_ui_action`` has fired before serving — the write-action registry is a single authoritative
13
+ module-global (``_auth._UI_WRITE_ACTIONS``). NOTE (review fix): there is deliberately NO mount-time
14
+ "every step-up route has a registry entry" self-check — that is a FALSE invariant (body-carrying
15
+ step-up POSTs map their stale-window redirect via ``reauth_next`` to a DIFFERENT registered unlock
16
+ page, so they intentionally have no own entry). Registry/route completeness is backstopped by the
17
+ moved tests + a golden route-table test instead.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from fastapi import FastAPI
23
+
24
+ from messagefoundry.api._ui_seam import UiDeps
25
+
26
+ from . import STATIC_DIR, _auth, assert_engine_seam, pages
27
+ from ._security import UiSecurityHeadersMiddleware
28
+ from ._static import AllowlistedStaticFiles
29
+ from .routes import (
30
+ account,
31
+ admin,
32
+ audit,
33
+ config,
34
+ connection_writes,
35
+ core,
36
+ monitoring,
37
+ monitoring_writes,
38
+ oidc,
39
+ search,
40
+ sso,
41
+ status,
42
+ uploaded_logs,
43
+ )
44
+
45
+ # Fixed registration order, pinned by the golden route-table test. This reproduces the pre-extraction
46
+ # order: add_auth_routes registered its admin/account/audit /ui routes first, then create_app's
47
+ # _UI_REGISTRARS (search FIRST so the literal /ui/messages/search beats /ui/messages/{id}; the literal
48
+ # bulk/purge-confirm paths are registered before {name}/purge/{scope} WITHIN connection_writes).
49
+ _REGISTRARS = (
50
+ admin,
51
+ account,
52
+ audit,
53
+ search,
54
+ core,
55
+ uploaded_logs,
56
+ monitoring,
57
+ status,
58
+ monitoring_writes,
59
+ connection_writes,
60
+ config,
61
+ sso,
62
+ # Self-gating: registers nothing unless [auth].oidc_enabled, so the golden route table
63
+ # is unchanged for the default-off build (ADR 0142 AC-1). Tail placement is safe --
64
+ # both paths are literal, with no {param} sibling anywhere in the table to shadow.
65
+ oidc,
66
+ )
67
+
68
+
69
+ def mount_ui(app: FastAPI, deps: UiDeps) -> None:
70
+ """Mount the entire /ui web console onto ``app``, wiring the moved routes to the injected
71
+ ``deps`` bundle.
72
+
73
+ Route registration is append-by-pattern and the security middleware is explicitly guarded, so a
74
+ re-mount of the SAME app does not stack a second, nonce-conflicting copy of it. The static mount
75
+ is NOT guarded — ``create_app`` builds a fresh ``FastAPI`` per call, so it is never re-mounted in
76
+ practice; a re-mount would simply shadow with an identical entry."""
77
+ assert_engine_seam(deps.engine_seam)
78
+ # Always-on seams the JSON engine reads when serve_ui is on (Option B Phase 0): the /ui CSP
79
+ # (co-versioned with app.js/app.css), the browser-cookie WS authorizer (CSWSH-guarded), and the
80
+ # server-rendered connections fragment pushed over /ws/stats. With the console absent these stay
81
+ # unset, so the security-headers middleware and /ws/stats take their JSON-only fallbacks.
82
+ app.state.ui_csp = _auth.UI_CSP
83
+ app.state.ui_ws_authorize = _auth.authorize_ui_ws
84
+ app.state.ui_connections_render = pages.connections_fragment
85
+
86
+ # ASVS 13.4.7: the console's asset tier serves ONLY allow-listed extensions. A bare StaticFiles
87
+ # serves any regular file under the directory, and this directory IS the working tree under the
88
+ # editable dev/CI install — so a stray .map/backup/.env is otherwise an unauthenticated 200. Any
89
+ # future static mount must use AllowlistedStaticFiles too; the allowlist governs the mount, not
90
+ # the process. See :mod:`._static`.
91
+ app.mount("/ui/static", AllowlistedStaticFiles(directory=str(STATIC_DIR)), name="ui-static")
92
+
93
+ for module in _REGISTRARS:
94
+ module.register(app, deps)
95
+
96
+ # Install the /ui browser-security hardening LAST so Starlette makes it the OUTERMOST middleware
97
+ # (added after the engine's security-headers middleware): its response send-wrapper runs last and
98
+ # thus owns the effective-https /ui CSP/COOP/reporting headers, while deferring to the engine's
99
+ # static app.state.ui_csp untouched over cleartext loopback (byte-identity). Guarded so a re-mount
100
+ # of the SAME app (the idempotency contract above) does not stack a second, nonce-conflicting copy.
101
+ # See :mod:`._security`.
102
+ if not any(getattr(m, "cls", None) is UiSecurityHeadersMiddleware for m in app.user_middleware):
103
+ app.add_middleware(UiSecurityHeadersMiddleware)
@@ -0,0 +1,25 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """HTML page/fragment builders for the /ui ops dashboard (ADR 0065), split by console area.
4
+
5
+ Every builder returns :class:`.._html.Markup`; every dynamic value is placed through the escaping
6
+ element builders in :mod:`.._html`, so attacker-influenced HL7/message content cannot inject markup.
7
+ No page emits inline script or ``on*`` handlers (CSP ``script-src 'self'``).
8
+
9
+ **Per-area package (ADR 0065 §multi-session-build).** Builders live in per-area modules so file
10
+ ownership matches lane ownership — a lane adds a page as a ``def`` + its name in that module's own
11
+ ``__all__`` (a lane-private edit), with **no** shared central list to collide on. Callers keep using
12
+ ``webui.pages.<fn>``; this ``__init__`` re-exports each module's public builders and is edited **only**
13
+ when a lane adds a wholly new area module.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .account import * # noqa: F401,F403
19
+ from .admin import * # noqa: F401,F403
20
+ from .audit import * # noqa: F401,F403
21
+ from .config import * # noqa: F401,F403
22
+ from .connections import * # noqa: F401,F403
23
+ from .messages import * # noqa: F401,F403
24
+ from .monitoring import * # noqa: F401,F403
25
+ from .uploaded_logs import * # noqa: F401,F403
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Shared cell/format helpers for the /ui page builders (ADR 0065).
4
+
5
+ Small, escape-neutral formatters imported by the per-area page modules (``connections``,
6
+ ``messages``, …) so the rendering conventions live in one place, never copy-pasted per module.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def _num(value: object) -> str:
13
+ """Render a count/None as text ('—' for None)."""
14
+ return "—" if value is None else str(value)
15
+
16
+
17
+ def _secs(value: float | None) -> str:
18
+ """Render an age in seconds as a compact string ('—' for None)."""
19
+ if value is None:
20
+ return "—"
21
+ return f"{value:.0f}s"