Skip to content

fix(webconsole): report the browser-hardening opt-out at start (BACKLOG #1118) - #782

Merged
wshallwshall merged 1 commit into
mainfrom
worktree-agent-a014ad4356ce58a9c
Sep 4, 2026
Merged

fix(webconsole): report the browser-hardening opt-out at start (BACKLOG #1118)#782
wshallwshall merged 1 commit into
mainfrom
worktree-agent-a014ad4356ce58a9c

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

The premise measurement came back dead, and that is the headline

BACKLOG #1118 asked how to reach ASVS 3.3.3 without breaking a cleartext loopback login. ADR 0172 landed since that research and removed the cleartext loopback bind, so there is nothing left to trade off. The engine always serves TLS, effective_https holds on the shipped default, and both cookies already resolve to their __Host- twins. No cookie code changed in this PR.

I measured before building anything, with a positive control on every reading.

What I measured Reading Control
ensure_api_tls_material(ApiSettings()) mints a pair, cert and key both on disk the tls_terminated_upstream topology returns None, so the proxy's own hop is not broken
effective_https over the served scheme True at scheme https False at scheme http, the pre-0172 default
session_cookie_name __Host-mf_session mf_session over http
oidc_flow_cookie_name __Host-mf_oidc_flow mf_oidc_flow over http
ApiSettings().tls_enabled / .exposure_protected False / False an operator chain flips both to True

The last row is the security trap the brief named, and the shipped code already avoids it. __main__.py copies the minted paths into a local model_copy that uvicorn reads and no gate does, so a self-signed placeholder never satisfies the off-loopback /ui exposure gate. The "generated-and-serving third state" the research asked for exists; it is just spelled as a local copy rather than a new field. I pinned it with a test so a later refactor cannot simplify it back into settings.api.

This covers every startable topology, not only the loopback default. The one posture still reaching the app over cleartext is a declared upstream terminator, and settings validation forces trusted_proxies alongside it, so exposure_protected is true there and effective_https holds anyway. There is no switch that returns the API socket to cleartext.

Import-path verification

Run with the primary interpreter and this worktree as cwd:

C:\Users\Scott\Code\MessageFoundry\.venv\Scripts\python.exe -c "import messagefoundry_webconsole; print(messagefoundry_webconsole.__file__)"
C:\Users\Scott\Code\MessageFoundry\.claude\worktrees\agent-a014ad4356ce58a9c\messagefoundry_webconsole\__init__.py

messagefoundry resolves inside the worktree too. Every reading above is against this branch's tree, not the primary checkout's.

What I built instead, and what I deliberately did not

I did not build the cookie work. There is none left. I built the one residual the item raised but does not grade.

MEFOR_WEBCONSOLE_DISABLE_BROWSER_HARDENING reverts both cookies to their bare names, and nothing at start said so. The predicate was read only from _auth.py and _security.py, both per-request. An operator who set it, or inherited it from a service environment, got a quietly weaker console with no signal anywhere. The serve ladder now names the variable, the two unprefixed names it reverts to, and the fact that Secure is not downgraded.

This is a report, not a control. The 2026-08-17 ruling that a warning earns nothing by itself still holds, and nothing here re-scores the cell on it. It matters more since ADR 0172 than before, for one plain reason: this env is now the only remaining way a deployment would lose the browser-enforced host binding. Before 0172 a cleartext bind lost it too, so this was one signal among several.

Severity, in the conditional the repo requires: a site that set this variable would lose the __Host- binding on both cookies, and until this change nothing would have told them.

Files

File Change
messagefoundry/__main__.py the opt-out advisory in the /ui ladder
messagefoundry_webconsole/__init__.py export the predicate and its env name at the package root, so the engine does not reach into _auth
tests/test_cli.py the advisory fires when set, and is absent by default
tests/test_api_tls.py the exposure-gate trap, and the cookie-name measurement made durable
docs/BACKLOG.md item #1118 plus its scored-index row

Negative control on the new advisory test: with __main__.py and the console __init__.py stashed, test_browser_hardening_opt_out_is_reported_at_start fails with assert 'MEFOR_WEBCONSOLE_DISABLE_BROWSER_HARDENING is set' in ''. The absence test passes either way, which is correct for a control.

Collision with PR 756

PR 756 (claude/backlog-1117-cookie-clear-symmetry) is open in messagefoundry_webconsole/_auth.py, on the cookie clear path, immediately beside the resolution sites this item anchors on. I did not touch _auth.py at all, and I did not refactor the resolution helpers or the shared predicates.

The one shared file is docs/BACKLOG.md. PR 756's hunk is at roughly line 7546 (item #1117); mine are at 410 and 7692 onward (item #1118). Separate hunks, about 150 lines apart, so they should merge clean. If a reviewer sees a conflict there, mine is the additive block at the end of #1118.

I also recorded the anchor drift in the item and told future readers to cite the two sites by function name rather than line number, precisely because 756 is about to move those lines.

Checks run and skipped

Check Result
ruff check (messagefoundry, messagefoundry_webconsole, tests) passed
ruff format --check passed, after formatting tests/test_cli.py
mypy strict (300 source files) passed
pytest tests/test_api_tls.py tests/test_cli.py tests/test_webconsole_{absent,mount,seam_snapshot}.py 190 passed
pytest packaging/messagefoundry-webconsole/tests/test_ui_hardening.py 11 passed
pytest tests/test_tooling_partition.py tests/test_phi_logging_inventory.py 35 passed
pytest tests/ -k backlog 117 passed, 4 skipped
scripts/docs/backlog_status_check.py OK, 657 items, one status each
pre-commit (11 hooks) passed
full suite skipped deliberately -- CI's job, per the contention measurement
hosted-runner legs (windows-service-smoke, load legs) never visible to a Builder; please read them

Glyph scan on the 162 added lines: zero hits, against a positive control of 889 hits for the same pattern over the whole of docs/BACKLOG.md.

What is left, and it is not a build

The closing act on #1118 is a scorecard re-score, and that record lives in the separate vault repository. I did not edit, commit or push anything there, and no cell content, coverage or gap appears in this PR. Whoever holds that record can now read a measurement instead of a plan.

Three residual disclosures from the research are unchanged by this pass and still belong on the cell:

  1. On a pure loopback bind the change is close to behaviourally inert, since http://127.0.0.1 was already a secure context.
  2. __Host- binds to the host, so a co-resident 127.0.0.1:* origin can still shadow by port.
  3. Nobody has yet measured whether a browser stores a prefixed cookie over https served with an untrusted self-signed chain. That is the only open measurement, and it is a browser test rather than engine work.

Open question for the Console, since a Builder cannot ask and wait: the item is still banner-open with Closing-act: scorecard-rescore, so I left its banner alone. If the fleet wants #1118 closed on the strength of this measurement, that needs the vault act first, and it needs a seat that may write there.

🤖 Generated with Claude Code

…OG #1118)

ADR 0172 dissolved this item's premise. The engine always serves TLS, so
effective_https holds on the shipped default and both cookies already resolve
to their __Host- twins with no cookie-code change. Measured, with controls,
and recorded in the item.

What is left is the one residual the item raised but did not grade:
MEFOR_WEBCONSOLE_DISABLE_BROWSER_HARDENING reverted the cookies to their bare
names and nothing at start said so. The serve ladder now names the variable,
the two unprefixed names, and the fact that Secure is not downgraded.

This is a report, not a control. Nothing here re-scores the cell on a warning.

Also pins the trap the research warned about: a minted certificate must not
make exposure_protected true, or a self-signed placeholder would satisfy the
off-loopback /ui gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Reviewed. The finding is that there was nothing to build, and proving that is the deliverable.

I flagged in the brief that ADR 0172 might already have pulled this item's lever, because the same thing had just dissolved a sibling item's research contest. This PR settles it with a positive control on every reading, which is what makes it a measurement rather than an assertion: the cookie names resolve to their __Host- twins over https and to the bare names over http, and the TLS material mints on the default while returning None on the proxy topology.

It did not manufacture cookie work to have something to ship. That is the outcome I asked for and the easy one to get wrong.

The security trap I warned about turns out to be already avoided, and pinning it is the right response. tls_enabled is literally bool(tls_cert_file), so a generated certificate surfacing through that key would have made exposure_protected true on every bind and silently satisfied the off-loopback /ui gate with a self-signed placeholder. The shipped code dodges this by copying the minted paths into a local model_copy that uvicorn reads and no gate does. That property was load-bearing and undocumented -- it reads like an implementation detail, so a future refactor could collapse it without anyone noticing. A test now holds it.

What it built is the honest residual, the one the cell raises but does not grade: MEFOR_WEBCONSOLE_DISABLE_BROWSER_HARDENING reverted both cookies to bare names with nothing at startup saying so. That matters more after ADR 0172, not less, because it is now the only remaining way a deployment would lose the host binding. It is reported as an advisory, not dressed up as a control, and nothing re-scores the cell on it.

Exporting the predicate through messagefoundry_webconsole/__init__ rather than reaching into _auth keeps the engine off a private module. Right call.

Collision avoided cleanly: it did not touch _auth.py at all, so PR 756's clear-path work is untouched. The only shared file is docs/BACKLOG.md, about 150 lines apart.

The glyph check with a positive control of 889 hits is the correct instrument -- a pattern that finds nothing anywhere is indistinguishable from a clean tree.

Reviewer independence, stated rather than assumed. I wrote this Builder's brief, so I am not independent of its scope; I am reviewing the diff, which I did not write. The owner has declined a separate Reviewer seat. Auto-merge is not armed.

@wshallwshall wshallwshall added the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 3, 2026
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 4, 2026
@wshallwshall
wshallwshall added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 58c87e7 Sep 4, 2026
59 of 62 checks passed
@wshallwshall
wshallwshall deleted the worktree-agent-a014ad4356ce58a9c branch September 4, 2026 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying. reviewed A reviewer has read this. Removed automatically when new commits arrive.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant