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
56 changes: 56 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17317,3 +17317,59 @@ committed alongside the fix rather than filed separately, since neither makes se

**Not done:** pushing, opening a PR, or anything that would exercise this in real CI. Committed and
handed to the Lander.

## 1370. operator-supplied names reach /ui URL paths unencoded or half-encoded, so a name carrying a slash addresses a different route

> 🔢 **Filed 2026-08-27 (builder 2) - BUILT IN THIS COMMIT, not yet landed.** Gap 2 of the two the Lander scoped on **PR 530**, this seat's own abandoned lane, re-implemented against current `main` rather than cherry-picked.
> Verdict: build
> Closing-act: code

**Cluster:** Web console security. **Priority:** P2. **Verdict:** build.
**Severity:** no engine effect, no PHI axis, and **no deployment axis (sec. 0)** -- with zero deployments this is a condition a FIRST deployment would meet, not an exposure anyone has today.

**What:** connection names and channel ids are interpolated into `/ui` URL paths. They are **unconstrained free text** -- the registry checks only for a duplicate and no charset gate exists -- so a name carrying `/` splits into two path segments and addresses a different route.

**MAIN WAS IN TWO DIFFERENT STATES, AND THE HALF-PROTECTED ONE IS THE MORE INTERESTING:**

```
connections.py:64 quote(r.name) PARTIAL -- quote's default is safe="/", so the separator passes
connections.py:200 quote(name) PARTIAL -- same
admin.py:363,381 {role.id} RAW
messages.py:618,628 {ch}/{dest} RAW
```

**A bare `quote()` reads as protection and provides none against the one character that matters.** Measured, not reasoned: `quote("IB/ACME")` returns it **unchanged**, while `quote("a?b")` and `quote("a#b")` are encoded -- so the call looks like it works everywhere it is tested by hand. `safe=""` is the whole fix.

**The fix:** `_seg()` in `pages/_common.py`, and the six sites routed through it.

**NOT A BLANKET SWEEP, AND THE EXCLUSION IS THE LOAD-BEARING PART.** `_auth`'s re-auth `next` carries a whole PATH inside a QUERY parameter, where `safe="/"` is **correct**; routing it through `_seg` would break every re-auth redirect. `connections.py:59` and `:384` keep a bare `quote()` for the same reason -- they build `?channel_id=` query values, not path segments. **A sweep of "every `quote()` call" would ship a broken login**, so a test pins the re-auth encoding against exactly that.

**Verification:** the seven tests come from the abandoned branch and **pass unmodified against this re-implementation** -- written for a different implementation of the same contract, so they corroborate rather than restate. Five mutants, all killed, each by a distinct red set: `_seg` reverted to the default `safe`; `_seg` over-encoding (the negative control fires); one site reverted to raw; **one site reverted to a bare `quote()` -- caught by the structural scan alone**; the custom-role site reverted.

**Related:** the sibling gap from the same PR 530 dispatch -- fetch-metadata does not cover the `/ui/static` mount (`_is_ui_fetch_scope` absent from main) -- is **NOT in this commit** and still wants building.

**Source:** dispatched by the Lander off PR 530 with a per-file measurement. Three searches in that scouting returned false zeros on SPELLING alone: `def test` missing 8 `async def test` functions, `def seg` matching `segment_ids` as a prefix, and `\bseg\(` unable to match `_seg(` because underscore is a word character.
## 1371. fetch-metadata never reaches the /ui/static mount, because a route dependency cannot run for a Mount

> 🔢 **Filed 2026-08-27 (builder 2) - BUILT IN THIS COMMIT, not yet landed.** Gap 1 of the two the Lander scoped on **PR 530**, re-implemented against current `main` rather than cherry-picked.
> Verdict: build
> Closing-act: code

**Cluster:** Web console security. **Priority:** P2. **Verdict:** build.
**Severity:** no engine effect, no PHI axis, and **no deployment axis (sec. 0)** -- a condition a FIRST deployment would meet, not an exposure anyone has today.

**What:** `main` DOES check fetch-metadata -- `_auth`'s per-route helper refuses any request whose `Sec-Fetch-Site` says cross-site. **But `/ui/static` is a Starlette `Mount`, not an `APIRoute`, so no route dependency ever runs for it.** The asset tier is the one `/ui` surface the per-route check cannot reach, and at a glance the console looks covered because the helper exists and is used across five modules.

