Skip to content

feat(webconsole): close two holes in the /ui fetch-metadata navigation carve-out (BACKLOG #1122) - #875

Open
wshallwshall wants to merge 2 commits into
mainfrom
claude/backlog-1122-console-fetch-metadata
Open

feat(webconsole): close two holes in the /ui fetch-metadata navigation carve-out (BACKLOG #1122)#875
wshallwshall wants to merge 2 commits into
mainfrom
claude/backlog-1122-console-fetch-metadata

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

What this is

The console limb of BACKLOG #1122 (ASVS 3.5.3). It closes two holes in the /ui
fetch-metadata navigation carve-out. It is NOT the JSON API limb and NOT the engine limb;
both are named below as deliberately not started.

#1122 stays OPEN. Its closing act is a vault scorecard re-score plus a banner flip,
neither of which is a Builder's to make. The status banner is untouched; the last commit
adds a progress note only.

Re-measure first: the largest correction is that half of this was already built

A UiFetchMetadataMiddleware has been on main since 829ed98f8 under #1371, which
scoped it to the /ui/static Mount a route dependency cannot reach. So the middleware, its
mount_ui registration and eight tests already existed. Two arms the item proposes were
therefore already shipped and are not rebuilt: refusing Sec-Fetch-Mode: no-cors, and
refusing cross-site and same-site.

The load-bearing case the item names was already closed. The injected image load against
GET /ui/messages/{id}/attachments/{aid} from a sibling local port returns 403 on
unmodified main
same-site is in _CROSS_ORIGIN_FETCH and an image load is no-cors,
so it never reaches the carve-out. Reported rather than re-argued.

What was actually still open, measured on the built app before the fix

Request Before After
cross-site navigate, Sec-Fetch-Dest: object 403 403
cross-site navigate, Sec-Fetch-Dest: iframe 303 served 403
cross-site navigate, Sec-Fetch-Dest: frame 303 served 403
cross-site navigate, no Sec-Fetch-Dest 303 served 403
same-site navigate, document, no Sec-Fetch-User 303 served 403
same-site navigate, document, Sec-Fetch-User: ?1 303 303
cross-site navigate, document (the IdP redirect) 303 303
headerless (the tray probe) 303 303
  1. Sec-Fetch-Dest was a denylist of object/embed. iframe, frame and an omitted
    header all slipped it. frame-ancestors 'none' does not cover the gap: it is emitted only
    on the /ui HTML surface, and /ui/static — the tier this middleware exists to reach —
    carries no CSP. It is now an allowlist of document.
  2. The carve-out treated same-site like cross-site. SameSite=Strict strips the cookie
    from a cross-site request but not from a same-site one, and a site ignores the port, so a
    page on http://127.0.0.1:9999 could script window.open at a /ui URL with the
    operator's session cookie attached
    . A same-site navigation now also needs
    Sec-Fetch-User: ?1, the header a browser sets only for a user-activated navigation.

The navigation carve-out, and why the two halves are asymmetric

Sec-Fetch-User is demanded of the same-site half only. The IdP's redirect back to the
OIDC callback is a server-driven 302 carrying no user activation once the IdP session is
established, so demanding ?1 cross-site would refuse silent re-authentication — a broken
login that every hermetic test still passes, which is how a first cut of this middleware
shipped a 403 on every real SSO login once already.

For the same reason the carve-out is not narrowed to the two federated-login paths as the
item proposes: a cross-site navigation arrives with no cookie, so refusing an operator's
intranet link would cost usability and buy no authority.

Every rule added is reached only after Sec-Fetch-Site has arrived, i.e. after the browser
proved it speaks fetch metadata. That is what lets them ship with no supported-browser matrix
in the tree, and it is what an absence rule could not claim.

Blast radius: zero shipped /ui test call sites broken

The brief flagged "hundreds of shipped /ui test call sites" as the cost that might defeat
this. Measured: 331 /ui call sites across both suites, of which 91 send
Sec-Fetch-Site: same-origin
(never inspected), 28 send cross-site (already expecting
403), and only 4 send Sec-Fetch-Mode: navigate and needed the Sec-Fetch-Dest: document
a real browser sends. No fixture, no shared helper, no migration. The four are the SSO leg,
the OIDC callback, the OIDC success flow, and the carve-out test itself.

The fail-closed arm is DECLINED, with the number

Inverting the absence condition and running the console suite reds 231 of 408 tests, and
refuses the shipped Windows tray's tokenless GET /ui liveness probe
(messagefoundry/tray/probe.py:102). The deeper reason it cannot ship is not the test cost:
docs/SYSTEM-REQUIREMENTS.md:135 promises only "A modern browser" and no supported-browser
matrix exists anywhere in the tree
, so refusing a browser that omits the family is an
undeclared support decision. It stays a named precondition on the item.

Not done, and named rather than started

  • The three route-level navigation gates (routes/sso.py:64, routes/oidc.py:182,
    :226) still read mode is not None and mode != "navigate", which has both defects fixed
    here. They are not shadowed by the middleware — they also catch same-origin non-navigation
    fetches and they audit the reject, which middleware cannot — so changing them alters an
    audited reject shape and belongs in its own change.
  • The engine limb: an OWNER FUNDING DECISION. Re-measured over the built app: 50
    safe-method route rows against a positive control of 54 unsafe-method rows, 40 paths
    safe-only. Priced honestly it is "the JSON read API becomes POST-only". Arm B is also
    decorative there and must not be wired as a consolation — require() resolves identity
    solely through bearer_token() with no cookie fallback, so a validator would reject only
    requests already destined for 401.

Verification

  • Mutation-proved. Five mutants, each a change someone would plausibly propose as an
    improvement, each killed by exactly one distinct test: denylist restored; activation
    clause dropped as dead code; ?1 demanded on both halves; every same-site navigation
    refused; absence made to fail closed. Baseline green in the same harness run.
  • ruff check + ruff format --check: pass. mypy messagefoundry and
    mypy messagefoundry_webconsole (strict): pass.
  • Full web console suite: 411 passed, 3 skipped (18m02s). That run was on the tree before a
    /simplify pass consolidated three duplicate tests into existing ones; the re-run on the final
    tree is in flight at PR-open time and its result is posted as a comment. The consolidation
    changed no behaviour — the 19-case header matrix returns byte-identical results across it, and
    the mutation harness re-ran green on the final code.
  • tests/test_security_doc_drift.py (41 passed) and tests/test_ui_oidc_interstitial_route.py
    (13 passed) — the two engine-side files that touch this surface.
  • Skipped, per the brief's contention warning: the full engine suite. Please read the
    hosted legs. The docs/SECURITY.md Table A row count is pinned in
    tests/test_security_doc_drift.py, updated 35 to 36 in the same commit.

Also in here

  • The Table A contract row for this control, which #1371 landed without.
  • A pre-existing miscitation repaired: test_ui_fetch_metadata_mount.py's header cited #1122
    for #1371's work, recorded as debt on #1334.
  • Two corrections to the item's own text, per section 11's completeness rule: its bare "five
    bulk PHI GETs" becomes "at least eleven" (the repo's own tested predicate, run over the built
    app), and its false premise that the shipped console is a browser calling the JSON API
    (mount_ui registers /ui onto the engine's own app and the handlers call in-process).

wshallwshall added 2 commits September 4, 2026 18:24
…n carve-out (BACKLOG #1122)

The destination check was a denylist of object/embed. Measured on the built app: a
cross-site Sec-Fetch-Mode: navigate carrying Sec-Fetch-Dest: iframe, frame, or no
destination header at all was served, while object was refused in the same run. It
is now an allowlist of document. frame-ancestors 'none' does not cover that gap, as
it is emitted only on the /ui HTML surface and /ui/static carries no CSP.

The carve-out also treated same-site like cross-site. SameSite=Strict strips the
session cookie from a cross-site request but not from a same-site one, and a site
ignores the port, so a page on another loopback port could script window.open at a
/ui URL with the operator's cookie attached. A same-site navigation now additionally
needs Sec-Fetch-User: ?1.

Cross-site is deliberately NOT asked for ?1: the IdP redirect back to the OIDC
callback is a server-driven 302 with no user activation once the IdP session is
established, so demanding it would refuse silent re-authentication.

An absent header still passes, unchanged. Every rule here is reached only after
Sec-Fetch-Site has arrived, which is what lets it ship with no supported-browser
matrix in the tree. Failing closed instead reds 231 of 408 console tests and refuses
the shipped tray probe; that arm is declined with the measurement on the item.

Five mutations killed by one distinct test each. Adds the Table A contract row the
control shipped without, and repairs a pre-existing test-header miscitation.
…osed arm as declined

Progress note only. The status banner is deliberately untouched: the closing act is a
vault scorecard re-score plus a Lander banner flip, neither of which is a Builder's to
make, so shipped code beside an open item is the correct outcome.

Records what was verified by execution, including the correction that this item's own
load-bearing case (an injected image load at a /ui attachment from a sibling local
port) was already closed on main by #1371's middleware, and that the two remaining
holes were in the navigation carve-out rather than in the resource-load limb.

Names three things left undone rather than started: the fail-closed arm, declined with
its measured cost and the missing supported-browser matrix; the three route-level
navigation gates in routes/sso.py and routes/oidc.py, which audit their reject and so
belong in their own change; and the roughly fifty-route engine limb, an owner funding
decision priced as "the JSON read API becomes POST-only".

Also corrects two measured errors in the item's own text, per section 11's completeness
rule: the bare "five bulk PHI GETs" enumeration becomes "at least eleven", and the
false premise that the shipped console is a browser calling the JSON API.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Final-tree console suite: green

The re-run the PR body promised has finished on the head commit:

407 passed, 3 skipped, 2 warnings in 909.22s (0:15:09)

The count moved 411 -> 407 by design, and the four are accounted for. The /simplify pass
folded three duplicate tests into existing ones and merged two destination tests into a single
allowlist loop, which is a net of four fewer test functions covering strictly more cases:

  • test_iframe_and_frame_... and test_a_navigation_that_omits_the_destination_... merged into
    test_only_a_document_destination_gets_the_navigation_carve_out, now looping
    ("object", "embed", "iframe", "frame", None) — one allowlist rule in one place, instead of a
    split that encoded the shape of the old denylist.
  • test_absence_is_still_allowed_... folded into
    test_a_headerless_request_is_allowed_because_the_tray_sends_none, which already carried the
    tray-probe rationale; it now sweeps the mount, an HTML route and a PHI route.
  • test_a_cross_site_navigation_is_not_asked_for_user_activation was byte-identical to
    test_a_cross_site_top_level_navigation_is_allowed_because_a_real_login_is_one after this
    branch added Sec-Fetch-Dest: document to the latter. Its rationale moved into that test's
    docstring rather than being duplicated.

The consolidation sharpened the mutation proof rather than weakening it. Before it, two
mutants each reddened two tests; after it, all five mutants are killed by exactly one distinct
test each
, with the baseline green in the same harness run:

Mutation (each a plausible "improvement") Killed by
destination back to the object/embed denylist test_only_a_document_destination_gets_the_navigation_carve_out
drop the same-site activation clause as dead code test_a_same_site_navigation_needs_user_activation_because_the_cookie_rides_along
demand Sec-Fetch-User on both halves test_a_cross_site_top_level_navigation_is_allowed_because_a_real_login_is_one
refuse every same-site navigation outright test_an_operator_click_from_a_sibling_port_is_still_allowed
fail closed on an absent Sec-Fetch-Site test_a_headerless_request_is_allowed_because_the_tray_sends_none

Behaviour is unchanged across the consolidation: the 19-case header matrix returns byte-identical
results before and after, mypy (strict) passes on both messagefoundry and
messagefoundry_webconsole, and ruff check / ruff format --check are clean.

Still to be read by someone whose process outlives mine: the hosted legs. The PR reads BEHIND
at the time of writing — that is the Lander's to resolve; I have not enqueued, rebased or armed
auto-merge.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Manager note. No reviewed label: the owner retired the requirement and the context was removed from branch protection at about 23:31Z. The workflow still runs, so a red check named a reviewer has read this will appear here and blocks nothing.

A correction I owe this pull request. My brief named an injected image load from a sibling local port as the load-bearing case for the gate. The Builder measured it and found it already returns 403 on unmodified main - same-site is already refused and an image load is no-cors, so it never reaches the carve-out. My argument was wrong, and the Builder was right to keep measuring past it rather than building to my conclusion.

What it found instead is narrower and real: Sec-Fetch-Dest was a denylist, so iframe, frame and an omitted header slipped it; and the carve-out treated same-site like cross-site, so a page on another local port could open a /ui URL with the operator's session cookie attached.

Fail-closed is declined with the number - inverting one condition reds 231 of 408 console tests - but the deciding reason is that docs/SYSTEM-REQUIREMENTS.md promises only a modern browser and no supported-browser matrix exists in the tree.

Skipped: the full engine suite. Read the hosted legs.

@github-actions github-actions Bot added the unread Green and mergeable, but not marked read. Read it, then add: reviewed label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

@wshallwshall

This pull request is green and nobody has marked it read.

Every check that has reported is passing and the reviewed label is absent, so
a reviewer has read this is the only thing between it and a merge. Nothing else reports that, which is
why this comment exists (BACKLOG #1413).

To clear it, IN THIS ORDER:

  1. If the branch is behind main, update it FIRST: gh pr update-branch --repo MEFORORG/MessageFoundry 875.
    That push fires synchronize, and synchronize REMOVES the reviewed label. Labelling
    before updating throws the label away and costs a round trip.
  2. Read the diff.
  3. gh pr edit 875 --repo MEFORORG/MessageFoundry --add-label reviewed

DO NOT TRIAGE THIS FROM mergeStateStatus. It returns one value with precedence, so BEHIND,
DIRTY and UNSTABLE each mask the missing check -- the requirement is invisible until you act on
something else. This check never reads that field; see scripts/ci/check_unread_prs.py.

A seat can find every pull request in this state in one call: gh pr list --label unread.
The label is withdrawn automatically once this pull request leaves the state.

The reviewed label is a PROCESS gate. It records that a step happened. It does not
establish that an independent party looked, and nothing here should be read as saying it does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

unread Green and mergeable, but not marked read. Read it, then add: reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant