Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4953,7 +4953,7 @@ The comment immediately above says *"Scope is deliberately the posture the requi

## 1025. Three `require_ui_step_up` routes emit PHI with no `phi=`, so they charge no per-actor read budget

> 🔢 **Filed 2026-08-05 — not started.** Value **5/10** · Difficulty **2/10** · _fill-in_. `GET /ui/messages/search`, `/ui/messages/search/layered` and `/ui/uploaded-logs/file/{file_id}` put PHI on the wire through `require_ui_step_up` without `phi=`, so `require_ui`'s `allow_phi_read` throttle never runs for them. **A missing rate limit, not a missing authorization check** — all three still gate on the right permission.
> ✅ **SHIPPED 2026-08-06 — the two content-search render paths brought under the per-actor read budget; the third route was already covered.** Value **5/10** · Difficulty **2/10** · _fill-in_. **AMENDED 2026-08-05 — scope corrected against the code before building.** The filing's premise (all three routes charge no read budget) does not hold: `search_messages`, `layered_search` and `browse_uploaded_file` each call `enforce_phi_read_pacing` in their own body — which the console executes when it invokes them directly — so every request that actually reaches a handler was already charged at the cited commit `e0482aea`. The real gap was only the console's SHORT-CIRCUIT renders (`GET /ui/messages/search` bare-form, `GET /ui/messages/search/layered` no-preset) that return *before* the handler runs. **AMENDED 2026-08-06 — mechanism corrected from a gate-level `phi=` to an inline branch charge.** A gate-level `phi=` on `require_ui_step_up` charges in the dependency, i.e. on *every* request, so it would have double-charged the criteria/preset path — which already charges in the handler — the exact double-count that excludes the uploaded route. Instead each search route now charges `enforce_phi_read_pacing` **inline on its short-circuit branch only**, so the bare-form / no-preset render spends a token while a real search still charges exactly once. `GET /ui/uploaded-logs/file/{file_id}` was deliberately left unchanged — it has no short-circuit and `browse_uploaded_file` paces every call, so any second charge would double-count the same budget (empirically the first browse would `429` at a budget of 1). **A missing rate limit, not a missing authorization check** — all three still gate on the right permission. Shipped: the two search short-circuit charges + the `require_ui_step_up` docstring corrected + `docs/SECURITY.md` and the webconsole CHANGELOG aligned to the true mechanism.

**Cluster:** Security / PHI anti-automation. **Priority:** P2. **Verdict:** build (small). **Severity:** would leave three PHI-emitting console routes outside the per-actor read budget on first deployment, so an authorised-but-abusive actor could enumerate through them without hitting the 429 the sibling browse routes enforce. No unauthorised access.