**The fix is MIDDLEWARE, which is the only tier that sees a Mount.** `UiFetchMetadataMiddleware` plus `_is_ui_fetch_scope`, deliberately WIDER than the existing `_is_ui_html_path`: that predicate excludes `/ui/static` correctly, because CSP headers only apply to HTML. **Collapsing the two would remove this check's only purpose.**

**THREE CARVE-OUTS, EACH OF WHICH LOOKS LIKE A WEAKNESS AND IS NOT.** Every one is pinned by a test, and every corresponding mutant is a plausible hardening pass:

- **A cross-site top-level NAVIGATION passes.** An intranet link into the console is one; so is the OIDC callback, cross-site by construction. Without reading `Sec-Fetch-Mode`, **every real SSO login would 403 while every hermetic test still passed**. Method is part of safe -- a cross-site navigation carrying a POST is a CSRF submission -- and `object`/`embed` are refused because that is framing, not navigation.
- **An ABSENT header passes.** `Sec-Fetch-Site` is browser-populated; old browsers, reporting agents and every non-browser client omit it. Failing closed would refuse **the shipped Windows tray's own `GET /ui` probe**, which builds its client with no headers at all.
- **403, NEVER 404.** The tray classifies 404 as DISABLED and every other status as ENABLED, so a "do not disclose the route" pass would make a healthy console report as switched off.

**Verification:** the eight tests come from the abandoned branch and **pass unmodified against this re-implementation** -- written for a different implementation of the same contract. Five mutants, all killed, each by a distinct red set, and each is a change someone would plausibly propose as an improvement: narrow the scope to the HTML predicate; fail closed on absence; return 404; drop the navigation carve-out; admit framing. Full webconsole suite 393 passed, 3 skipped.

**Related:** #1370, the sibling gap from the same PR 530 dispatch. Both are now built; PR 530's branch itself remains superseded and should not be cherry-picked.

**Source:** dispatched by the Lander off PR 530. Its brief called this file a possible silent-green test file -- 180 lines with zero `def test`. It collects **8**; they are `async def test`, and a pattern anchored on `def test` cannot match one.
81 changes: 80 additions & 1 deletion messagefoundry_webconsole/_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,10 @@
import secrets

from starlette.datastructures import MutableHeaders
from starlette.responses import PlainTextResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send

from ._auth import browser_hardening_enabled, security_headers_context
from ._auth import _CROSS_ORIGIN_FETCH, browser_hardening_enabled, security_headers_context
from ._html import reset_csp_nonce, set_csp_nonce

#: The route (registered in :mod:`.routes.core`) the browser POSTs CSP violation reports to, and the
Expand Down Expand Up @@ -222,6 +223,84 @@ def _is_ui_html_path(path: str) -> bool:
return (path == "/ui" or path.startswith("/ui/")) and not path.startswith("/ui/static")


def _is_ui_fetch_scope(path: str) -> bool:
"""Every /ui path INCLUDING the static mount -- deliberately WIDER than :func:`_is_ui_html_path`.

The asset tier is exactly what a per-route validator cannot reach: ``/ui/static`` is mounted as a
Starlette ``Mount``, not registered as an ``APIRoute``, so a route dependency never runs for it.
THAT GAP IS THE REASON THIS CHECK IS MIDDLEWARE RATHER THAN A DEPENDENCY, so excluding the mount
here would remove its only purpose. The narrower predicate above is correct for CSP headers, which
only apply to HTML; do not collapse the two.
"""
return path == "/ui" or path.startswith("/ui/")


#: A cross-site request that is a SAFE TOP-LEVEL NAVIGATION is allowed -- an intranet link into the
#: console is one, and so is the OIDC callback, where the IdP's redirect back is cross-site BY
#: CONSTRUCTION. METHOD is part of safe: a cross-site navigation carrying a POST is a CSRF form
#: submission, and no supported flow makes one.
_SAFE_NAVIGATION_METHODS = frozenset({"GET", "HEAD"})
#: ``object``/``embed`` pull a subresource into someone else's page while still reporting
#: ``Sec-Fetch-Mode: navigate``. That is framing, not navigation, so it does not get the carve-out.
_FRAMING_DESTINATIONS = frozenset({"object", "embed"})


