feat(webconsole): close two holes in the /ui fetch-metadata navigation carve-out (BACKLOG #1122) - #875
feat(webconsole): close two holes in the /ui fetch-metadata navigation carve-out (BACKLOG #1122)#875wshallwshall wants to merge 2 commits into
Conversation
…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.
Final-tree console suite: greenThe re-run the PR body promised has finished on the head commit: The count moved 411 -> 407 by design, and the four are accounted for. The
The consolidation sharpened the mutation proof rather than weakening it. Before it, two
Behaviour is unchanged across the consolidation: the 19-case header matrix returns byte-identical Still to be read by someone whose process outlives mine: the hosted legs. The PR reads |
|
Manager note. No 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 What it found instead is narrower and real: Fail-closed is declined with the number - inverting one condition reds 231 of 408 console tests - but the deciding reason is that Skipped: the full engine suite. Read the hosted legs. |
|
This pull request is green and nobody has marked it read. Every check that has reported is passing and the To clear it, IN THIS ORDER:
DO NOT TRIAGE THIS FROM A seat can find every pull request in this state in one call: The |
What this is
The console limb of BACKLOG #1122 (ASVS 3.5.3). It closes two holes in the
/uifetch-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
UiFetchMetadataMiddlewarehas been onmainsince829ed98f8under #1371, whichscoped it to the
/ui/staticMount a route dependency cannot reach. So the middleware, itsmount_uiregistration and eight tests already existed. Two arms the item proposes weretherefore already shipped and are not rebuilt: refusing
Sec-Fetch-Mode: no-cors, andrefusing 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 onunmodified
main—same-siteis in_CROSS_ORIGIN_FETCHand an image load isno-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
navigate,Sec-Fetch-Dest: objectnavigate,Sec-Fetch-Dest: iframenavigate,Sec-Fetch-Dest: framenavigate, noSec-Fetch-Destnavigate,document, noSec-Fetch-Usernavigate,document,Sec-Fetch-User: ?1navigate,document(the IdP redirect)Sec-Fetch-Destwas a denylist ofobject/embed.iframe,frameand an omittedheader all slipped it.
frame-ancestors 'none'does not cover the gap: it is emitted onlyon the /ui HTML surface, and
/ui/static— the tier this middleware exists to reach —carries no CSP. It is now an allowlist of
document.same-sitelikecross-site.SameSite=Strictstrips the cookiefrom 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:9999could scriptwindow.openat a /ui URL with theoperator'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-Useris demanded of the same-site half only. The IdP's redirect back to theOIDC callback is a server-driven 302 carrying no user activation once the IdP session is
established, so demanding
?1cross-site would refuse silent re-authentication — a brokenlogin 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-Sitehas arrived, i.e. after the browserproved 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
/uitest call sites brokenThe brief flagged "hundreds of shipped
/uitest call sites" as the cost that might defeatthis. Measured: 331
/uicall sites across both suites, of which 91 sendSec-Fetch-Site: same-origin(never inspected), 28 sendcross-site(already expecting403), and only 4 send
Sec-Fetch-Mode: navigateand needed theSec-Fetch-Dest: documenta 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 /uiliveness probe(
messagefoundry/tray/probe.py:102). The deeper reason it cannot ship is not the test cost:docs/SYSTEM-REQUIREMENTS.md:135promises only "A modern browser" and no supported-browsermatrix 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
routes/sso.py:64,routes/oidc.py:182,:226) still readmode is not None and mode != "navigate", which has both defects fixedhere. 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.
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 identitysolely through
bearer_token()with no cookie fallback, so a validator would reject onlyrequests already destined for 401.
Verification
improvement, each killed by exactly one distinct test: denylist restored; activation
clause dropped as dead code;
?1demanded on both halves; every same-site navigationrefused; absence made to fail closed. Baseline green in the same harness run.
ruff check+ruff format --check: pass.mypy messagefoundryandmypy messagefoundry_webconsole(strict): pass./simplifypass consolidated three duplicate tests into existing ones; the re-run on the finaltree 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) andtests/test_ui_oidc_interstitial_route.py(13 passed) — the two engine-side files that touch this surface.
hosted legs. The
docs/SECURITY.mdTable A row count is pinned intests/test_security_doc_drift.py, updated 35 to 36 in the same commit.Also in here
test_ui_fetch_metadata_mount.py's header cited #1122for #1371's work, recorded as debt on #1334.
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_uiregisters /ui onto the engine's own app and the handlers call in-process).