Expand Down
4 changes: 2 additions & 2 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,11 +468,11 @@ PHI on the wire: the twelve message/search rows above marked PHI (`/messages`, `
them carry an explicit PHI-read hop refusal + per-actor budget; the other four (`/search/presets` × 3
and `POST /uploads/{id}/resend`) return no body content of their own.

**With the console served** (`serve_ui=True` — the deployed posture for a console-served instance) **at least ten more** emit PHI. ⚠️ **This is deliberately not a closed enumeration**, per CLAUDE.md §11: a fixed count is a liability that the next PHI-emitting route silently falsifies, and this one already was — it read "nine more" and omitted `POST /ui/messages/{id}/edit-resend`, whose `_reject` arm re-renders both the pristine `core.get_message` detail and the operator's edited `raw_value`. **The authority is the code, not this list:** a `/ui` route emits PHI if it renders a message body, and the ones that charge the per-actor read budget are exactly those passing `phi=True` to `require_ui` or `require_ui_step_up` (`messagefoundry_webconsole/_auth.py`). Known today:
**With the console served** (`serve_ui=True` — the deployed posture for a console-served instance) **at least ten more** emit PHI. ⚠️ **This is deliberately not a closed enumeration**, per CLAUDE.md §11: a fixed count is a liability that the next PHI-emitting route silently falsifies, and this one already was — it read "nine more" and omitted `POST /ui/messages/{id}/edit-resend`, whose `_reject` arm re-renders both the pristine `core.get_message` detail and the operator's edited `raw_value`. **The authority is the code, not this list:** a `/ui` route emits PHI if it renders a message body, and the ones that charge the per-actor read budget are those passing `phi=True` to `require_ui` / `require_ui_step_up` (`messagefoundry_webconsole/_auth.py`) **or** that reach `enforce_phi_read_pacing` some other way — a reused engine handler that paces in its own body (`search_messages` / `layered_search` / `browse_uploaded_file`), or a console route that charges it inline on a short-circuit render (BACKLOG #1025). Known today:
`GET /ui/messages`, `/ui/messages/{id}`, `/ui/messages/{id}/parse-tree`,
`/ui/messages/{id}/attachments/{id}`, `/ui/messages/{id}/edit`, `POST /ui/messages/{id}/edit-resend`,
`GET /ui/messages/search`, `/ui/messages/search/layered`, `/ui/dead-letters` and
`/ui/uploaded-logs/file/{file_id}` — of which the three search/upload routes carry **no** read budget (BACKLOG #1025). Note
`/ui/uploaded-logs/file/{file_id}` — all four charge the per-actor read budget (BACKLOG #1025): the reused engine handlers behind search, layered and uploaded-browse pace it in their own body, and #1025 additionally charges the two search routes' short-circuit renders (bare-form / no-preset) **inline**, since those return before the handler runs — the uploaded-browse route needs no extra charge (its handler paces every call and it has no short-circuit, so any second charge would double-count). Note
`GET /ui/messages/{message_id}/edit`: it renders the full message detail, so it requires
`messages:view_raw` **as well as** `messages:edit` and fails closed on either (BACKLOG #324). The
catalogue's "implies `messages:view_raw`" for `messages:edit` remains a **built-in-role convention,
Expand Down
38 changes: 27 additions & 11 deletions messagefoundry_webconsole/_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,17 +521,33 @@ def require_ui_step_up(
JSON ``require_phi_read`` routes charge. It defaults **False** because most routes riding this
factory are admin writes (user/role management, replay, purge, config reload) that emit no message
body: charging a PHI budget there would throttle administration on a quota that measures PHI reads.
Set it on a step-up route only when the route's own response carries PHI. It is **not** yet set on
every such route: at least ``GET /ui/messages/search``, ``GET /ui/messages/search/layered`` and
``GET /ui/uploaded-logs/file/{file_id}`` would still skip this budget on a deployed instance.
BACKLOG #324 gated the edit pair only, by owner ruling, and deliberately left those — a gap stated
here, not an accepted posture. That list is a **sample, not a census**: to know which routes charge
the budget today, read the gates, not this docstring.

ORDERING, deliberate: the budget is charged inside ``base``, i.e. BEFORE the step-up freshness
check below — so a request that ends in a 303 to ``/ui/reauth`` has already spent a token. That is
the fail-safe direction (an attacker cannot probe the route for free by letting the window go
stale) and is immaterial at the shipped 120-reads/60s default.
The charge runs in the DEPENDENCY, i.e. on EVERY request the route serves, BEFORE the body. So set
it only when the route's own response carries PHI *and* the handler it calls does NOT already pace
the budget itself — otherwise the two charges stack. The rule splits by handler:

* Handlers whose pacing lives ONLY in their ``require_phi_read`` Depends — e.g. ``get_message`` —
do NOT charge when a /ui route calls them DIRECTLY (that Depends is skipped on a direct call), so
the gate ``phi=`` is the route's single charge. ``GET /ui/messages/{id}/edit`` and
``POST /ui/messages/{id}/edit-resend`` set it for exactly this reason (BACKLOG #324).
* Handlers that call ``enforce_phi_read_pacing`` in their OWN body — ``search_messages``,
``export_messages``, ``layered_search``, ``browse_uploaded_file`` (``require_step_up`` paces
NON-GET only, so these step-up GETs pace themselves) — are ALREADY charged whenever a route
reaches them, so a gate ``phi=`` would spend the SAME per-actor bucket twice. A route that always
reaches such a handler (``GET /ui/uploaded-logs/file/{file_id}``) therefore passes NO ``phi=``. A
route that reaches one only on its criteria path and otherwise short-circuits to a render
(``GET /ui/messages/search``, ``GET /ui/messages/search/layered``) also passes NO ``phi=``:
instead it charges ``enforce_phi_read_pacing`` INLINE on the short-circuit branch only (BACKLOG
#1025), so the render is budgeted without double-charging the real-search path.

To know which routes charge the budget today, read the gates AND the handler bodies, not this
docstring.

ORDERING, deliberate (gate ``phi=`` routes, i.e. the edit pair): the budget is charged inside
``base``, i.e. BEFORE the step-up freshness check below — so a request that ends in a 303 to
``/ui/reauth`` has already spent a token. That is the fail-safe direction (an attacker cannot probe
the route for free by letting the window go stale) and is immaterial at the shipped 120-reads/60s
default. (An INLINE charge, as the two search routes use, runs after the dependency instead, so a
stale-window request there redirects before charging — harmless, since a redirect emits no PHI.)
"""
# allow_mfa_pending: the base must NOT fire the 6.3.3 redirect for a step-up route. This
# factory runs its OWN mfa_satisfied check below, which 303s to /ui/reauth *carrying the
Expand Down
19 changes: 19 additions & 0 deletions messagefoundry_webconsole/routes/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from messagefoundry.api._ui_seam import UiDeps
from messagefoundry.api.models import SearchPresetCreateRequest, SearchPresetCriteria
from messagefoundry.api.security import enforce_phi_read_pacing
from messagefoundry.auth import Identity, Permission

from .. import pages
Expand Down Expand Up @@ -54,6 +55,11 @@ async def _presets(engine: Any, identity: Identity, request: Request) -> list[An
async def ui_message_search(
request: Request,
engine: Any = Depends(deps.get_engine),
# No gate-level phi= here (BACKLOG #1025): require_ui_step_up charges the budget in the
# dependency, i.e. on EVERY request, but a criteria-bearing search already charges once inside
# core.search_messages (its body calls enforce_phi_read_pacing, app.py) — so a gate-level phi=
# would spend the same per-actor bucket twice on the real-search path. The genuinely-unpaced
# path is only the bare-form render, which is charged explicitly in its branch below.
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_READ)),
content: str | None = Query(None, max_length=512),
field_path: str | None = Query(None, max_length=32),
Expand Down Expand Up @@ -81,6 +87,11 @@ async def ui_message_search(
control_id=control_id or "",
)
if not has_criteria:
# BACKLOG #1025: this bare-form render returns WITHOUT reaching core.search_messages,
# whose body charges the per-actor read budget. Charge here — only on the short-circuit
# branch — so the render is under the same budget as a real search WITHOUT double-charging
# it (429 + Retry-After when the actor is over budget).
enforce_phi_read_pacing(request, identity)
return HTMLResponse(pages.message_search(None, presets=preset_list, **shared))
try:
# Call the JSON handler directly (its require_step_up Depends is skipped —
Expand Down Expand Up @@ -173,12 +184,20 @@ async def ui_delete_preset(
async def ui_layered_search(
request: Request,
engine: Any = Depends(deps.get_engine),
# No gate-level phi= here, as on ui_message_search (BACKLOG #1025): the composed run already
# charges once inside core.layered_search, so a gate-level phi= would double-charge that path.
# The no-preset 400 re-render is the only unpaced path, charged explicitly in its branch below.
identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_READ)),
presets: list[str] | None = Query(None),
) -> HTMLResponse:
preset_list = await _presets(engine, identity, request)
ids = ",".join(p for p in (presets or []) if p)
if not ids:
# BACKLOG #1025, as on ui_message_search: this no-preset 400 re-render returns before
# core.layered_search (which charges the budget in its own body), so charge here — only on
# this short-circuit branch — to bring it under the per-actor read budget without
# double-charging the composed-run path.
enforce_phi_read_pacing(request, identity)
return HTMLResponse(
pages.message_search(
None, error="select at least one preset to layer", presets=preset_list
Expand Down
14 changes: 14 additions & 0 deletions packaging/messagefoundry-webconsole/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ the engine compatibility range.
role gets `403` on the editor (it can still resubmit through the JSON API); no built-in role is
affected, since `ADMINISTRATOR` and `OPERATOR` grant both. Both verbs additionally charge the
per-actor PHI-read budget now, so either can return `429` + `Retry-After` under automation.
- **The two content-search step-up GET routes now charge the per-actor PHI-read budget on their
short-circuit renders** (BACKLOG #1025). `GET /ui/messages/search` and `GET /ui/messages/search/layered`
already charged the budget when they ran a real search — the reused engine handlers
(`search_messages` / `layered_search`) pace it in their own body — but the bare-form and no-preset
re-renders return *before* reaching those handlers, so on a deployed instance those render paths
would have skipped the per-actor read budget. Each now charges `enforce_phi_read_pacing` **inline on
its short-circuit branch only**, so the render spends a token and the route can return `429` +
`Retry-After` under automation, **without** double-charging the real-search path (a gate-level
`phi=`, which runs on every request, would have spent the bucket twice whenever a criterion was
supplied). `GET /ui/uploaded-logs/file/{file_id}`, named alongside them in the original report, was
found already paced by its own handler (`browse_uploaded_file`) on every call — it has no
short-circuit — and is deliberately left unchanged; a charge there would double-count the same
budget. **No engine UI seam change:** the charge reuses the existing `enforce_phi_read_pacing` helper
the reused handlers already call.

## [0.2.15] — 2026-07-06 — Early Access

Expand Down
Loading
Loading