class UiFetchMetadataMiddleware:
"""Refuse a /ui request the BROWSER ITSELF labels cross-site (BACKLOG #1371, ASVS 3.5.3).

It shares its membership set with ``_auth``'s per-route cross-site check, lifted to middleware so
it ALSO covers the ``/ui/static`` Mount that route dependencies cannot see -- but it is NOT that
check at a wider scope. The per-route helper guards hand-picked routes where nothing legitimate
EVER arrives cross-site; applying that bare set to every /ui request would add top-level
NAVIGATIONS, which those callers never see.

**A CROSS-SITE TOP-LEVEL NAVIGATION IS LEGITIMATE AND MUST PASS.** An intranet link into the
console is one; so is the OIDC callback. Without reading ``Sec-Fetch-Mode`` as well, every real
SSO login would 403 while every hermetic test still passed.

**ABSENT IS ALLOWED, AND THAT IS THE LOAD-BEARING HALF.** ``Sec-Fetch-Site`` is browser-populated:
an old browser, an out-of-band reporting agent, and every non-browser client omit it entirely.
Failing closed on absence would refuse the shipped Windows tray's own ``GET /ui`` probe, which
builds its client with no headers at all. So this rejects only a header that is PRESENT and says
cross-site or same-site.

**403, NEVER 404.** The tray classifies 404 as DISABLED and every other status as ENABLED, so a
404 here would make it report a healthy console as switched off. A later "return 404 rather than
disclose the route" hardening pass would look like an improvement and silently break the tray.
"""

def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or not _is_ui_fetch_scope(scope.get("path", "")):
await self.app(scope, receive, send)
return
# Read from the raw scope rather than building a Request: this runs for every /ui asset, and
# header names on the wire are lower-cased bytes by ASGI contract.
site = mode = dest = None
for key, value in scope.get("headers") or ():
if key == b"sec-fetch-site":
site = value.decode("latin-1")
elif key == b"sec-fetch-mode":
mode = value.decode("latin-1")
elif key == b"sec-fetch-dest":
dest = value.decode("latin-1")
if site is None or site not in _CROSS_ORIGIN_FETCH:
await self.app(scope, receive, send)
return
if (
mode == "navigate"
and str(scope.get("method", "")).upper() in _SAFE_NAVIGATION_METHODS
and dest not in _FRAMING_DESTINATIONS
):
await self.app(scope, receive, send)
return
await PlainTextResponse("cross-site request rejected", status_code=403)(
scope, receive, send
)


class UiSecurityHeadersMiddleware:
"""Pure-ASGI /ui browser-security hardening (see the module docstring)."""

Expand Down
7 changes: 6 additions & 1 deletion messagefoundry_webconsole/mount.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from messagefoundry.api._ui_seam import UiDeps

from . import STATIC_DIR, _auth, assert_engine_seam, pages
from ._security import UiSecurityHeadersMiddleware
from ._security import UiFetchMetadataMiddleware, UiSecurityHeadersMiddleware
from ._static import AllowlistedStaticFiles
from .routes import (
account,
Expand Down Expand Up @@ -101,3 +101,8 @@ def mount_ui(app: FastAPI, deps: UiDeps) -> None:
# See :mod:`._security`.
if not any(getattr(m, "cls", None) is UiSecurityHeadersMiddleware for m in app.user_middleware):
app.add_middleware(UiSecurityHeadersMiddleware)
# Fetch-metadata (BACKLOG #1371). Registered the same idempotent way, and separately from the
# headers middleware because its SCOPE is deliberately wider -- it must reach the /ui/static Mount,
# which no route dependency can see.
if not any(getattr(m, "cls", None) is UiFetchMetadataMiddleware for m in app.user_middleware):
app.add_middleware(UiFetchMetadataMiddleware)
22 changes: 22 additions & 0 deletions messagefoundry_webconsole/pages/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from __future__ import annotations

from urllib.parse import quote


def _num(value: object) -> str:
"""Render a count/None as text ('—' for None)."""
Expand All @@ -19,3 +21,23 @@ def _secs(value: float | None) -> str:
if value is None:
return "—"
return f"{value:.0f}s"


def _seg(value: object) -> str:
"""Percent-encode ONE path segment, INCLUDING ``/`` (BACKLOG #1370).

``quote`` DEFAULTS TO ``safe="/"``, which leaves alone the single character a path segment turns
on. Measured rather than reasoned: ``quote("IB/ACME")`` returns it UNCHANGED, so a name carrying a
slash silently becomes two segments and addresses a different route. ``safe=""`` is the whole fix,
and it is why a bare ``quote`` call at one of these sites reads as protection while providing none
against the one character that matters.

CONNECTION NAMES ARE WHY THIS IS NOT THEORETICAL. They are unconstrained free text -- the registry
checks only for a duplicate and no charset gate exists -- so the "every interpolated id is a
``uuid4().hex``" argument that covers most /ui interpolations is FALSE for them.

NOT FOR A PATH LEGITIMATELY CARRIED IN A QUERY PARAMETER. ``_auth``'s re-auth ``next`` uses
``safe="/"`` deliberately, and routing it through here would break it. These sites are partitioned
by READING each one, never by a blanket builder.
"""
return quote(str(value), safe="")
5 changes: 3 additions & 2 deletions messagefoundry_webconsole/pages/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)

