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,1077 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Core /ui pages: login/logout, dashboard, messages + parse-tree, dead-letters, replay, and the step-up re-auth flow (GET/POST /ui/reauth + the WebAuthn leg) — the sole consumer of the write-action registry (ADR 0065)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
|
16
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from messagefoundry.api._ui_seam import UiDeps
|
|
20
|
+
from messagefoundry.api.models import (
|
|
21
|
+
DeadLetterReplayRequest,
|
|
22
|
+
EditResendRequest,
|
|
23
|
+
PendingApprovalResponse,
|
|
24
|
+
)
|
|
25
|
+
from messagefoundry.api.security import get_auth
|
|
26
|
+
from messagefoundry.auth import Identity, Permission
|
|
27
|
+
from messagefoundry.auth.identity import AuthProvider
|
|
28
|
+
from messagefoundry.auth.service import AuthService, MfaStatus
|
|
29
|
+
from messagefoundry.auth.tokens import hash_token
|
|
30
|
+
from messagefoundry.parsing import HL7PeekError, parse_tree
|
|
31
|
+
|
|
32
|
+
from .. import pages
|
|
33
|
+
from .._auth import (
|
|
34
|
+
CLEAR_SITE_DATA_HEADER,
|
|
35
|
+
CLEAR_SITE_DATA_VALUE,
|
|
36
|
+
WEBAUTHN_EXTRA_MISSING_NOTICE,
|
|
37
|
+
WEBAUTHN_RP_CHANGED_NOTICE,
|
|
38
|
+
WEBAUTHN_RP_MISSING_NOTICE,
|
|
39
|
+
allow_reauth_attempt,
|
|
40
|
+
assert_not_cross_site,
|
|
41
|
+
assert_same_origin,
|
|
42
|
+
clear_session_cookie,
|
|
43
|
+
is_unlock_action,
|
|
44
|
+
login_redirect_response,
|
|
45
|
+
lookup_ui_action,
|
|
46
|
+
register_ui_action,
|
|
47
|
+
require_ui,
|
|
48
|
+
require_ui_step_up,
|
|
49
|
+
session_token,
|
|
50
|
+
set_session_cookie,
|
|
51
|
+
webauthn_rp,
|
|
52
|
+
)
|
|
53
|
+
from .._html import CSP_PROBE_SRC
|
|
54
|
+
from .._service import _service
|
|
55
|
+
|
|
56
|
+
_log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
#: Login-page outcome codes that mean "a session just ended" and therefore carry Clear-Site-Data.
|
|
59
|
+
#: The header itself (and every server-driven expiry redirect that carries it) lives in
|
|
60
|
+
#: :mod:`.._auth` — see :data:`.._auth.CLEAR_SITE_DATA_VALUE`. ``pwchanged`` belongs here for the same
|
|
61
|
+
#: reason as ``loggedout``: a password change revokes EVERY session for the user. It is belt — the
|
|
62
|
+
#: 303 that sends the browser here already carries the header via ``clear_session_cookie`` — but a
|
|
63
|
+
#: browser can reach this landing by another route (Back, a bookmark) with the cookie already gone,
|
|
64
|
+
#: which is exactly the case ``_has_stale_session_cookie`` below cannot detect.
|
|
65
|
+
_CLEAR_SITE_DATA_LOGIN_CODES = frozenset({"expired", "loggedout", "pwchanged"})
|
|
66
|
+
|
|
67
|
+
#: How many report bodies of one CSP violation BATCH the WARNING line summarises before it is
|
|
68
|
+
#: truncated to a count. The reports are attacker-influenceable, so the log line is bounded.
|
|
69
|
+
_CSP_REPORT_SUMMARY_MAX = 5
|
|
70
|
+
|
|
71
|
+
# Edit-and-resubmit (ADR 0090 §9, BACKLOG #153). The GET editor page is the step-up `unlock`
|
|
72
|
+
# continuation (a GET form the re-auth flow can 303-GET-redirect back to); the body-carrying POST
|
|
73
|
+
# `/edit-resend` is deliberately NOT a registered continuation — its `reauth_next` maps a stale-window
|
|
74
|
+
# step-up to this /edit page, so the operator re-submits inside a fresh window (mirrors /ui/users).
|
|
75
|
+
register_ui_action(
|
|
76
|
+
r"^/ui/messages/[^/?#]+/edit$", Permission.MESSAGES_EDIT, auto_retry=False, unlock=True
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _csp_report_bodies(doc: object) -> list[dict[str, object]] | None:
|
|
81
|
+
"""Normalize either wired delivery shape to the LIST of report bodies it carries, or ``None`` when
|
|
82
|
+
the payload is not an object at all: the legacy ``report-uri`` body
|
|
83
|
+
(``{"csp-report": {"document-uri": ..., "violated-directive": ..., "blocked-uri": ...}}``, always
|
|
84
|
+
exactly one) and the modern Reporting-API ``report-to`` batch (a top-level ARRAY whose entries
|
|
85
|
+
carry a ``body`` dict keyed ``documentURL``/``effectiveDirective``/``blockedURL``,
|
|
86
|
+
``application/reports+json``). Never raises on hostile input — the report is
|
|
87
|
+
attacker-influenceable DATA, never instructions.
|
|
88
|
+
|
|
89
|
+
Returning every entry rather than the first is LOAD-BEARING (ASVS 3.7.5). The Reporting API
|
|
90
|
+
batches per endpoint, and the enforcement canary provokes one report per page load on every
|
|
91
|
+
conforming browser, so a genuine violation raised on the same page load arrives in the SAME POST,
|
|
92
|
+
behind the canary. A first-entry-only view classified that batch by the canary and dropped the real
|
|
93
|
+
violation to DEBUG — the exact detection hole the external-canary design exists to avoid.
|
|
94
|
+
"""
|
|
95
|
+
if isinstance(doc, list):
|
|
96
|
+
bodies: list[dict[str, object]] = []
|
|
97
|
+
for entry in doc:
|
|
98
|
+
if isinstance(entry, dict):
|
|
99
|
+
body = entry.get("body", entry)
|
|
100
|
+
if isinstance(body, dict):
|
|
101
|
+
bodies.append(body)
|
|
102
|
+
return bodies
|
|
103
|
+
if isinstance(doc, dict):
|
|
104
|
+
inner = doc.get("csp-report", doc)
|
|
105
|
+
return [inner] if isinstance(inner, dict) else []
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _csp_report_summary(report: dict[str, object]) -> str:
|
|
110
|
+
"""A bounded, PHI-free one-line summary of ONE CSP violation report body (either delivery shape).
|
|
111
|
+
Returns ``"empty"`` when nothing usable is present."""
|
|
112
|
+
# Accept both the hyphenated report-uri keys and the camelCase report-to keys, labelling the
|
|
113
|
+
# summary by the stable report-uri name in either case.
|
|
114
|
+
field_specs = (
|
|
115
|
+
("document-uri", "documentURL"),
|
|
116
|
+
("violated-directive", "effectiveDirective"),
|
|
117
|
+
("blocked-uri", "blockedURL"),
|
|
118
|
+
)
|
|
119
|
+
fields: list[str] = []
|
|
120
|
+
for legacy_key, modern_key in field_specs:
|
|
121
|
+
value = report.get(legacy_key)
|
|
122
|
+
if value is None:
|
|
123
|
+
value = report.get(modern_key)
|
|
124
|
+
if value:
|
|
125
|
+
fields.append(f"{legacy_key}={str(value)[:256]}")
|
|
126
|
+
return "; ".join(fields) or "empty"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _request_origin(request: Request) -> str | None:
|
|
130
|
+
"""This deployment's own ORIGIN (``scheme://host[:port]``, lowercased) for a same-origin
|
|
131
|
+
comparison, or ``None`` when it cannot be established.
|
|
132
|
+
|
|
133
|
+
Follows the precedence of ``_auth._origin_matches`` — ``[api].public_origin`` is authoritative
|
|
134
|
+
when configured (the off-loopback case behind a proxy that may not preserve ``Host``), else the
|
|
135
|
+
request's own ``Host`` header — but, unlike that function's Host-only fallback, it also carries the
|
|
136
|
+
SCHEME. A violation report's blocked URL is absolute, so the scheme IS observable here, and
|
|
137
|
+
``http://<our-host>/ui/static/csp-probe.js`` on an https deployment is NOT our canary. Behind a
|
|
138
|
+
TLS-terminating proxy that neither sets ``public_origin`` nor rewrites ``scope['scheme']`` the
|
|
139
|
+
comparison simply fails and the canary's own reports WARN instead of being filtered — noisier,
|
|
140
|
+
never quieter, which is the only safe direction for a filter on a security log.
|
|
141
|
+
"""
|
|
142
|
+
public_origin: str | None = getattr(request.app.state, "public_origin", None)
|
|
143
|
+
if public_origin:
|
|
144
|
+
parts = urlsplit(public_origin)
|
|
145
|
+
if not parts.scheme or not parts.netloc:
|
|
146
|
+
return None
|
|
147
|
+
return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
|
|
148
|
+
host = request.headers.get("host")
|
|
149
|
+
return f"{request.url.scheme.lower()}://{host.lower()}" if host else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_expected_csp_probe_report(report: dict[str, object], origin: str | None) -> bool:
|
|
153
|
+
"""Whether THIS report body is the one violation the 3.7.5 enforcement canary is designed to
|
|
154
|
+
provoke — a conforming browser refusing OUR OWN un-nonced ``/ui/static/csp-probe.js``.
|
|
155
|
+
|
|
156
|
+
The match is SAME-ORIGIN — scheme AND ``host[:port]`` — plus same-path, and nothing else. The path
|
|
157
|
+
alone is not sufficient: it is attacker-selectable on an attacker-hosted payload, so a host-blind
|
|
158
|
+
match would let ``https://evil.example/ui/static/csp-probe.js`` — an injected script load a real
|
|
159
|
+
CSP blocked — be filed as the expected canary and dropped to DEBUG. ``origin`` is this
|
|
160
|
+
deployment's own ``scheme://host[:port]`` (:func:`_request_origin`); a relative ``blocked-uri``
|
|
161
|
+
(some browsers report a bare path) is same-origin by construction, and an absolute URL must match
|
|
162
|
+
it case-insensitively. Unknown origin fails CLOSED — the report warns.
|
|
163
|
+
|
|
164
|
+
Being an external script rather than an inline one is what makes any filtering safe at all: a
|
|
165
|
+
blocked INLINE canary reports ``blocked-uri: "inline"`` with the same directive and document as a
|
|
166
|
+
blocked injected inline script, so any filter wide enough to drop the canary would also drop the
|
|
167
|
+
report a real XSS attempt produces. Directive naming is deliberately NOT part of the match
|
|
168
|
+
(browsers report ``script-src-elem`` or ``script-src`` depending on version), and neither is the
|
|
169
|
+
document URI (every /ui page emits the canary, so it discriminates nothing).
|
|
170
|
+
"""
|
|
171
|
+
blocked = report.get("blocked-uri")
|
|
172
|
+
if blocked is None:
|
|
173
|
+
blocked = report.get("blockedURL")
|
|
174
|
+
if not isinstance(blocked, str):
|
|
175
|
+
return False
|
|
176
|
+
parts = urlsplit(blocked)
|
|
177
|
+
# Compare paths: browsers report the absolute URL, but tolerate a bare path too. Query/fragment are
|
|
178
|
+
# stripped so a cache-buster can never smuggle a non-probe URL past the match.
|
|
179
|
+
if parts.path != CSP_PROBE_SRC:
|
|
180
|
+
return False
|
|
181
|
+
if not parts.netloc:
|
|
182
|
+
return True
|
|
183
|
+
return origin is not None and f"{parts.scheme.lower()}://{parts.netloc.lower()}" == origin
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def _has_stale_session_cookie(request: Request, auth: AuthService | None) -> bool:
|
|
187
|
+
"""Whether this request presents a session cookie that no longer authenticates (ASVS 14.3.1).
|
|
188
|
+
|
|
189
|
+
A first-time visitor carries no cookie and gets an ordinary login form; a browser arriving with a
|
|
190
|
+
dead ``mf_session`` is landing AFTER a termination — idle/absolute expiry, revoke, admin disable —
|
|
191
|
+
and its cached PHI pages must be dropped even when the redirect that sent it here was not ours
|
|
192
|
+
(a bookmark, a Back navigation, or a client that never ran the watchdog). Validated with
|
|
193
|
+
``activity=False``: probing a dead session must not be treated as user activity.
|
|
194
|
+
"""
|
|
195
|
+
token = session_token(request)
|
|
196
|
+
if not token:
|
|
197
|
+
return False
|
|
198
|
+
if auth is None or not auth.enabled:
|
|
199
|
+
return True # a cookie with no auth configured can never authenticate
|
|
200
|
+
return await auth.identity_for_token(token, activity=False) is None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def register(app: FastAPI, deps: UiDeps) -> None:
|
|
204
|
+
"""Register the phase-0 /ui routes (login, dashboard, messages, dead-letters, replay,
|
|
205
|
+
reauth). Runs first in ``_UI_REGISTRARS``; a page lane adds its own
|
|
206
|
+
``_register_<area>(app)`` + one tuple entry below, so parallel lanes never edit this
|
|
207
|
+
shared block (ADR 0065 §multi-session-build)."""
|
|
208
|
+
core = deps.core
|
|
209
|
+
|
|
210
|
+
@app.get("/ui/session-status")
|
|
211
|
+
async def ui_session_status(
|
|
212
|
+
request: Request,
|
|
213
|
+
service: AuthService = Depends(_service),
|
|
214
|
+
# activity=False (ASVS 14.3.1) is LOAD-BEARING: this probe is the client watchdog's heartbeat,
|
|
215
|
+
# so refreshing the idle clock from it would make the watchdog keep the very session alive it
|
|
216
|
+
# exists to notice the end of. No permission is required beyond a live session — every
|
|
217
|
+
# authenticated page needs it, including a must-change-confined one.
|
|
218
|
+
# allow_mfa_pending is MANDATORY, not a convenience: the confinement page at /ui/mfa carries
|
|
219
|
+
# the same watchdog, so gating this probe would 303 it to /ui/mfa on every heartbeat and the
|
|
220
|
+
# page would fight its own poll.
|
|
221
|
+
identity: Identity = Depends(
|
|
222
|
+
require_ui(allow_must_change=True, allow_mfa_pending=True, activity=False)
|
|
223
|
+
),
|
|
224
|
+
) -> JSONResponse:
|
|
225
|
+
"""The session watchdog's heartbeat (ASVS 14.3.1).
|
|
226
|
+
|
|
227
|
+
Reaching this AT ALL is the liveness signal — ``require_ui`` 303s to the login page the moment
|
|
228
|
+
the session is idle-expired, absolute-expired or revoked, and the client treats that redirect
|
|
229
|
+
as termination. The body carries the ABSOLUTE deadline as **remaining seconds**, never a
|
|
230
|
+
wall-clock the client would have to trust against its own (possibly wrong, possibly
|
|
231
|
+
adversary-set) system time, and it is read from the session RECORD rather than computed from
|
|
232
|
+
``[auth].session_absolute_hours`` — a federated session's deadline may be capped lower by the
|
|
233
|
+
IdP's ``id_token.exp``, so settings arithmetic would over-state it.
|
|
234
|
+
|
|
235
|
+
``expires_secs`` is null when the record cannot be resolved; the client then relies on the
|
|
236
|
+
server verdict and its stale-contact bound alone rather than inventing a deadline.
|
|
237
|
+
"""
|
|
238
|
+
remaining: int | None = None
|
|
239
|
+
token = session_token(request)
|
|
240
|
+
if token:
|
|
241
|
+
# The SAME indexed point-lookup ``identity_for_token`` just did (``require_ui`` above),
|
|
242
|
+
# not a per-user session listing: this probe runs every 30s in every open console tab, and
|
|
243
|
+
# ``list_sessions`` is an unindexed scan on all three backends.
|
|
244
|
+
record = await service.store.get_session(hash_token(token))
|
|
245
|
+
if record is not None:
|
|
246
|
+
remaining = max(0, int(record.expires_at - datetime.now(UTC).timestamp()))
|
|
247
|
+
return JSONResponse({"expires_secs": remaining})
|
|
248
|
+
|
|
249
|
+
@app.get("/ui/login", response_class=HTMLResponse)
|
|
250
|
+
async def ui_login_form(
|
|
251
|
+
request: Request, e: str | None = Query(None, max_length=32)
|
|
252
|
+
) -> HTMLResponse:
|
|
253
|
+
auth = get_auth(request)
|
|
254
|
+
ad_enabled = auth is not None and auth.ad_enabled
|
|
255
|
+
sso_enabled = auth is not None and auth.kerberos_available
|
|
256
|
+
# getattr: an older engine (seam < 10) has no oidc_available property at all, and a bare
|
|
257
|
+
# attribute read would AttributeError rather than degrade (the exposure_protected
|
|
258
|
+
# precedent). oidc_available, not oidc_enabled -- the LINK should disappear while the IdP
|
|
259
|
+
# is known-down, even though the start leg still attempts per-request (AC-8).
|
|
260
|
+
oidc_enabled = bool(getattr(auth, "oidc_available", False))
|
|
261
|
+
resp = HTMLResponse(
|
|
262
|
+
pages.login(
|
|
263
|
+
e, ad_enabled=ad_enabled, sso_enabled=sso_enabled, oidc_enabled=oidc_enabled
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
# ASVS 14.3.1: this render IS the post-termination landing page when either the outcome code
|
|
267
|
+
# says so (the watchdog's ?e=expired, the redirect target of POST /ui/logout, or the
|
|
268
|
+
# server's own expiry redirect) OR the browser still presents a session cookie that no
|
|
269
|
+
# longer authenticates — a stale cookie IS a terminated session, whatever URL it landed on.
|
|
270
|
+
# Tell the browser to drop that session's cached representations so Back / bfcache cannot
|
|
271
|
+
# resurrect a PHI page whose session no longer exists.
|
|
272
|
+
if e in _CLEAR_SITE_DATA_LOGIN_CODES or await _has_stale_session_cookie(request, auth):
|
|
273
|
+
resp.headers[CLEAR_SITE_DATA_HEADER] = CLEAR_SITE_DATA_VALUE
|
|
274
|
+
return resp
|
|
275
|
+
|
|
276
|
+
@app.post("/ui/login")
|
|
277
|
+
async def ui_login(request: Request) -> Response:
|
|
278
|
+
# ASVS 3.5.1 — FIRST statement, ahead of the per-address login budget below: a cross-site
|
|
279
|
+
# credential POST is refused having spent no rate-limit token and run no password verify. The
|
|
280
|
+
# unauthenticated leg is exactly where SameSite=Strict supplies nothing (there is no session
|
|
281
|
+
# cookie yet), so this origin check is the only login-CSRF control available here.
|
|
282
|
+
assert_same_origin(request)
|
|
283
|
+
auth = get_auth(request)
|
|
284
|
+
if auth is None or not auth.enabled:
|
|
285
|
+
raise HTTPException(503, "authentication is not configured")
|
|
286
|
+
client = request.client.host if request.client else None
|
|
287
|
+
if not auth.allow_login_attempt(client):
|
|
288
|
+
raise HTTPException(429, "too many login attempts", headers={"Retry-After": "30"})
|
|
289
|
+
# Parse the urlencoded login form with stdlib — the engine has no python-multipart dep, so
|
|
290
|
+
# Form()/request.form() would fail; a same-origin login POST is always urlencoded here.
|
|
291
|
+
form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
|
|
292
|
+
# L5b (ADR 0068 §8): browser AD-password login rides the SAME auth.login seam as the
|
|
293
|
+
# JSON surface — allow-listed provider values only; absent stays LOCAL (regression-
|
|
294
|
+
# pinned). ONE session is minted per form POST, so the AD role-resync/revocation side
|
|
295
|
+
# effect fires once at login, never per navigation.
|
|
296
|
+
provider_value = form.get("provider", "local")
|
|
297
|
+
if provider_value not in ("local", "ad"):
|
|
298
|
+
return RedirectResponse("/ui/login?e=bad", status_code=303)
|
|
299
|
+
outcome = await auth.login(
|
|
300
|
+
form.get("username", ""),
|
|
301
|
+
form.get("password", ""),
|
|
302
|
+
provider=AuthProvider.AD if provider_value == "ad" else AuthProvider.LOCAL,
|
|
303
|
+
client=client,
|
|
304
|
+
)
|
|
305
|
+
if not outcome.ok or outcome.token is None:
|
|
306
|
+
return RedirectResponse("/ui/login?e=bad", status_code=303)
|
|
307
|
+
# A must-change account goes straight to the browser rotation page (L4b) — every other
|
|
308
|
+
# /ui route would bounce it there anyway (require_ui). An MFA-pending session lands on the
|
|
309
|
+
# second-factor page for the same reason (ASVS 6.3.3). Order matches the server-side gates:
|
|
310
|
+
# must_change first, since a fresh account is BOTH and can only rotate.
|
|
311
|
+
#
|
|
312
|
+
# UX only — require_ui is what actually enforces, and it covers the other two cookie-minting
|
|
313
|
+
# legs (Kerberos SSO, the OIDC callback) without either needing this branch.
|
|
314
|
+
if outcome.must_change_password:
|
|
315
|
+
target = "/ui/account/password"
|
|
316
|
+
elif outcome.mfa_required:
|
|
317
|
+
target = "/ui/mfa"
|
|
318
|
+
else:
|
|
319
|
+
target = "/ui"
|
|
320
|
+
resp = RedirectResponse(target, status_code=303)
|
|
321
|
+
set_session_cookie(resp, outcome.token, request=request)
|
|
322
|
+
return resp
|
|
323
|
+
|
|
324
|
+
@app.post("/ui/logout")
|
|
325
|
+
async def ui_logout(request: Request) -> Response:
|
|
326
|
+
# ASVS 3.5.1 — FIRST statement, before the session is revoked below. This route deliberately
|
|
327
|
+
# carries NO Depends gate (so a must-change-confined session can still sign itself out — the
|
|
328
|
+
# affordance ASVS 7.4.4 makes visible), which means the origin check is the only
|
|
329
|
+
# request-provenance control it will ever have: without it a cross-site POST the browser
|
|
330
|
+
# attaches the cookie to would forcibly terminate the operator's session.
|
|
331
|
+
assert_same_origin(request)
|
|
332
|
+
auth = get_auth(request)
|
|
333
|
+
token = session_token(request)
|
|
334
|
+
if auth is not None and token:
|
|
335
|
+
await auth.logout(token)
|
|
336
|
+
resp = RedirectResponse("/ui/login?e=loggedout", status_code=303)
|
|
337
|
+
# ASVS 14.3.1: revoking server-side and deleting the cookie leaves the browser's CACHED
|
|
338
|
+
# representations of the operator's PHI pages behind, which Back / bfcache can resurrect.
|
|
339
|
+
# ``clear_session_cookie`` emits Clear-Site-Data itself — cookie deletion IS the termination,
|
|
340
|
+
# so the two cannot be written apart (see its docstring for the scope rationale).
|
|
341
|
+
clear_session_cookie(resp, request)
|
|
342
|
+
return resp
|
|
343
|
+
|
|
344
|
+
@app.get("/ui", response_class=HTMLResponse)
|
|
345
|
+
async def ui_dashboard(
|
|
346
|
+
engine: Any = Depends(deps.get_engine),
|
|
347
|
+
identity: Identity = Depends(require_ui(Permission.MONITORING_READ)),
|
|
348
|
+
) -> HTMLResponse:
|
|
349
|
+
rows = await core.list_connections(engine=engine, identity=identity)
|
|
350
|
+
return HTMLResponse(pages.dashboard(rows))
|
|
351
|
+
|
|
352
|
+
@app.get("/ui/connections", response_class=HTMLResponse)
|
|
353
|
+
async def ui_connections(
|
|
354
|
+
engine: Any = Depends(deps.get_engine),
|
|
355
|
+
# activity=False (ASVS 14.3.1): the dashboard's 5s live-table refresh is timer-driven, not
|
|
356
|
+
# user activity — it must not keep an abandoned tab's session alive.
|
|
357
|
+
identity: Identity = Depends(require_ui(Permission.MONITORING_READ, activity=False)),
|
|
358
|
+
) -> HTMLResponse:
|
|
359
|
+
rows = await core.list_connections(engine=engine, identity=identity)
|
|
360
|
+
return HTMLResponse(pages.connections_fragment(rows))
|
|
361
|
+
|
|
362
|
+
@app.get("/ui/connection/{name}", response_class=HTMLResponse)
|
|
363
|
+
async def ui_connection_details(
|
|
364
|
+
name: str,
|
|
365
|
+
request: Request,
|
|
366
|
+
engine: Any = Depends(deps.get_engine),
|
|
367
|
+
identity: Identity = Depends(require_ui(Permission.MONITORING_READ)),
|
|
368
|
+
) -> HTMLResponse:
|
|
369
|
+
# Compose the detail view from existing monitoring handlers (no new PHI surface): find the row in
|
|
370
|
+
# the (already channel-scoped) connection list, then its recent events. A singular /ui/connection/
|
|
371
|
+
# path avoids colliding with the /ui/connections/{purge-confirm,...} action routes.
|
|
372
|
+
rows = await core.list_connections(engine=engine, identity=identity)
|
|
373
|
+
row = next((r for r in rows if r.name == name), None)
|
|
374
|
+
if row is None:
|
|
375
|
+
raise HTTPException(404, "connection not found")
|
|
376
|
+
# Events are recorded + RBAC-scoped by the RAW connection name (channel_id for a source,
|
|
377
|
+
# destination for an outbound), NOT the composite display name — pass the raw name so the events
|
|
378
|
+
# actually match and a channel-scoped operator isn't spuriously denied (+ audited) on their own.
|
|
379
|
+
events_key = (
|
|
380
|
+
row.destination if (row.role == "destination" and row.destination) else row.channel_id
|
|
381
|
+
)
|
|
382
|
+
try:
|
|
383
|
+
# Pass EVERY param explicitly: called directly (not over HTTP), any FastAPI Query(...) default
|
|
384
|
+
# left unfilled arrives as a Query object (kind would reach the store un-iterable → 500).
|
|
385
|
+
events = await core.list_connection_events(
|
|
386
|
+
engine=engine,
|
|
387
|
+
identity=identity,
|
|
388
|
+
connection=events_key,
|
|
389
|
+
kind=None,
|
|
390
|
+
since=None,
|
|
391
|
+
limit=50,
|
|
392
|
+
request=request,
|
|
393
|
+
)
|
|
394
|
+
except HTTPException:
|
|
395
|
+
events = [] # still show the connection's info + stats if events are RBAC-scoped out
|
|
396
|
+
return HTMLResponse(pages.connection_details(row, events))
|
|
397
|
+
|
|
398
|
+
@app.get("/ui/messages", response_class=HTMLResponse)
|
|
399
|
+
async def ui_messages(
|
|
400
|
+
request: Request,
|
|
401
|
+
engine: Any = Depends(deps.get_engine),
|
|
402
|
+
identity: Identity = Depends(require_ui(Permission.MESSAGES_READ, phi=True)),
|
|
403
|
+
channel_id: str | None = Query(None, max_length=256),
|
|
404
|
+
status_filter: str | None = Query(None, alias="status", max_length=64),
|
|
405
|
+
message_type: str | None = Query(None, max_length=64),
|
|
406
|
+
control_id: str | None = Query(None, max_length=256),
|
|
407
|
+
received_from: str | None = Query(None, max_length=32), # datetime-local (UTC)
|
|
408
|
+
received_to: str | None = Query(None, max_length=32),
|
|
409
|
+
defer: bool = Query(False),
|
|
410
|
+
limit: int = Query(50, ge=1, le=500),
|
|
411
|
+
offset: int = Query(0, ge=0),
|
|
412
|
+
) -> HTMLResponse:
|
|
413
|
+
# Arriving pre-filled from a connection name (defer=1) with no explicit dates → default a 1-day
|
|
414
|
+
# window (UTC). The operator adjusts and clicks Search (a plain submit, no defer) to run it.
|
|
415
|
+
if defer and not received_from and not received_to:
|
|
416
|
+
now = datetime.now(UTC)
|
|
417
|
+
received_from = (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
|
|
418
|
+
received_to = now.strftime("%Y-%m-%dT%H:%M")
|
|
419
|
+
|
|
420
|
+
def _epoch(value: str | None) -> float | None:
|
|
421
|
+
if not value:
|
|
422
|
+
return None
|
|
423
|
+
try:
|
|
424
|
+
return datetime.fromisoformat(value).replace(tzinfo=UTC).timestamp()
|
|
425
|
+
except ValueError:
|
|
426
|
+
return None # a malformed datetime-local simply drops that bound
|
|
427
|
+
|
|
428
|
+
if defer:
|
|
429
|
+
# Form-only landing: pre-filled, NOT run until the operator submits (#4b).
|
|
430
|
+
return HTMLResponse(
|
|
431
|
+
pages.messages(
|
|
432
|
+
None,
|
|
433
|
+
deferred=True,
|
|
434
|
+
channel_id=channel_id or "",
|
|
435
|
+
status=status_filter or "",
|
|
436
|
+
message_type=message_type or "",
|
|
437
|
+
control_id=control_id or "",
|
|
438
|
+
received_from=received_from or "",
|
|
439
|
+
received_to=received_to or "",
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
data = await core.list_messages(
|
|
444
|
+
request,
|
|
445
|
+
engine=engine,
|
|
446
|
+
identity=identity,
|
|
447
|
+
channel_id=channel_id,
|
|
448
|
+
status=status_filter,
|
|
449
|
+
message_type=message_type,
|
|
450
|
+
control_id=control_id,
|
|
451
|
+
received_from=_epoch(received_from),
|
|
452
|
+
received_to=_epoch(received_to),
|
|
453
|
+
limit=limit,
|
|
454
|
+
offset=offset,
|
|
455
|
+
)
|
|
456
|
+
return HTMLResponse(
|
|
457
|
+
pages.messages(
|
|
458
|
+
data,
|
|
459
|
+
channel_id=channel_id or "",
|
|
460
|
+
status=status_filter or "",
|
|
461
|
+
message_type=message_type or "",
|
|
462
|
+
control_id=control_id or "",
|
|
463
|
+
received_from=received_from or "",
|
|
464
|
+
received_to=received_to or "",
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
@app.get("/ui/messages/{message_id}", response_class=HTMLResponse)
|
|
469
|
+
async def ui_message_detail(
|
|
470
|
+
message_id: str,
|
|
471
|
+
request: Request,
|
|
472
|
+
engine: Any = Depends(deps.get_engine),
|
|
473
|
+
identity: Identity = Depends(require_ui(Permission.MESSAGES_VIEW_RAW, phi=True)),
|
|
474
|
+
) -> HTMLResponse:
|
|
475
|
+
detail = await core.get_message(message_id, request, engine=engine, identity=identity)
|
|
476
|
+
return HTMLResponse(pages.message_detail(detail))
|
|
477
|
+
|
|
478
|
+
@app.get("/ui/messages/{message_id}/parse-tree", response_class=HTMLResponse)
|
|
479
|
+
async def ui_message_parse_tree(
|
|
480
|
+
message_id: str,
|
|
481
|
+
request: Request,
|
|
482
|
+
engine: Any = Depends(deps.get_engine),
|
|
483
|
+
identity: Identity = Depends(require_ui(Permission.MESSAGES_VIEW_RAW, phi=True)),
|
|
484
|
+
) -> HTMLResponse:
|
|
485
|
+
# Reuse the single audited PHI path (get_message → record_view + record_audit), then render
|
|
486
|
+
# the tree server-side via the pure parsing lib. Non-HL7 bodies (X12/DICOM/binary) have no
|
|
487
|
+
# HL7 tree — surface that rather than 500. No new PHI egress beyond the audited raw fetch.
|
|
488
|
+
detail = await core.get_message(message_id, request, engine=engine, identity=identity)
|
|
489
|
+
try:
|
|
490
|
+
nodes = parse_tree(detail.raw)
|
|
491
|
+
except HL7PeekError as exc:
|
|
492
|
+
return HTMLResponse(pages.parse_tree_unavailable(message_id, str(exc)))
|
|
493
|
+
return HTMLResponse(pages.parse_tree_page(message_id, nodes))
|
|
494
|
+
|
|
495
|
+
@app.get("/ui/messages/{message_id}/attachments/{attachment_id}")
|
|
496
|
+
async def ui_download_attachment(
|
|
497
|
+
message_id: str,
|
|
498
|
+
attachment_id: str,
|
|
499
|
+
request: Request,
|
|
500
|
+
engine: Any = Depends(deps.get_engine),
|
|
501
|
+
identity: Identity = Depends(require_ui(Permission.MESSAGES_VIEW_RAW, phi=True)),
|
|
502
|
+
) -> Response:
|
|
503
|
+
# Reuse the engine's single audited download path (linkage + channel-scope 404 guard +
|
|
504
|
+
# record_view + attachment_download audit), then hand its Response straight to the browser. A
|
|
505
|
+
# top-level GET nav can't carry the bearer token, so the /ui gate re-asserts view_raw via the
|
|
506
|
+
# session cookie; the engine handler does the same PHI audit as the JSON API route.
|
|
507
|
+
result: Response = await core.download_attachment(
|
|
508
|
+
message_id, attachment_id, engine=engine, identity=identity, request=request
|
|
509
|
+
)
|
|
510
|
+
return result
|
|
511
|
+
|
|
512
|
+
@app.get("/ui/dead-letters", response_class=HTMLResponse)
|
|
513
|
+
async def ui_dead_letters(
|
|
514
|
+
request: Request,
|
|
515
|
+
engine: Any = Depends(deps.get_engine),
|
|
516
|
+
identity: Identity = Depends(require_ui(Permission.MESSAGES_READ, phi=True)),
|
|
517
|
+
channel_id: str | None = Query(None, max_length=256),
|
|
518
|
+
destination_name: str | None = Query(None, max_length=256),
|
|
519
|
+
limit: int = Query(50, ge=1, le=500),
|
|
520
|
+
offset: int = Query(0, ge=0),
|
|
521
|
+
) -> HTMLResponse:
|
|
522
|
+
data = await core.list_dead_letters(
|
|
523
|
+
request,
|
|
524
|
+
engine=engine,
|
|
525
|
+
identity=identity,
|
|
526
|
+
channel_id=channel_id,
|
|
527
|
+
destination_name=destination_name,
|
|
528
|
+
limit=limit,
|
|
529
|
+
offset=offset,
|
|
530
|
+
)
|
|
531
|
+
return HTMLResponse(pages.dead_letters(data))
|
|
532
|
+
|
|
533
|
+
# Safe operator actions (M2): inbound connection start/stop/restart. These reuse the JSON
|
|
534
|
+
# control handlers (require CONNECTIONS_CONTROL + the per-channel _control_guard), and add
|
|
535
|
+
# assert_same_origin as CSRF defense-in-depth on top of the SameSite=Strict cookie (a
|
|
536
|
+
# cross-site POST carries no cookie, so require_ui already 303s). No step-up gate applies to
|
|
537
|
+
# start/stop/restart (unlike replay, which is require_step_up and lands with the browser MFA
|
|
538
|
+
# flow in a later milestone). Each redirects back to the dashboard.
|
|
539
|
+
async def _ui_control(
|
|
540
|
+
request: Request,
|
|
541
|
+
name: str,
|
|
542
|
+
engine: Any,
|
|
543
|
+
identity: Identity,
|
|
544
|
+
action: Callable[..., Any],
|
|
545
|
+
) -> Response:
|
|
546
|
+
assert_same_origin(request)
|
|
547
|
+
# ADR 0150: the console mounts IN-PROCESS on the engine's own app, so this Request is the
|
|
548
|
+
# BROWSER's — forwarding it attributes the row to the operator's host, not the engine.
|
|
549
|
+
await action(name, engine=engine, identity=identity, request=request)
|
|
550
|
+
return RedirectResponse("/ui", status_code=303)
|
|
551
|
+
|
|
552
|
+
@app.post("/ui/connections/{name}/start")
|
|
553
|
+
async def ui_start_connection(
|
|
554
|
+
name: str,
|
|
555
|
+
request: Request,
|
|
556
|
+
engine: Any = Depends(deps.get_engine),
|
|
557
|
+
identity: Identity = Depends(require_ui(Permission.CONNECTIONS_CONTROL)),
|
|
558
|
+
) -> Response:
|
|
559
|
+
return await _ui_control(request, name, engine, identity, core.start_connection)
|
|
560
|
+
|
|
561
|
+
@app.post("/ui/connections/{name}/stop")
|
|
562
|
+
async def ui_stop_connection(
|
|
563
|
+
name: str,
|
|
564
|
+
request: Request,
|
|
565
|
+
engine: Any = Depends(deps.get_engine),
|
|
566
|
+
identity: Identity = Depends(require_ui(Permission.CONNECTIONS_CONTROL)),
|
|
567
|
+
) -> Response:
|
|
568
|
+
return await _ui_control(request, name, engine, identity, core.stop_connection)
|
|
569
|
+
|
|
570
|
+
@app.post("/ui/connections/{name}/restart")
|
|
571
|
+
async def ui_restart_connection(
|
|
572
|
+
name: str,
|
|
573
|
+
request: Request,
|
|
574
|
+
engine: Any = Depends(deps.get_engine),
|
|
575
|
+
identity: Identity = Depends(require_ui(Permission.CONNECTIONS_CONTROL)),
|
|
576
|
+
) -> Response:
|
|
577
|
+
return await _ui_control(request, name, engine, identity, core.restart_connection)
|
|
578
|
+
|
|
579
|
+
# Sensitive action (M2b): single-message replay. It is require_step_up in the JSON API, so the
|
|
580
|
+
# /ui route uses require_ui_step_up — which, if the session hasn't recently stepped up, 303s the
|
|
581
|
+
# browser to /ui/reauth?next=<this action> instead of returning a 403 header the browser can't
|
|
582
|
+
# act on. After re-auth the browser auto-retries this POST (now inside the step-up window).
|
|
583
|
+
@app.post("/ui/messages/{message_id}/replay")
|
|
584
|
+
async def ui_replay_message(
|
|
585
|
+
message_id: str,
|
|
586
|
+
request: Request,
|
|
587
|
+
engine: Any = Depends(deps.get_engine),
|
|
588
|
+
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_REPLAY)),
|
|
589
|
+
) -> Response:
|
|
590
|
+
assert_same_origin(request)
|
|
591
|
+
await core.replay_message(message_id, engine=engine, identity=identity, request=request)
|
|
592
|
+
return RedirectResponse(f"/ui/messages/{message_id}", status_code=303)
|
|
593
|
+
|
|
594
|
+
# Edit-and-resubmit (ADR 0090 §9, BACKLOG #153). GET renders the editor (a COPY of the raw); the
|
|
595
|
+
# step-up gate opens it inside a fresh window (unlock continuation). The origin row is only READ
|
|
596
|
+
# here (the audited get_message path); nothing is written until the operator POSTs /edit-resend.
|
|
597
|
+
@app.get("/ui/messages/{message_id}/edit", response_class=HTMLResponse)
|
|
598
|
+
async def ui_message_edit(
|
|
599
|
+
message_id: str,
|
|
600
|
+
request: Request,
|
|
601
|
+
engine: Any = Depends(deps.get_engine),
|
|
602
|
+
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_EDIT)),
|
|
603
|
+
) -> HTMLResponse:
|
|
604
|
+
detail = await core.get_message(message_id, request, engine=engine, identity=identity)
|
|
605
|
+
# A fresh per-open idempotency token: a double-submit of THIS rendered form is an idempotent
|
|
606
|
+
# no-op; re-opening the editor mints a new token (a genuine second resubmit).
|
|
607
|
+
return HTMLResponse(pages.message_edit(detail, uuid4().hex))
|
|
608
|
+
|
|
609
|
+
@app.post("/ui/messages/{message_id}/edit-resend")
|
|
610
|
+
async def ui_message_edit_resend(
|
|
611
|
+
message_id: str,
|
|
612
|
+
request: Request,
|
|
613
|
+
engine: Any = Depends(deps.get_engine),
|
|
614
|
+
identity: Identity = Depends(
|
|
615
|
+
require_ui_step_up(
|
|
616
|
+
Permission.MESSAGES_EDIT,
|
|
617
|
+
# Stale-window step-up on this body-carrying POST → re-open the /edit form (fresh
|
|
618
|
+
# window), never the POST path (a re-POST would drop the edited body).
|
|
619
|
+
reauth_next=lambda r: r.url.path.removesuffix("/edit-resend") + "/edit",
|
|
620
|
+
)
|
|
621
|
+
),
|
|
622
|
+
) -> Response:
|
|
623
|
+
assert_same_origin(request)
|
|
624
|
+
# Parse the urlencoded resubmit form with stdlib — the engine has no python-multipart dep, so
|
|
625
|
+
# Form()/request.form() would fail (mirrors the /ui/login + /ui/reauth POST parsing above).
|
|
626
|
+
form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
|
|
627
|
+
raw = str(form.get("raw", ""))
|
|
628
|
+
idem = str(form.get("idempotency_key", "")).strip()
|
|
629
|
+
mode = str(form.get("mode", "reroute"))
|
|
630
|
+
to = str(form.get("to", "")).strip()
|
|
631
|
+
|
|
632
|
+
async def _reject(msg: str) -> HTMLResponse:
|
|
633
|
+
# Re-render the editor preserving the operator's edits (raw_value) AND their destination
|
|
634
|
+
# choice (mode + to) — so a rejected direct send doesn't silently reset to re-route and drop
|
|
635
|
+
# the typed outbound (review #153-4). The audited get_message re-read is the same PHI path
|
|
636
|
+
# the GET used. NEVER echo the edited body in the error text.
|
|
637
|
+
detail = await core.get_message(message_id, request, engine=engine, identity=identity)
|
|
638
|
+
return HTMLResponse(
|
|
639
|
+
pages.message_edit(
|
|
640
|
+
detail, idem or uuid4().hex, raw_value=raw, error=msg, mode=mode, to=to
|
|
641
|
+
),
|
|
642
|
+
status_code=400,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
if mode == "direct" and not to:
|
|
646
|
+
return await _reject(
|
|
647
|
+
"choose an outbound connection for a direct send, or re-route instead"
|
|
648
|
+
)
|
|
649
|
+
try:
|
|
650
|
+
body = EditResendRequest(
|
|
651
|
+
raw=raw,
|
|
652
|
+
idempotency_key=idem,
|
|
653
|
+
reroute=(mode != "direct"),
|
|
654
|
+
to=(to if mode == "direct" else None),
|
|
655
|
+
)
|
|
656
|
+
except ValidationError:
|
|
657
|
+
# PHI-safe: a bad edited body must never be echoed — a generic message only.
|
|
658
|
+
return await _reject("invalid input")
|
|
659
|
+
try:
|
|
660
|
+
result = await core.edit_resend_message(
|
|
661
|
+
message_id, body=body, engine=engine, identity=identity, request=request
|
|
662
|
+
)
|
|
663
|
+
except HTTPException as exc:
|
|
664
|
+
# str(exc.detail) carries ids only (the endpoint never interpolates the body).
|
|
665
|
+
return await _reject(str(exc.detail))
|
|
666
|
+
# Land on the NEW correlated child (re-route) so the operator sees the resubmit flow; the direct
|
|
667
|
+
# path lands back on the origin (which now carries the new outbound row). The ORIGINAL is intact.
|
|
668
|
+
target = result.new_message_id if (result.reroute and result.new_message_id) else message_id
|
|
669
|
+
return RedirectResponse(f"/ui/messages/{target}", status_code=303)
|
|
670
|
+
|
|
671
|
+
async def _reauth_webauthn_state(
|
|
672
|
+
request: Request,
|
|
673
|
+
auth: AuthService,
|
|
674
|
+
token: str | None,
|
|
675
|
+
mfa: MfaStatus,
|
|
676
|
+
satisfied: bool,
|
|
677
|
+
) -> tuple[str | None, str | None]:
|
|
678
|
+
"""(assertion-options JSON, fail-closed notice) for the reauth page's passkey leg.
|
|
679
|
+
|
|
680
|
+
Options are freshly staged per render (the prior challenge is single-use — ADR 0068
|
|
681
|
+
decision 1(e): the passkey button must survive a failed password/code attempt). The
|
|
682
|
+
notice is the legible dead-end copy when ceremonies can't run (extra absent /
|
|
683
|
+
rp unavailable) — never a redirect loop."""
|
|
684
|
+
if satisfied or not mfa.webauthn_enrolled:
|
|
685
|
+
return None, None
|
|
686
|
+
if not auth.webauthn_available():
|
|
687
|
+
return None, WEBAUTHN_EXTRA_MISSING_NOTICE
|
|
688
|
+
rp = webauthn_rp(request)
|
|
689
|
+
if rp is None:
|
|
690
|
+
return None, WEBAUTHN_RP_MISSING_NOTICE
|
|
691
|
+
options = await auth.begin_webauthn_assertion(token, rp_id=rp[0])
|
|
692
|
+
if options is None:
|
|
693
|
+
# Enrolled, but every credential was minted under a DIFFERENT rp_id (the
|
|
694
|
+
# origin-migration case, ADR 0068 §7) — a legible dead-end naming the
|
|
695
|
+
# admin-reset recovery, never a bare password form with a misleading
|
|
696
|
+
# "complete the passkey prompt" error (PR-A review finding).
|
|
697
|
+
return None, WEBAUTHN_RP_CHANGED_NOTICE
|
|
698
|
+
return options, None
|
|
699
|
+
|
|
700
|
+
@app.get("/ui/mfa", response_class=HTMLResponse)
|
|
701
|
+
async def ui_mfa_form(request: Request) -> Response:
|
|
702
|
+
"""The ASVS 6.3.3 confinement page for an MFA-pending browser session.
|
|
703
|
+
|
|
704
|
+
Carries NO ``require_ui`` dependency on purpose — the gate 303s every pending session here, so
|
|
705
|
+
a gated version of this page would redirect to itself. Auth is done by hand instead, in the
|
|
706
|
+
SAME order the gate uses, so the two cannot disagree.
|
|
707
|
+
|
|
708
|
+
A separate route rather than a reuse of ``/ui/reauth``: that page 303s to /ui unless ``next``
|
|
709
|
+
names a registered write action, and its POST always demands the password — which an operator
|
|
710
|
+
proved seconds earlier at sign-in.
|
|
711
|
+
"""
|
|
712
|
+
auth = get_auth(request)
|
|
713
|
+
token = session_token(request)
|
|
714
|
+
identity = await auth.identity_for_token(token) if auth is not None else None
|
|
715
|
+
if auth is None or identity is None:
|
|
716
|
+
return login_redirect_response()
|
|
717
|
+
if identity.must_change_password:
|
|
718
|
+
# must_change outranks MFA, mirroring require()/require_ui: a fresh account is both, and
|
|
719
|
+
# only rotation is reachable until it happens.
|
|
720
|
+
return RedirectResponse("/ui/account/password", status_code=303)
|
|
721
|
+
if await auth.mfa_satisfied(token):
|
|
722
|
+
return RedirectResponse("/ui", status_code=303) # idempotent: nothing owed
|
|
723
|
+
mfa = await auth.mfa_status(identity)
|
|
724
|
+
if not (mfa.enabled or mfa.webauthn_enrolled):
|
|
725
|
+
# Required but NOTHING enrolled: there is no factor to ask for. Reuse the existing
|
|
726
|
+
# enroll-first bounce rather than rendering a form that cannot be answered — this is
|
|
727
|
+
# what keeps a fresh account from bouncing between the gate and an empty page.
|
|
728
|
+
return RedirectResponse("/ui/account?m=enroll_first", status_code=303)
|
|
729
|
+
wa_options, wa_notice = await _reauth_webauthn_state(request, auth, token, mfa, False)
|
|
730
|
+
return HTMLResponse(
|
|
731
|
+
pages.mfa_gate(
|
|
732
|
+
totp_enrolled=mfa.enabled,
|
|
733
|
+
webauthn_options=wa_options,
|
|
734
|
+
webauthn_notice=wa_notice,
|
|
735
|
+
)
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
@app.post("/ui/mfa")
|
|
739
|
+
async def ui_mfa_submit(request: Request) -> Response:
|
|
740
|
+
assert_same_origin(request)
|
|
741
|
+
auth = get_auth(request)
|
|
742
|
+
token = session_token(request)
|
|
743
|
+
identity = await auth.identity_for_token(token) if auth is not None else None
|
|
744
|
+
if auth is None or not token or identity is None:
|
|
745
|
+
return login_redirect_response()
|
|
746
|
+
if identity.must_change_password:
|
|
747
|
+
return RedirectResponse("/ui/account/password", status_code=303)
|
|
748
|
+
client = request.client.host if request.client else None
|
|
749
|
+
if not allow_reauth_attempt(auth, identity, client):
|
|
750
|
+
# Same per-ACTOR ceremony budget the reauth/password flows draw on, so code-guessing
|
|
751
|
+
# here cannot outrun it either.
|
|
752
|
+
# Retry-After: 30 matches POST /ui/reauth, the sibling ceremony on the SAME per-actor
|
|
753
|
+
# budget — a different hint for the same limiter would just misreport when it clears.
|
|
754
|
+
raise HTTPException(429, "too many attempts", headers={"Retry-After": "30"})
|
|
755
|
+
form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
|
|
756
|
+
if await auth.verify_mfa(token, form.get("code", ""), client=client):
|
|
757
|
+
return RedirectResponse("/ui", status_code=303)
|
|
758
|
+
mfa = await auth.mfa_status(identity)
|
|
759
|
+
wa_options, wa_notice = await _reauth_webauthn_state(request, auth, token, mfa, False)
|
|
760
|
+
# The submitted code is NOT echoed back — it is a bearer credential, and verify_mfa has
|
|
761
|
+
# already audited the failure. Generic copy: the form cannot say whether the code was
|
|
762
|
+
# wrong or expired without narrowing a guess.
|
|
763
|
+
return HTMLResponse(
|
|
764
|
+
pages.mfa_gate(
|
|
765
|
+
totp_enrolled=mfa.enabled,
|
|
766
|
+
error="That code wasn't accepted. Try again.",
|
|
767
|
+
webauthn_options=wa_options,
|
|
768
|
+
webauthn_notice=wa_notice,
|
|
769
|
+
),
|
|
770
|
+
status_code=400,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
@app.get("/ui/reauth", response_class=HTMLResponse)
|
|
774
|
+
async def ui_reauth_form(
|
|
775
|
+
request: Request,
|
|
776
|
+
next_: str = Query("", alias="next", max_length=512),
|
|
777
|
+
) -> Response:
|
|
778
|
+
# next MUST be a registered /ui action — a body-less POST the re-auth may auto-retry
|
|
779
|
+
# (is_safe_ui_action) OR a GET admin form page it may unlock (is_unlock_action). Never an
|
|
780
|
+
# arbitrary URL (anti open-redirect) — an unregistered next bounces to /ui.
|
|
781
|
+
action = lookup_ui_action(next_)
|
|
782
|
+
if action is None:
|
|
783
|
+
return RedirectResponse("/ui", status_code=303)
|
|
784
|
+
auth = get_auth(request)
|
|
785
|
+
token = session_token(request)
|
|
786
|
+
identity = await auth.identity_for_token(token) if auth is not None else None
|
|
787
|
+
if auth is None or identity is None:
|
|
788
|
+
# The session ended under the operator (expiry / revoke) — a post-termination landing
|
|
789
|
+
# like any other, so it carries Clear-Site-Data + the explanatory code (14.3.1).
|
|
790
|
+
return login_redirect_response()
|
|
791
|
+
if identity.must_change_password:
|
|
792
|
+
# Mirror require_ui's confinement: a must-change session can only rotate (L4b).
|
|
793
|
+
return RedirectResponse("/ui/account/password", status_code=303)
|
|
794
|
+
mfa = await auth.mfa_status(identity)
|
|
795
|
+
if mfa.required and not (mfa.enabled or mfa.webauthn_enrolled) and action.step_up:
|
|
796
|
+
# A full-step-up action a required-but-UNENROLLED session (no factor of EITHER
|
|
797
|
+
# kind — ADR 0068 decision 1(a)) can NEVER satisfy — send it to enroll instead
|
|
798
|
+
# of a password form that would loop straight back. Enrollment itself is
|
|
799
|
+
# step_up=False (below).
|
|
800
|
+
return RedirectResponse("/ui/account?m=enroll_first", status_code=303)
|
|
801
|
+
# The rendering splits BY FACTOR (decision 1(b)): the TOTP code field renders iff
|
|
802
|
+
# TOTP is enrolled (a required-but-unenrolled account can never produce a code —
|
|
803
|
+
# demanding one would deadlock, L4b); the passkey hook renders iff WebAuthn is
|
|
804
|
+
# enrolled — a WebAuthn-only user sees password + passkey, never an unanswerable
|
|
805
|
+
# code field; a both-enrolled user sees both, either satisfies.
|
|
806
|
+
satisfied = await auth.mfa_satisfied(token)
|
|
807
|
+
mfa_needed = not satisfied and mfa.enabled
|
|
808
|
+
wa_options, wa_notice = await _reauth_webauthn_state(request, auth, token, mfa, satisfied)
|
|
809
|
+
return HTMLResponse(
|
|
810
|
+
pages.reauth(
|
|
811
|
+
next_,
|
|
812
|
+
mfa_needed=mfa_needed,
|
|
813
|
+
webauthn_options=wa_options,
|
|
814
|
+
webauthn_notice=wa_notice,
|
|
815
|
+
)
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
@app.post("/ui/reauth")
|
|
819
|
+
async def ui_reauth(request: Request) -> Response:
|
|
820
|
+
assert_same_origin(request)
|
|
821
|
+
auth = get_auth(request)
|
|
822
|
+
token = session_token(request)
|
|
823
|
+
identity = await auth.identity_for_token(token) if auth is not None else None
|
|
824
|
+
if auth is None or not token or identity is None:
|
|
825
|
+
return login_redirect_response() # session ended mid-ceremony — see ui_reauth_form
|
|
826
|
+
if identity.must_change_password:
|
|
827
|
+
# Mirror require_ui's confinement: a must-change session can only rotate (L4b).
|
|
828
|
+
return RedirectResponse("/ui/account/password", status_code=303)
|
|
829
|
+
form = dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
|
|
830
|
+
next_ = form.get("next", "")
|
|
831
|
+
action = lookup_ui_action(next_)
|
|
832
|
+
if action is None:
|
|
833
|
+
return RedirectResponse("/ui", status_code=303)
|
|
834
|
+
mfa = await auth.mfa_status(identity)
|
|
835
|
+
if mfa.required and not (mfa.enabled or mfa.webauthn_enrolled) and action.step_up:
|
|
836
|
+
# See ui_reauth_form: a full-step-up action this session can never satisfy (no
|
|
837
|
+
# factor of EITHER kind — ADR 0068 decision 1(a)) — send it to enroll rather
|
|
838
|
+
# than loop. Checked BEFORE the rate limiter so a correct password isn't burned
|
|
839
|
+
# into a 429 (the review's silent-loop finding; the ordering pin covers the
|
|
840
|
+
# generalized condition too).
|
|
841
|
+
return RedirectResponse("/ui/account?m=enroll_first", status_code=303)
|
|
842
|
+
satisfied = await auth.mfa_satisfied(token)
|
|
843
|
+
if not satisfied and not mfa.enabled and mfa.webauthn_enrolled:
|
|
844
|
+
# ADR 0068 decision 1(d): a WebAuthn-ONLY user's password form can never satisfy
|
|
845
|
+
# MFA by itself — the passkey leg (POST /ui/reauth/webauthn) must run first.
|
|
846
|
+
# Checked BEFORE the rate limiter (parallel to the anti-loop check) so a
|
|
847
|
+
# password-first submission burns no limiter slot and no password verify runs
|
|
848
|
+
# before the ceremony. Never "Invalid code." — the user has no code to type.
|
|
849
|
+
wa_options, wa_notice = await _reauth_webauthn_state(
|
|
850
|
+
request, auth, token, mfa, satisfied
|
|
851
|
+
)
|
|
852
|
+
return HTMLResponse(
|
|
853
|
+
pages.reauth(
|
|
854
|
+
next_,
|
|
855
|
+
mfa_needed=False,
|
|
856
|
+
webauthn_options=wa_options,
|
|
857
|
+
webauthn_notice=wa_notice,
|
|
858
|
+
error=wa_notice
|
|
859
|
+
or "Complete the passkey prompt first, then re-enter your password.",
|
|
860
|
+
),
|
|
861
|
+
status_code=400,
|
|
862
|
+
)
|
|
863
|
+
client = request.client.host if request.client else None
|
|
864
|
+
if not allow_reauth_attempt(auth, identity, client): # per-ACTOR, not the sign-in budget
|
|
865
|
+
raise HTTPException(429, "too many attempts", headers={"Retry-After": "30"})
|
|
866
|
+
# Satisfy whichever factor is pending — TOTP first (mirrors require_step_up), then
|
|
867
|
+
# password. The code is only demanded from a user with an ENROLLED authenticator
|
|
868
|
+
# (decision 1(c): the code branch keys on TOTP enrollment alone — a WebAuthn-only
|
|
869
|
+
# user is never asked for a code): a required-but-unenrolled account reaches this
|
|
870
|
+
# page on its way to enrolling (L4b) and has nothing to type — its enrollment routes
|
|
871
|
+
# gate on the password step-up alone (require_ui_reauth_only), exactly like the JSON
|
|
872
|
+
# require_reauth_only. Error re-renders re-stage FRESH assertion options (decision
|
|
873
|
+
# 1(e)): the prior challenge was single-use, and the passkey button must survive a
|
|
874
|
+
# failed password/code attempt.
|
|
875
|
+
mfa_enrolled = mfa.enabled
|
|
876
|
+
if mfa_enrolled and not satisfied:
|
|
877
|
+
code = form.get("code", "").strip()
|
|
878
|
+
if not code or not await auth.verify_mfa(token, code, client=client):
|
|
879
|
+
wa_options, wa_notice = await _reauth_webauthn_state(
|
|
880
|
+
request, auth, token, mfa, await auth.mfa_satisfied(token)
|
|
881
|
+
)
|
|
882
|
+
return HTMLResponse(
|
|
883
|
+
pages.reauth(
|
|
884
|
+
next_,
|
|
885
|
+
mfa_needed=True,
|
|
886
|
+
webauthn_options=wa_options,
|
|
887
|
+
webauthn_notice=wa_notice,
|
|
888
|
+
error="Invalid code.",
|
|
889
|
+
)
|
|
890
|
+
)
|
|
891
|
+
# 7.5.1 (ADR 0077): mint the single-use grant bound to this continuation's action. action.action
|
|
892
|
+
# is None for every non-factor continuation (replay/purge/config/create-user), so reauth mints
|
|
893
|
+
# nothing there and those flows stay byte-identical; the factor-binding lanes tag their action.
|
|
894
|
+
if not await auth.reauth(
|
|
895
|
+
identity, form.get("password", ""), token=token, client=client, purpose=action.action
|
|
896
|
+
):
|
|
897
|
+
still_unsatisfied = not await auth.mfa_satisfied(token)
|
|
898
|
+
wa_options, wa_notice = await _reauth_webauthn_state(
|
|
899
|
+
request, auth, token, mfa, not still_unsatisfied
|
|
900
|
+
)
|
|
901
|
+
return HTMLResponse(
|
|
902
|
+
pages.reauth(
|
|
903
|
+
next_,
|
|
904
|
+
mfa_needed=mfa_enrolled and still_unsatisfied,
|
|
905
|
+
webauthn_options=wa_options,
|
|
906
|
+
webauthn_notice=wa_notice,
|
|
907
|
+
error="Incorrect password.",
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
# Fully stepped up. Hand control back per the action's continuation style:
|
|
911
|
+
# - an unlock target is a GET admin form → 303-GET-redirect so it re-opens inside the now
|
|
912
|
+
# fresh window; the operator then submits the body-carrying POST (incl. a create-user
|
|
913
|
+
# password) once, never crossing /ui/reauth (the stateless confirm-after-step-up path).
|
|
914
|
+
# - otherwise it is a body-less POST action → auto-retry it via the same-origin submit form.
|
|
915
|
+
if is_unlock_action(next_):
|
|
916
|
+
return RedirectResponse(next_, status_code=303)
|
|
917
|
+
return HTMLResponse(pages.reauth_continue(next_))
|
|
918
|
+
|
|
919
|
+
# ADR 0068 decision 6: the browser passkey leg of step-up. A cookie-authed JSON POST
|
|
920
|
+
# (the sanctioned /ui carve — the cookie stays confined to /ui deps; bearer_token()
|
|
921
|
+
# is untouched) that verifies an assertion and stamps the session's MFA leg ONLY —
|
|
922
|
+
# the operator still submits POST /ui/reauth (password) for reauth_at + the WP-L3-13
|
|
923
|
+
# client re-anchor. NOT registered as a continuation (body-carrying JSON — part of the
|
|
924
|
+
# step-up mechanism itself). MFA-pending sessions pass (the assertion IS the proof);
|
|
925
|
+
# must-change confinement is mirrored manually like both /ui/reauth handlers.
|
|
926
|
+
@app.post("/ui/reauth/webauthn")
|
|
927
|
+
async def ui_reauth_webauthn(request: Request) -> Response:
|
|
928
|
+
assert_same_origin(request)
|
|
929
|
+
auth = get_auth(request)
|
|
930
|
+
token = session_token(request)
|
|
931
|
+
identity = await auth.identity_for_token(token) if auth is not None else None
|
|
932
|
+
if auth is None or not token or identity is None:
|
|
933
|
+
return JSONResponse({"ok": False, "error": "session expired"}, status_code=401)
|
|
934
|
+
if identity.must_change_password:
|
|
935
|
+
# Mirror require_ui's confinement: a must-change session can only rotate (L4b).
|
|
936
|
+
return JSONResponse({"ok": False, "error": "password change required"}, status_code=403)
|
|
937
|
+
rp = webauthn_rp(request)
|
|
938
|
+
if rp is None:
|
|
939
|
+
return JSONResponse({"ok": False, "error": "rp_unavailable"}, status_code=409)
|
|
940
|
+
client = request.client.host if request.client else None
|
|
941
|
+
if not allow_reauth_attempt(auth, identity, client): # per-ACTOR, not the sign-in budget
|
|
942
|
+
return JSONResponse(
|
|
943
|
+
{"ok": False, "error": "too many attempts"},
|
|
944
|
+
status_code=429,
|
|
945
|
+
headers={"Retry-After": "30"},
|
|
946
|
+
)
|
|
947
|
+
try:
|
|
948
|
+
body = await request.json()
|
|
949
|
+
response_json = json.dumps(body["response"])
|
|
950
|
+
except (ValueError, KeyError, TypeError):
|
|
951
|
+
return JSONResponse({"ok": False, "error": "malformed request"}, status_code=400)
|
|
952
|
+
ok = await auth.finish_webauthn_assertion(
|
|
953
|
+
token, response_json, client=client, rp_id=rp[0], origin=rp[1]
|
|
954
|
+
)
|
|
955
|
+
if not ok:
|
|
956
|
+
return JSONResponse(
|
|
957
|
+
{"ok": False, "error": "passkey verification failed"}, status_code=400
|
|
958
|
+
)
|
|
959
|
+
return JSONResponse({"ok": True})
|
|
960
|
+
|
|
961
|
+
# Bulk dead-letter replay (M3): re-queue ALL dead deliveries for one channel. Like message
|
|
962
|
+
# replay it is require_step_up (→ require_ui_step_up, which 303s to /ui/reauth on a stale
|
|
963
|
+
# step-up; the channel is in the PATH so the auto-retry re-POST carries it — no lost body).
|
|
964
|
+
# Reuses the JSON replay_dead_letters handler, so the dual-control approval gate applies: when
|
|
965
|
+
# it holds the op for a second approver, surface that instead of redirecting.
|
|
966
|
+
async def _ui_dl_replay(
|
|
967
|
+
request: Request,
|
|
968
|
+
channel_id: str | None,
|
|
969
|
+
destination_name: str | None,
|
|
970
|
+
engine: Any,
|
|
971
|
+
identity: Identity,
|
|
972
|
+
gate: Any,
|
|
973
|
+
) -> Response:
|
|
974
|
+
assert_same_origin(request)
|
|
975
|
+
# channel_id=None ⇒ every channel (the all-channels scope, L6b); the JSON handler
|
|
976
|
+
# pre-checks scope and refuses a channel-scoped user before mutating anything.
|
|
977
|
+
result = await core.replay_dead_letters(
|
|
978
|
+
DeadLetterReplayRequest(channel_id=channel_id, destination_name=destination_name),
|
|
979
|
+
Response(),
|
|
980
|
+
engine=engine,
|
|
981
|
+
identity=identity,
|
|
982
|
+
gate=gate,
|
|
983
|
+
request=request,
|
|
984
|
+
)
|
|
985
|
+
if isinstance(result, PendingApprovalResponse):
|
|
986
|
+
return HTMLResponse(pages.dead_letter_pending(result))
|
|
987
|
+
return RedirectResponse("/ui/dead-letters", status_code=303)
|
|
988
|
+
|
|
989
|
+
# L6b (#75 parity): replay ALL dead deliveries across every channel in one action (the
|
|
990
|
+
# desktop's null-scope "Replay all"). Declared before the {channel_id} routes; the
|
|
991
|
+
# literal `replay-all` can't be a channel id (it has no `/replay` suffix). Same
|
|
992
|
+
# step-up + dual-control gate; the JSON handler still denies channel-scoped users.
|
|
993
|
+
@app.post("/ui/dead-letters/replay-all")
|
|
994
|
+
async def ui_replay_all_dead_letters(
|
|
995
|
+
request: Request,
|
|
996
|
+
engine: Any = Depends(deps.get_engine),
|
|
997
|
+
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_REPLAY)),
|
|
998
|
+
gate: Any = Depends(deps.get_gate),
|
|
999
|
+
) -> Response:
|
|
1000
|
+
return await _ui_dl_replay(request, None, None, engine, identity, gate)
|
|
1001
|
+
|
|
1002
|
+
@app.post("/ui/dead-letters/{channel_id}/replay")
|
|
1003
|
+
async def ui_replay_dead_letters(
|
|
1004
|
+
channel_id: str,
|
|
1005
|
+
request: Request,
|
|
1006
|
+
engine: Any = Depends(deps.get_engine),
|
|
1007
|
+
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_REPLAY)),
|
|
1008
|
+
gate: Any = Depends(deps.get_gate),
|
|
1009
|
+
) -> Response:
|
|
1010
|
+
# All dead deliveries for the channel (every destination).
|
|
1011
|
+
return await _ui_dl_replay(request, channel_id, None, engine, identity, gate)
|
|
1012
|
+
|
|
1013
|
+
@app.post("/ui/dead-letters/{channel_id}/{destination_name}/replay")
|
|
1014
|
+
async def ui_replay_dead_letters_dest(
|
|
1015
|
+
channel_id: str,
|
|
1016
|
+
destination_name: str,
|
|
1017
|
+
request: Request,
|
|
1018
|
+
engine: Any = Depends(deps.get_engine),
|
|
1019
|
+
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_REPLAY)),
|
|
1020
|
+
gate: Any = Depends(deps.get_gate),
|
|
1021
|
+
) -> Response:
|
|
1022
|
+
# Just the dead deliveries for this (channel, destination).
|
|
1023
|
+
return await _ui_dl_replay(request, channel_id, destination_name, engine, identity, gate)
|
|
1024
|
+
|
|
1025
|
+
@app.post("/ui/csp-report")
|
|
1026
|
+
async def ui_csp_report(request: Request) -> Response:
|
|
1027
|
+
# ASVS 3.5.1 — FIRST statement. The THIRD unguarded /ui POST is disposed of here, with the
|
|
1028
|
+
# NARROW guard rather than the full same-origin check, because the two are not interchangeable
|
|
1029
|
+
# on a report sink: `report-uri` delivery is document-initiated (Sec-Fetch-Site: same-origin),
|
|
1030
|
+
# but Reporting-API (`report-to`) delivery is made OUT OF BAND by the user agent's reporting
|
|
1031
|
+
# agent — no Sec-Fetch-* headers, possibly `Origin: null` — so assert_same_origin's Origin
|
|
1032
|
+
# fallback would 403 every modern report and silently blind the 3.7.5 canary. The property that
|
|
1033
|
+
# actually matters here is preserved: a report a FOREIGN site's CSP aimed at this endpoint
|
|
1034
|
+
# (log amplification) is refused. The residual exposure of the header-less path is bounded by
|
|
1035
|
+
# construction — the sink is unauthenticated, non-state-changing and observation-only: it
|
|
1036
|
+
# parses defensively, never echoes or acts on the body, logs one bounded PHI-free summary and
|
|
1037
|
+
# 204s, under the engine's 1 MiB request-body cap.
|
|
1038
|
+
assert_not_cross_site(request)
|
|
1039
|
+
# Browser-delivered CSP violation report (ASVS 3.7.5). UNAUTHENTICATED and non-mutating: a
|
|
1040
|
+
# browser attaches no session credential to a report POST, and this only observes. The body is
|
|
1041
|
+
# attacker-influenceable DATA (never instructions) — parse it defensively, log a BOUNDED,
|
|
1042
|
+
# PHI-free summary at WARNING (the /ui surface carries no message bodies in its URLs), and 204.
|
|
1043
|
+
# Never echo or act on the report. Both the legacy report-uri body and the modern report-to
|
|
1044
|
+
# ARRAY (the wired Reporting-Endpoints header) are normalized by ``_csp_report_bodies``.
|
|
1045
|
+
client = request.client.host if request.client else "<unknown>"
|
|
1046
|
+
raw = await request.body()
|
|
1047
|
+
if not raw:
|
|
1048
|
+
_log.warning("CSP violation report from %s: %s", client, "empty")
|
|
1049
|
+
return Response(status_code=204)
|
|
1050
|
+
try:
|
|
1051
|
+
doc = json.loads(raw.decode("utf-8", "replace"))
|
|
1052
|
+
except ValueError:
|
|
1053
|
+
_log.warning("CSP violation report from %s: %s", client, "malformed-json")
|
|
1054
|
+
return Response(status_code=204)
|
|
1055
|
+
bodies = _csp_report_bodies(doc)
|
|
1056
|
+
if bodies is None:
|
|
1057
|
+
_log.warning("CSP violation report from %s: %s", client, "non-object")
|
|
1058
|
+
return Response(status_code=204)
|
|
1059
|
+
# Partition the BATCH, never classify it by one entry. The canary fires once per page load on
|
|
1060
|
+
# every conforming browser and the Reporting API batches per endpoint, so a real violation
|
|
1061
|
+
# raised on the same page load arrives in the same POST alongside the canary's report. Only
|
|
1062
|
+
# the canary's own entries are dropped to DEBUG (so it never floods the operational log); a
|
|
1063
|
+
# batch containing ANY other blocked URL — including "inline", the shape a real XSS attempt
|
|
1064
|
+
# produces — still WARNS, and the warning summarises the REAL entries, not the canary.
|
|
1065
|
+
origin = _request_origin(request)
|
|
1066
|
+
real = [b for b in bodies if not _is_expected_csp_probe_report(b, origin)]
|
|
1067
|
+
canary_count = len(bodies) - len(real)
|
|
1068
|
+
if canary_count:
|
|
1069
|
+
_log.debug("CSP enforcement canary blocked as designed (%d report(s))", canary_count)
|
|
1070
|
+
if real or not bodies:
|
|
1071
|
+
# Bounded on BOTH axes — per-field (256 chars, in the summariser), per-batch (the first
|
|
1072
|
+
# few entries) and overall (1024 chars) — so a hostile flood cannot inflate the log.
|
|
1073
|
+
shown = " | ".join(_csp_report_summary(b) for b in real[:_CSP_REPORT_SUMMARY_MAX])
|
|
1074
|
+
if len(real) > _CSP_REPORT_SUMMARY_MAX:
|
|
1075
|
+
shown += f" (+{len(real) - _CSP_REPORT_SUMMARY_MAX} more)"
|
|
1076
|
+
_log.warning("CSP violation report from %s: %s", client, (shown or "empty")[:1024])
|
|
1077
|
+
return Response(status_code=204)
|