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.
- messagefoundry_webconsole/__init__.py +128 -0
- messagefoundry_webconsole/_auth.py +841 -0
- messagefoundry_webconsole/_html.py +539 -0
- messagefoundry_webconsole/_security.py +253 -0
- messagefoundry_webconsole/_service.py +22 -0
- messagefoundry_webconsole/_static.py +78 -0
- messagefoundry_webconsole/mount.py +103 -0
- messagefoundry_webconsole/pages/__init__.py +25 -0
- messagefoundry_webconsole/pages/_common.py +21 -0
- messagefoundry_webconsole/pages/account.py +771 -0
- messagefoundry_webconsole/pages/admin.py +480 -0
- messagefoundry_webconsole/pages/audit.py +66 -0
- messagefoundry_webconsole/pages/config.py +113 -0
- messagefoundry_webconsole/pages/connections.py +519 -0
- messagefoundry_webconsole/pages/messages.py +689 -0
- messagefoundry_webconsole/pages/monitoring.py +853 -0
- messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
- messagefoundry_webconsole/routes/__init__.py +6 -0
- messagefoundry_webconsole/routes/_common.py +45 -0
- messagefoundry_webconsole/routes/account.py +451 -0
- messagefoundry_webconsole/routes/admin.py +505 -0
- messagefoundry_webconsole/routes/audit.py +39 -0
- messagefoundry_webconsole/routes/config.py +67 -0
- messagefoundry_webconsole/routes/connection_writes.py +248 -0
- messagefoundry_webconsole/routes/core.py +1077 -0
- messagefoundry_webconsole/routes/monitoring.py +94 -0
- messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
- messagefoundry_webconsole/routes/oidc.py +172 -0
- messagefoundry_webconsole/routes/search.py +204 -0
- messagefoundry_webconsole/routes/sso.py +88 -0
- messagefoundry_webconsole/routes/status.py +178 -0
- messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
- messagefoundry_webconsole/static/app.css +345 -0
- messagefoundry_webconsole/static/app.js +1506 -0
- messagefoundry_webconsole/static/csp-probe.js +9 -0
- messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
- messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
- messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
- 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"
|