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,539 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Zero-dependency, autoescape-by-default HTML rendering for the /ui ops dashboard (ADR 0065).
|
|
4
|
+
|
|
5
|
+
Security model (the reason this exists instead of a template engine): the **only** way to place a
|
|
6
|
+
dynamic value into the page is through :func:`el`/:func:`text`, which HTML-escape by default. Markup
|
|
7
|
+
that is already known safe must be wrapped explicitly in :class:`Markup`. There is **no**
|
|
8
|
+
template-syntax escape hatch (no ``|safe``), so an un-escaped injection of attacker-influenced HL7 is
|
|
9
|
+
not expressible in a page builder. Treat every message/HL7 value as hostile data.
|
|
10
|
+
|
|
11
|
+
This keeps the browser UI at **zero new runtime dependencies** (no jinja2, no npm) — a deliberate
|
|
12
|
+
trade recorded in ADR 0065; the module is small and localized so a later swap to a template engine is
|
|
13
|
+
contained.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextvars
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from html import escape
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CSP_PROBE_SRC",
|
|
24
|
+
"SCRIPTS_BLOCKED_BANNER_ID",
|
|
25
|
+
"SCRIPTS_OK_CLASS",
|
|
26
|
+
"Markup",
|
|
27
|
+
"attr",
|
|
28
|
+
"current_csp_nonce",
|
|
29
|
+
"el",
|
|
30
|
+
"minimal_nav",
|
|
31
|
+
"page",
|
|
32
|
+
"register_nav",
|
|
33
|
+
"reset_csp_nonce",
|
|
34
|
+
"rows_table",
|
|
35
|
+
"set_csp_nonce",
|
|
36
|
+
"text",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
#: Per-response CSP nonce (ADR 0065 §hardening / BACKLOG #192, ASVS 3.4.7/3.4.8). The /ui security
|
|
40
|
+
#: middleware mints one per SECURE-CONTEXT response (effective-https OR the loopback secure-context —
|
|
41
|
+
#: ``security_headers_context``, ADR 0143) and binds it here BEFORE the route renders; :func:`page`
|
|
42
|
+
#: reads it to stamp the ``<script>`` tag so it matches that response's ``script-src 'nonce-…'`` header.
|
|
43
|
+
#: A ContextVar (not a module global) so concurrent requests never share a nonce; ``None`` when the
|
|
44
|
+
#: middleware binds none (the org opt-out, or a cleartext NON-loopback context) means no nonce is emitted
|
|
45
|
+
#: (byte-identity with the pre-#192 tag).
|
|
46
|
+
_CSP_NONCE: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
47
|
+
"mf_ui_csp_nonce", default=None
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def set_csp_nonce(nonce: str | None) -> contextvars.Token[str | None]:
|
|
52
|
+
"""Bind ``nonce`` for the current context (the /ui security middleware, per secure-context response —
|
|
53
|
+
effective-https OR the loopback secure-context, ADR 0143). Returns the reset token the middleware
|
|
54
|
+
restores in its ``finally``."""
|
|
55
|
+
return _CSP_NONCE.set(nonce)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def reset_csp_nonce(token: contextvars.Token[str | None]) -> None:
|
|
59
|
+
"""Undo a :func:`set_csp_nonce` binding (middleware teardown)."""
|
|
60
|
+
_CSP_NONCE.reset(token)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def current_csp_nonce() -> str | None:
|
|
64
|
+
"""The CSP nonce bound for this response, or ``None`` (no nonce: the org opt-out, or a cleartext
|
|
65
|
+
NON-loopback context — the loopback secure-context binds one, ADR 0143)."""
|
|
66
|
+
return _CSP_NONCE.get()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# HTML void elements never get a closing tag or children.
|
|
70
|
+
_VOID = frozenset(
|
|
71
|
+
{"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "wbr"}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Markup(str):
|
|
76
|
+
"""A string already known to be safe HTML — never re-escaped by :func:`el`/:func:`text`.
|
|
77
|
+
|
|
78
|
+
Only ever construct this from trusted, developer-authored markup (never from message/HL7 data).
|
|
79
|
+
Results of :func:`el`/:func:`page` are ``Markup`` so builders compose without double-escaping.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
__slots__ = ()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def text(value: object) -> Markup:
|
|
86
|
+
"""Escape any value to safe HTML text. ``Markup`` passes through; ``None`` renders empty."""
|
|
87
|
+
if isinstance(value, Markup):
|
|
88
|
+
return value
|
|
89
|
+
return Markup(escape("" if value is None else str(value), quote=True))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _render_child(child: object) -> str:
|
|
93
|
+
if isinstance(child, Markup):
|
|
94
|
+
return child
|
|
95
|
+
if isinstance(child, (list, tuple)):
|
|
96
|
+
return "".join(_render_child(c) for c in child)
|
|
97
|
+
return escape("" if child is None else str(child), quote=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def attr(name: str, value: object) -> Markup:
|
|
101
|
+
"""Render a single escaped ``name="value"`` attribute (both sides escaped)."""
|
|
102
|
+
return Markup(f'{escape(name)}="{escape(str(value), quote=True)}"')
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def el(tag: str, *children: object, **attrs: object) -> Markup:
|
|
106
|
+
"""Build an element with escaped attributes and escaped children.
|
|
107
|
+
|
|
108
|
+
Attribute keys map ``_`` → ``-`` and a trailing ``_`` is stripped (so ``class_`` → ``class``,
|
|
109
|
+
``hx_get`` → ``hx-get``). A ``None``/``False`` attribute value is omitted; ``True`` renders a bare
|
|
110
|
+
attribute. Children that are :class:`Markup` pass through; any other value is HTML-escaped — so a
|
|
111
|
+
raw ``str`` (e.g. an HL7 field) can never inject markup.
|
|
112
|
+
"""
|
|
113
|
+
parts: list[str] = [f"<{escape(tag)}"]
|
|
114
|
+
for key, value in attrs.items():
|
|
115
|
+
if value is None or value is False:
|
|
116
|
+
continue
|
|
117
|
+
name = key.rstrip("_").replace("_", "-")
|
|
118
|
+
if value is True:
|
|
119
|
+
parts.append(f" {escape(name)}")
|
|
120
|
+
else:
|
|
121
|
+
parts.append(f' {escape(name)}="{escape(str(value), quote=True)}"')
|
|
122
|
+
parts.append(">")
|
|
123
|
+
if tag in _VOID:
|
|
124
|
+
return Markup("".join(parts))
|
|
125
|
+
for child in children:
|
|
126
|
+
parts.append(_render_child(child))
|
|
127
|
+
parts.append(f"</{escape(tag)}>")
|
|
128
|
+
return Markup("".join(parts))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def page(
|
|
132
|
+
title: str,
|
|
133
|
+
*body: object,
|
|
134
|
+
nav: object = None,
|
|
135
|
+
active: str = "",
|
|
136
|
+
head_extra: object = None,
|
|
137
|
+
) -> Markup:
|
|
138
|
+
"""Wrap page ``body`` in the shared document chrome (doctype, head, nav, main).
|
|
139
|
+
|
|
140
|
+
``title`` and all ``body`` content are escaped by the element builders. The head links the
|
|
141
|
+
same-origin ``/ui/static`` assets. There are no ``on*`` handlers anywhere.
|
|
142
|
+
|
|
143
|
+
**The 3.7.5 hardening detects, all emitted on the single ``nonce is not None`` gate** — a
|
|
144
|
+
per-response CSP nonce is bound, i.e. an effective-https response OR the loopback secure-context
|
|
145
|
+
(``security_headers_context``, ADR 0143). Four artifacts ship together, or none of them do:
|
|
146
|
+
|
|
147
|
+
* a NONCE'D inline mark script that stamps :data:`SCRIPTS_OK_CLASS` on ``<html>`` (and, as a belt,
|
|
148
|
+
hides the banner below directly once the body is parsed);
|
|
149
|
+
* an UN-NONCED external ``<script src=`` :data:`CSP_PROBE_SRC` ``>`` — the CSP-enforcement canary,
|
|
150
|
+
deliberately NOT nonced: an enforcing browser must refuse it, so a nonce would invert the signal;
|
|
151
|
+
* a NONCE'D inline detect that raises the insecure-context and CSP-degraded banners;
|
|
152
|
+
* the server-rendered :data:`SCRIPTS_BLOCKED_BANNER_ID` ``role="alert"`` in ``<body>``, which the
|
|
153
|
+
mark script above removes from view on a healthy client.
|
|
154
|
+
|
|
155
|
+
With no nonce bound (the org opt-out, or a cleartext NON-loopback context) NONE of the four is
|
|
156
|
+
emitted and the shell is byte-identical to the pre-hardening page: no inline script the static
|
|
157
|
+
``script-src 'self'`` CSP would block, and no canary that same policy would ALLOW (which would read
|
|
158
|
+
as "CSP not enforced" on a perfectly conforming browser).
|
|
159
|
+
|
|
160
|
+
``head_extra`` appends markup to the ``<head>`` and is ``None`` for every existing caller, so the
|
|
161
|
+
rendered document is byte-identical unless a caller opts in. It exists for the federated-login
|
|
162
|
+
landing hop (ADR 0142), which needs a ``<meta http-equiv="refresh">``: a pragma directive is only
|
|
163
|
+
conforming inside ``<head>``, and relying on browsers' tolerance of one in ``<body>`` would make a
|
|
164
|
+
security-critical redirect depend on undefined behaviour.
|
|
165
|
+
"""
|
|
166
|
+
nonce = current_csp_nonce()
|
|
167
|
+
head_parts: list[Markup] = [
|
|
168
|
+
el("meta", charset="utf-8"),
|
|
169
|
+
el("meta", name="viewport", content="width=device-width, initial-scale=1"),
|
|
170
|
+
el("meta", name="referrer", content="no-referrer"),
|
|
171
|
+
el("title", f"{title} — MessageFoundry"),
|
|
172
|
+
el("link", rel="stylesheet", href="/ui/static/app.css"),
|
|
173
|
+
# First-party live-poll script (no third-party JS). On a SECURE-CONTEXT response (effective-https
|
|
174
|
+
# OR the loopback secure-context — ``security_headers_context``, ADR 0143) the /ui security
|
|
175
|
+
# middleware binds a per-response CSP nonce that stamps this tag + the matching
|
|
176
|
+
# ``script-src 'nonce-…' 'strict-dynamic'`` header (ADR 0065 §hardening / #192); when no nonce is
|
|
177
|
+
# bound (opt-out / cleartext non-loopback) the nonce is None and the tag is byte-identical (CSP:
|
|
178
|
+
# script-src 'self').
|
|
179
|
+
el("script", src="/ui/static/app.js", defer=True, nonce=nonce),
|
|
180
|
+
]
|
|
181
|
+
# 3.7.5 client-side hardening detects: emitted ONLY when a per-response nonce is bound (a
|
|
182
|
+
# secure-context response — effective-https OR loopback, ADR 0143). With no nonce bound (opt-out /
|
|
183
|
+
# cleartext non-loopback) ``nonce is None`` -> the shell is byte-identical AND we never emit an
|
|
184
|
+
# inline <script> the ``script-src 'self'`` CSP would block (nor the canary, which that policy
|
|
185
|
+
# would ALLOW as 'self' and so would falsely read as "CSP not enforced").
|
|
186
|
+
if nonce is not None:
|
|
187
|
+
# (a0) The SCRIPTS-RUNNING MARK: a nonce'd inline script that flags the document element, so
|
|
188
|
+
# the server-rendered ``mf-scripts-blocked-banner`` below is hidden by app.css BEFORE first
|
|
189
|
+
# paint on a healthy client (no flash) and STANDS on a client that runs no script at all. It
|
|
190
|
+
# is first in document order so the mark is set as early as possible, and it is the inverse
|
|
191
|
+
# detect to the canary: the canary catches "CSP not enforced", this catches "our own nonce'd
|
|
192
|
+
# scripts did not run" — a browser that ENFORCES CSP but does not understand nonce sources
|
|
193
|
+
# blocks every script under ``script-src 'nonce-…' 'strict-dynamic'`` (app.js, the detect
|
|
194
|
+
# below, and the 14.3.1 session watchdog), so no script-raised banner could ever render.
|
|
195
|
+
head_parts.append(el("script", Markup(_SCRIPTS_RUNNING_MARK_JS), nonce=nonce))
|
|
196
|
+
# (a) The CSP-ENFORCEMENT CANARY: an UN-NONCED external script. Parser-blocking (no defer/
|
|
197
|
+
# async) so it resolves BEFORE the nonce'd detect below runs — classic scripts execute in
|
|
198
|
+
# document order. Under ``script-src 'nonce-…' 'strict-dynamic'`` an enforcing browser refuses
|
|
199
|
+
# it before the fetch and ``window.__mfCspProbe`` stays undefined; a browser that does not
|
|
200
|
+
# enforce CSP runs it. An EXTERNAL probe rather than an inline one is deliberate: its blocked
|
|
201
|
+
# URL is a unique discriminator, so the one expected violation report can be filtered out of
|
|
202
|
+
# the log by path WITHOUT a filter broad enough to also swallow the report a real inline XSS
|
|
203
|
+
# injection would produce (which is indistinguishable from a blocked inline canary), and
|
|
204
|
+
# without adding ``'report-sample'`` — which would put attacker-influenced script text into
|
|
205
|
+
# the general log.
|
|
206
|
+
head_parts.append(el("script", src=CSP_PROBE_SRC))
|
|
207
|
+
# (b) The nonce'd detect + banner script, which reads (a)'s outcome. Both banners
|
|
208
|
+
# degrade-never-block (see ``_INSECURE_CONTEXT_WARN_JS`` / ``_CSP_NOT_ENFORCED_WARN_JS``).
|
|
209
|
+
head_parts.append(
|
|
210
|
+
el("script", Markup(_INSECURE_CONTEXT_WARN_JS + _CSP_NOT_ENFORCED_WARN_JS), nonce=nonce)
|
|
211
|
+
)
|
|
212
|
+
if head_extra is not None:
|
|
213
|
+
head_parts.append(Markup(str(head_extra)))
|
|
214
|
+
head = Markup("".join(head_parts))
|
|
215
|
+
header = nav if nav is not None else _default_nav(active)
|
|
216
|
+
# The scripts-blocked banner is SERVER-rendered (plain HTML, no script needed to show it) and
|
|
217
|
+
# removed from view by app.css only once the nonce'd mark script has run — fail-VISIBLE. Emitted
|
|
218
|
+
# on the same gate as the head detects, so the no-nonce shell stays byte-identical.
|
|
219
|
+
body_parts: list[object] = []
|
|
220
|
+
if nonce is not None:
|
|
221
|
+
body_parts.append(_SCRIPTS_BLOCKED_BANNER)
|
|
222
|
+
body_parts.extend((header, el("main", *body)))
|
|
223
|
+
document = Markup(
|
|
224
|
+
"<!doctype html>"
|
|
225
|
+
+ el(
|
|
226
|
+
"html",
|
|
227
|
+
el("head", head),
|
|
228
|
+
el("body", *body_parts),
|
|
229
|
+
lang="en",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return document
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# The top-nav registry (key, href, label), in display order. Seeded with the core phase-0 items; a
|
|
236
|
+
# page lane appends ONE entry via register_nav() co-located with its builder, so parallel lanes never
|
|
237
|
+
# collide on a central literal (ADR 0065 §multi-session-build). Display order = registration order.
|
|
238
|
+
_NAV_ITEMS: list[tuple[str, str, str]] = [
|
|
239
|
+
("dashboard", "/ui", "Connections"),
|
|
240
|
+
("messages", "/ui/messages", "Messages"),
|
|
241
|
+
("dead-letters", "/ui/dead-letters", "Dead letters"),
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def register_nav(key: str, href: str, label: str) -> None:
|
|
246
|
+
"""Register a top-nav item (idempotent by ``key``; appended at the tail = displayed last).
|
|
247
|
+
|
|
248
|
+
A read/admin page lane calls this at import from its own page module to add itself to the nav
|
|
249
|
+
without editing this file — the append-only seam that keeps parallel lanes conflict-free.
|
|
250
|
+
"""
|
|
251
|
+
if not any(existing == key for existing, _href, _label in _NAV_ITEMS):
|
|
252
|
+
_NAV_ITEMS.append((key, href, label))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def wordmark(*, tm: bool = False) -> Markup:
|
|
256
|
+
"""The **MessageFoundry** wordmark, per the brand wordmark guidelines (June 2026): the single
|
|
257
|
+
camelCase word with ``Message`` in the base text color and ``Foundry`` in molten amber
|
|
258
|
+
(``#f59e0b``, the ``--foundry`` token). ``tm=True`` appends the superscript ™ — set it on the
|
|
259
|
+
primary lockup (the masthead) and the most-prominent appearance (the sign-in heading), and omit
|
|
260
|
+
it on repeated or running-text mentions. The amber stays confined to this mark and headings —
|
|
261
|
+
never body copy. ``Message`` and ``Foundry`` are adjacent with no separating space so the mark
|
|
262
|
+
renders as one word.
|
|
263
|
+
"""
|
|
264
|
+
parts: list[object] = ["Message", el("span", "Foundry", class_="wm-foundry")]
|
|
265
|
+
if tm:
|
|
266
|
+
parts.append(el("sup", "™", class_="wm-tm"))
|
|
267
|
+
return el("span", *parts, class_="wordmark")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
#: Top-nav groups, each rendered as a dropdown: (menu label, member keys). A registered key not listed
|
|
271
|
+
#: here falls into a trailing "More" menu, so a new page lane still appears without editing this.
|
|
272
|
+
_NAV_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
273
|
+
("Traffic", ("dashboard", "messages", "dead-letters", "events")),
|
|
274
|
+
("Monitoring", ("status", "alerts", "flow", "audit", "uploaded-logs")),
|
|
275
|
+
("Admin", ("users", "config")),
|
|
276
|
+
("Account", ("account", "security-events")),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Two live status glyphs pinned to the right of the nav (left of Sign out): the alerts bell and the
|
|
281
|
+
# engine-health heart. They render NEUTRAL (gray) with data hooks; app.js polls GET /ui/nav-status (~15s,
|
|
282
|
+
# from every page) and recolors them — green/orange/blinking-red for engine health, severity-colored or gray
|
|
283
|
+
# for alerts. Monochrome inline SVG with fill=currentColor so a CSS `color` drives the tint (emoji can't be
|
|
284
|
+
# recolored). The SVGs are static, hand-authored Markup constants with NO data interpolation — no injection
|
|
285
|
+
# surface under the CSP. (Material-style glyph paths, 24×24 viewBox.)
|
|
286
|
+
_BELL_SVG = Markup(
|
|
287
|
+
'<svg class="statglyph" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" '
|
|
288
|
+
'focusable="false"><path fill="currentColor" d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07'
|
|
289
|
+
"-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1"
|
|
290
|
+
'l-2-2z"/></svg>'
|
|
291
|
+
)
|
|
292
|
+
_HEART_SVG = Markup(
|
|
293
|
+
'<svg class="statglyph" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" '
|
|
294
|
+
'focusable="false"><path fill="currentColor" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 '
|
|
295
|
+
"4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 "
|
|
296
|
+
'6.86-8.55 11.54L12 21.35z"/></svg>'
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# 3.7.5 (ASVS): a nonce'd, client-side insecure-context banner. window.isSecureContext is the
|
|
300
|
+
# transport precondition a page script can read directly; CSP-nonce ENFORCEMENT is detected
|
|
301
|
+
# separately and actively by the un-nonced canary (``_CSP_NOT_ENFORCED_WARN_JS`` below reads its
|
|
302
|
+
# outcome). COOP/CORP enforcement remains genuinely undetectable from inside the page — no browser API
|
|
303
|
+
# exposes it — and is documented as degrade-silent-with-rationale rather than warned. Both banners are
|
|
304
|
+
# a VISIBLE signal, never an active block (degrade-never-block): each only inserts a DOM node, wrapped
|
|
305
|
+
# in try/catch, textContent-only, NO inline on* handler.
|
|
306
|
+
# Static developer-authored JS with no data interpolation (Markup, like the nav SVGs) => not an
|
|
307
|
+
# injection surface. page() emits it whenever a per-response nonce is bound — a secure-context response
|
|
308
|
+
# (effective-https OR the loopback secure-context, ADR 0143). With no nonce bound (opt-out / cleartext
|
|
309
|
+
# non-loopback) the shell stays byte-identical AND the `script-src 'self'` CSP would otherwise block an
|
|
310
|
+
# un-nonced inline script. http://localhost / http://127.0.0.1 are themselves secure contexts, so even
|
|
311
|
+
# when the loopback shell now carries this nonce'd script, window.isSecureContext is true and it never
|
|
312
|
+
# trips (no banner shown).
|
|
313
|
+
_INSECURE_CONTEXT_WARN_JS = Markup(
|
|
314
|
+
"(function(){try{if(window.isSecureContext===false){"
|
|
315
|
+
"var show=function(){"
|
|
316
|
+
"if(!document.body||document.getElementById('mf-insecure-context-banner'))return;"
|
|
317
|
+
"var b=document.createElement('div');b.id='mf-insecure-context-banner';"
|
|
318
|
+
"b.className='mf-insecure-banner';b.setAttribute('role','alert');"
|
|
319
|
+
"b.textContent='Insecure connection: this console is not being served to your browser over "
|
|
320
|
+
"HTTPS. Browser hardening (secure cookies, COOP, CSP nonces) is degraded. Reach it over the "
|
|
321
|
+
"https:// origin behind the documented reverse proxy.';"
|
|
322
|
+
"document.body.insertBefore(b,document.body.firstChild);};"
|
|
323
|
+
"if(document.body){show();}else{document.addEventListener('DOMContentLoaded',show);}"
|
|
324
|
+
"}}catch(e){}})();"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
#: The un-nonced CSP-enforcement canary the shell loads (see :func:`page`). A browser enforcing the
|
|
328
|
+
#: nonce CSP blocks it; the resulting violation report is the ONE expected report, filtered out of the
|
|
329
|
+
#: log by this exact path in ``routes.core`` (ASVS 3.7.5).
|
|
330
|
+
CSP_PROBE_SRC = "/ui/static/csp-probe.js"
|
|
331
|
+
|
|
332
|
+
#: The class the nonce'd mark script stamps on ``<html>`` and the id of the server-rendered banner it
|
|
333
|
+
#: thereby hides (``app.css`` owns the hiding rule). Named constants because THREE artifacts must
|
|
334
|
+
#: agree — the mark script, the banner markup, and the stylesheet.
|
|
335
|
+
SCRIPTS_OK_CLASS = "mf-scripts-ok"
|
|
336
|
+
SCRIPTS_BLOCKED_BANNER_ID = "mf-scripts-blocked-banner"
|
|
337
|
+
|
|
338
|
+
# 3.7.5: the nonce'd mark. Static developer-authored JS, no data interpolation (Markup, like the nav
|
|
339
|
+
# SVGs) => not an injection surface. ``className +=`` rather than classList for maximum compatibility
|
|
340
|
+
# with exactly the old/limited clients this detect exists for.
|
|
341
|
+
#
|
|
342
|
+
# TWO mechanisms, deliberately: the class stamp (hidden by app.css BEFORE first paint, no flash) is the
|
|
343
|
+
# LOAD-BEARING path — it is the only one that can work on a client that runs no script at all, which is
|
|
344
|
+
# the case the banner exists for. The direct ``style.display`` hide is a BELT against a warn control
|
|
345
|
+
# that cries wolf on a healthy client: the stylesheet link carries no cache-buster (adding one would
|
|
346
|
+
# break the byte-identity of the no-nonce shell, a deliberate invariant), and the static mount emits
|
|
347
|
+
# etag/last-modified but no Cache-Control, so a browser applying heuristic freshness can render
|
|
348
|
+
# post-upgrade HTML against a pre-upgrade app.css that has no hiding rule yet. Scripts run in that
|
|
349
|
+
# scenario, so the belt fires and the banner never appears on a conforming client. The banner lives in
|
|
350
|
+
# <body>, which the head-parse-time mark script has not reached yet, so the hide is deferred to
|
|
351
|
+
# DOMContentLoaded (and runs immediately if the document is already past parsing).
|
|
352
|
+
_SCRIPTS_RUNNING_MARK_JS = Markup(
|
|
353
|
+
"(function(){try{document.documentElement.className+=' "
|
|
354
|
+
+ SCRIPTS_OK_CLASS
|
|
355
|
+
+ "';var hide=function(){var b=document.getElementById('"
|
|
356
|
+
+ SCRIPTS_BLOCKED_BANNER_ID
|
|
357
|
+
+ "');if(b){b.style.display='none';}};"
|
|
358
|
+
"if(document.readyState!=='loading'){hide();}"
|
|
359
|
+
"else{document.addEventListener('DOMContentLoaded',hide);}"
|
|
360
|
+
"}catch(e){}})();"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
#: 3.7.5: the FAIL-VISIBLE half of the detect pair. The canary catches a browser that does not enforce
|
|
364
|
+
#: CSP; this catches the inverse — a browser that ENFORCES CSP but does not understand ``'nonce-…'`` /
|
|
365
|
+
#: ``'strict-dynamic'`` sources (and, incidentally, a browser with JavaScript disabled). Under
|
|
366
|
+
#: ``script-src 'nonce-…' 'strict-dynamic'`` such a client has no valid script source at all, so it
|
|
367
|
+
#: blocks app.js, both detect scripts AND the 14.3.1 session watchdog — no script-raised banner could
|
|
368
|
+
#: ever render, and the console would silently lose its client-side PHI controls. So the warning is
|
|
369
|
+
#: SERVER-rendered and visible by default; ``app.css`` hides it under :data:`SCRIPTS_OK_CLASS`, which
|
|
370
|
+
#: only the nonce'd mark script above can set. A client that blocks scripts leaves it standing.
|
|
371
|
+
_SCRIPTS_BLOCKED_BANNER = el(
|
|
372
|
+
"div",
|
|
373
|
+
"This browser is not running the console's scripts: Content-Security-Policy nonce sources are "
|
|
374
|
+
"unsupported or JavaScript is disabled. Client-side protections — the automatic session "
|
|
375
|
+
"logoff that clears this page, the insecure-connection detect and live status — are NOT "
|
|
376
|
+
"active. Server-side session expiry, permissions and auditing still apply. Use a current "
|
|
377
|
+
"browser with JavaScript enabled.",
|
|
378
|
+
id=SCRIPTS_BLOCKED_BANNER_ID,
|
|
379
|
+
class_="mf-insecure-banner",
|
|
380
|
+
role="alert",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# 3.7.5 (ASVS): the ACTIVE CSP-enforcement detect, and the reason the 'JS cannot feature-detect nonce
|
|
384
|
+
# enforcement' limitation no longer holds for THIS header. ``window.__mfCspProbe`` can only be true if
|
|
385
|
+
# the un-nonced canary script executed, which the nonce CSP forbids — so a truthy flag is positive
|
|
386
|
+
# evidence that the browser is NOT enforcing the policy, on the default deployment, where the
|
|
387
|
+
# isSecureContext banner is correctly inert (http://127.0.0.1 IS a secure context). Same
|
|
388
|
+
# degrade-never-block shape as the banner above: a DOM node, try/catch, textContent only, no on*
|
|
389
|
+
# handler, no data interpolation (Markup, like the nav SVGs) => not an injection surface.
|
|
390
|
+
_CSP_NOT_ENFORCED_WARN_JS = Markup(
|
|
391
|
+
"(function(){try{if(window.__mfCspProbe===true){"
|
|
392
|
+
"var show=function(){"
|
|
393
|
+
"if(!document.body||document.getElementById('mf-csp-degraded-banner'))return;"
|
|
394
|
+
"var b=document.createElement('div');b.id='mf-csp-degraded-banner';"
|
|
395
|
+
"b.className='mf-insecure-banner';b.setAttribute('role','alert');"
|
|
396
|
+
"b.textContent='This browser does not enforce Content-Security-Policy: console hardening is "
|
|
397
|
+
"degraded and script-injection defenses are not being applied. Use a current browser to reach "
|
|
398
|
+
"this console.';"
|
|
399
|
+
"document.body.insertBefore(b,document.body.firstChild);};"
|
|
400
|
+
"if(document.body){show();}else{document.addEventListener('DOMContentLoaded',show);}"
|
|
401
|
+
"}}catch(e){}})();"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _nav_status_icons() -> Markup:
|
|
406
|
+
"""The alerts bell + engine-health heart, in that order (alerts left of the heart). Each is a LINK to
|
|
407
|
+
its detail page — the bell to /ui/alerts, the heart to /ui/status — so a colored glyph is a one-click
|
|
408
|
+
path to the related items. Neutral until the first ``/ui/nav-status`` poll recolors them + sets a live
|
|
409
|
+
aria-label (app.js). No ``role=status``: as a link the state rides the aria-label (announced on focus),
|
|
410
|
+
not a live region that would re-announce every 15s poll. The container carries the app.js hook."""
|
|
411
|
+
bell = el(
|
|
412
|
+
"a",
|
|
413
|
+
_BELL_SVG,
|
|
414
|
+
href="/ui/alerts",
|
|
415
|
+
class_="navstat alerts-unknown",
|
|
416
|
+
data_mf_nav_alerts=True,
|
|
417
|
+
title="Active alerts",
|
|
418
|
+
aria_label="Active alerts",
|
|
419
|
+
)
|
|
420
|
+
heart = el(
|
|
421
|
+
"a",
|
|
422
|
+
_HEART_SVG,
|
|
423
|
+
href="/ui/status",
|
|
424
|
+
class_="navstat health-unknown",
|
|
425
|
+
data_mf_nav_health=True,
|
|
426
|
+
title="Engine health",
|
|
427
|
+
aria_label="Engine health",
|
|
428
|
+
)
|
|
429
|
+
return el("div", bell, heart, class_="navstatus", data_mf_nav_status=True)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _logout_form() -> Markup:
|
|
433
|
+
"""The one-click Sign-out control: a tiny same-origin POST form (form-action 'self'), wired to the
|
|
434
|
+
real server-side revocation. THE single markup site — :func:`_default_nav` and
|
|
435
|
+
:func:`minimal_nav` both render this, so the affordance can never drift between the full chrome and
|
|
436
|
+
the confinement chrome (ASVS 7.4.4)."""
|
|
437
|
+
return el(
|
|
438
|
+
"form",
|
|
439
|
+
el("button", "Sign out", type="submit"),
|
|
440
|
+
method="post",
|
|
441
|
+
action="/ui/logout",
|
|
442
|
+
class_="logout",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _default_nav(active: str) -> Markup:
|
|
447
|
+
by_key = {key: (key, href, label) for key, href, label in _NAV_ITEMS}
|
|
448
|
+
seen: set[str] = set()
|
|
449
|
+
|
|
450
|
+
def _link(item: tuple[str, str, str]) -> Markup:
|
|
451
|
+
key, href, label = item
|
|
452
|
+
return el("a", label, href=href, class_="active" if key == active else None)
|
|
453
|
+
|
|
454
|
+
def _dropdown(glabel: str, items: list[tuple[str, str, str]]) -> Markup:
|
|
455
|
+
# CSS-only dropdown: opens on :hover AND :focus-within, so it's keyboard-reachable with NO JS
|
|
456
|
+
# (stays within the script-src 'self' CSP). The toggle is a <button> (a menu opener, not a link)
|
|
457
|
+
# and shows active when the current page is one of its members; the items inside navigate.
|
|
458
|
+
active_group = any(item[0] == active for item in items)
|
|
459
|
+
top = el(
|
|
460
|
+
"button",
|
|
461
|
+
f"{glabel} ▾",
|
|
462
|
+
type="button",
|
|
463
|
+
# aria-haspopup marks it a menu opener; aria-expanded is intentionally omitted — a CSS-only
|
|
464
|
+
# menu can't truthfully toggle it without JS, so an honest static button beats a lying attr.
|
|
465
|
+
aria_haspopup="menu",
|
|
466
|
+
class_="navtop active" if active_group else "navtop",
|
|
467
|
+
)
|
|
468
|
+
menu = el("div", *[_link(i) for i in items], class_="navmenu")
|
|
469
|
+
return el("div", top, menu, class_="navgroup")
|
|
470
|
+
|
|
471
|
+
groups: list[object] = []
|
|
472
|
+
for glabel, keys in _NAV_GROUPS:
|
|
473
|
+
items = [by_key[k] for k in keys if k in by_key]
|
|
474
|
+
seen.update(k for k in keys if k in by_key)
|
|
475
|
+
if items:
|
|
476
|
+
groups.append(_dropdown(glabel, items))
|
|
477
|
+
extra = [item for item in _NAV_ITEMS if item[0] not in seen] # future lanes, ungrouped
|
|
478
|
+
if extra:
|
|
479
|
+
groups.append(_dropdown("More", extra))
|
|
480
|
+
|
|
481
|
+
brand = el("a", wordmark(tm=True), href="/ui", class_="brand")
|
|
482
|
+
# Right cluster: the live status glyphs then Sign out, grouped so nav's space-between keeps the links
|
|
483
|
+
# left and this block flush right (heart sits directly left of Sign out, alerts left of the heart).
|
|
484
|
+
right = el("div", _nav_status_icons(), _logout_form(), class_="navright")
|
|
485
|
+
# ``data-mf-session-watchdog`` (14.3.1) rides the <nav> element deliberately: a nav is rendered
|
|
486
|
+
# ONLY on an authenticated page (the two unauthenticated entry pages pass nav=Markup("")), so the
|
|
487
|
+
# hook is present exactly where a live session's rendered PHI needs discarding when that session
|
|
488
|
+
# ends — with no per-render authentication plumbing to keep in sync. minimal_nav carries it too.
|
|
489
|
+
return el(
|
|
490
|
+
"nav",
|
|
491
|
+
el("div", brand, *groups, class_="navlinks"),
|
|
492
|
+
right,
|
|
493
|
+
data_mf_session_watchdog=True,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def minimal_nav() -> Markup:
|
|
498
|
+
"""The **confinement chrome**: the brand wordmark plus the one-click POST Sign-out form, and
|
|
499
|
+
nothing else (ASVS 7.4.4).
|
|
500
|
+
|
|
501
|
+
Authenticated pages that deliberately drop the full nav — step-up re-auth, the forced
|
|
502
|
+
must-change-password page, passkey enrolment, the federated landing hop — used to render
|
|
503
|
+
``nav=Markup("")`` and therefore no logout affordance at all. The must-change page is the severe
|
|
504
|
+
case: :func:`._auth.require_ui` 303s such a session back to it from every other ``/ui`` route, so a
|
|
505
|
+
confined operator had **no reachable sign-out** even though ``POST /ui/logout`` would have accepted
|
|
506
|
+
them (that route carries no ``Depends`` gate precisely so it can).
|
|
507
|
+
|
|
508
|
+
It reuses the SAME logout form :func:`_default_nav` renders, so there is one markup site to change.
|
|
509
|
+
It deliberately omits the nav groups (preserving each page's focus/confinement intent) **and**
|
|
510
|
+
:func:`_nav_status_icons` — those glyphs carry the ``data-mf-nav-status`` hook that makes ``app.js``
|
|
511
|
+
poll ``GET /ui/nav-status`` every ~15s, and that route needs ``monitoring:read``; on a
|
|
512
|
+
reauth/must-change/passkey page the poll would 303-loop to the login or change-password page. The
|
|
513
|
+
hook these pages DO keep is the session watchdog (14.3.1), which lives on the ``<nav>`` element
|
|
514
|
+
itself — see :func:`_default_nav`.
|
|
515
|
+
"""
|
|
516
|
+
brand = el("a", wordmark(tm=True), href="/ui", class_="brand")
|
|
517
|
+
right = el("div", _logout_form(), class_="navright")
|
|
518
|
+
return el("nav", el("div", brand, class_="navlinks"), right, data_mf_session_watchdog=True)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def rows_table(
|
|
522
|
+
headers: Iterable[str], rows: Iterable[Iterable[object]], *, adjustable: bool = True
|
|
523
|
+
) -> Markup:
|
|
524
|
+
"""A table whose header cells and every body cell are escaped (cells accept ``Markup`` for links).
|
|
525
|
+
|
|
526
|
+
``adjustable`` (default) marks it ``data-mf-table`` so ``app.js`` enhances it in the browser with
|
|
527
|
+
click-to-sort + drag-to-resize columns (remembered per table). Use it for DATA GRIDS — the connections
|
|
528
|
+
dashboard, message/audit lists — where sorting and resizing earn their keep.
|
|
529
|
+
|
|
530
|
+
Pass ``adjustable=False`` for small **key/value readout** tables (status, connection detail, config
|
|
531
|
+
reload): they render as a plain full-width table (class ``info``) whose long values WRAP instead of the
|
|
532
|
+
fixed-layout grid's explicit width — so they never show a horizontal scrollbar, and they drop the
|
|
533
|
+
sort/resize UI a 2-column readout doesn't need. Purely presentational; with JS off both render plainly.
|
|
534
|
+
"""
|
|
535
|
+
head = el("tr", *[el("th", h) for h in headers])
|
|
536
|
+
body = [el("tr", *[el("td", c) for c in row]) for row in rows]
|
|
537
|
+
if adjustable:
|
|
538
|
+
return el("table", el("thead", head), el("tbody", *body), class_="grid", data_mf_table=True)
|
|
539
|
+
return el("table", el("thead", head), el("tbody", *body), class_="grid info")
|