from .._html import Markup, el, page, register_nav, rows_table
from ._common import _seg

__all__ = [
"ad_groups_page",
Expand Down Expand Up @@ -360,7 +361,7 @@ def role_form_page(
description if description is not None else (role.description or "" if role else "")
)
perm_checked = checked if checked is not None else (role.permissions if role else ())
action = f"/ui/roles/custom/{role.id}/update" if role else "/ui/roles/custom"
action = f"/ui/roles/custom/{_seg(role.id)}/update" if role else "/ui/roles/custom"
form = el(
"form",
el("label", "Name", el("input", name="display_name", value=name_value, autofocus=True)),
Expand All @@ -378,7 +379,7 @@ def role_form_page(
"form",
el("button", "Delete role", type="submit"),
method="post",
action=f"/ui/roles/custom/{role.id}/delete",
action=f"/ui/roles/custom/{_seg(role.id)}/delete",
class_="ctl",
)
)
Expand Down
6 changes: 3 additions & 3 deletions messagefoundry_webconsole/pages/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from messagefoundry.api.models import ConnectionEventInfo, ConnectionRow

from .._html import Markup, el, page, rows_table, text
from ._common import _num, _secs
from ._common import _num, _secs, _seg

__all__ = [
"bulk_control_result",
Expand Down Expand Up @@ -61,7 +61,7 @@ def _name_cell(r: ConnectionRow) -> Markup:
el(
"a",
"ⓘ",
href=f"/ui/connection/{quote(r.name)}",
href=f"/ui/connection/{_seg(r.name)}",
class_="detail-link",
title="Connection details",
aria_label=f"Details for {_display_name(r.name)}",
Expand Down Expand Up @@ -197,7 +197,7 @@ def _flag_cell(r: ConnectionRow) -> Markup:
aria_label=("Unflag " if r.flagged else "Flag ") + name,
),
method="post",
action=f"/ui/connections/{quote(name)}/flag",
action=f"/ui/connections/{_seg(name)}/flag",
class_="ctl flagform",
)

Expand Down
5 changes: 3 additions & 2 deletions messagefoundry_webconsole/pages/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from messagefoundry.parsing.tree import TreeNode

from .._html import Markup, el, page, rows_table, text
from ._common import _seg

__all__ = [
"dead_letter_pending",
Expand Down Expand Up @@ -615,7 +616,7 @@ def dead_letters(data: DeadLetterList) -> Markup:
"form",
el("button", f"Replay all dead — {ch}", type="submit"),
method="post",
action=f"/ui/dead-letters/{ch}/replay",
action=f"/ui/dead-letters/{_seg(ch)}/replay",
class_="ctl",
)
for ch in channels
Expand All @@ -625,7 +626,7 @@ def dead_letters(data: DeadLetterList) -> Markup:
"form",
el("button", f"Replay {ch} → {dest}", type="submit"),
method="post",
action=f"/ui/dead-letters/{ch}/{dest}/replay",
action=f"/ui/dead-letters/{_seg(ch)}/{_seg(dest)}/replay",
class_="ctl",
)
for ch, dest in pairs
Expand Down
Loading
Loading