Skip to content

fix(webconsole): the browser-hardening opt-out drops __Host- only, so __Secure- is the fallback (BACKLOG #1117) - #934

Open
wshallwshall wants to merge 1 commit into
mainfrom
fix/1117-secure-cookie-prefix-fallback
Open

fix(webconsole): the browser-hardening opt-out drops __Host- only, so __Secure- is the fallback (BACKLOG #1117)#934
wshallwshall wants to merge 1 commit into
mainfrom
fix/1117-secure-cookie-prefix-fallback

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Implements the owner ruling of 2026-09-05 on BACKLOG #1117 (ASVS 3.3.1), and records it in the item.

PR 908 settled that item's lens contest by measurement and left exactly one thing open: nobody had written down whether the org browser-hardening opt-out means "no __Host-" or "no cookie-name prefix at all". Its builder correctly declined to answer that by building. The owner has ruled: it drops __Host- ONLY. __Secure- is therefore the pinned verb's required fallback, and this PR is the code half.

What changed

Site Before After
session_cookie_name, oidc_flow_cookie_name (messagefoundry_webconsole/_auth.py) two branches: prefixed, or bare three: the transport decides first, then the hatch picks __Host- or __Secure-
the opt-out arm over any effective-https posture mf_session / mf_oidc_flow, with Secure set __Secure-mf_session / __Secure-mf_oidc_flow
a genuinely cleartext origin bare names, no Secure unchanged, deliberately
the serve_ui opt-out report (messagefoundry/__main__.py) named mf_session / mf_oidc_flow names the __Secure- twins, and says Secure is what keeps them writable

Anchors are cited by symbol, not by line: #1117 records that these have already drifted twice.

Three things the item forbids, and what this does instead

  • Not widened exposure_protected. Untouched. The same predicate carries 3.3.3, and widening it would make it lie.
  • Not Secure unconditionally. The Secure attribute is exactly where it was, keyed on effective_https.
  • Not inferred one conjunct from the other. The name and the Secure attribute each take their own effective_https call. They agree because they read the same predicate, not because either derives from the other.

The fallback is only correct where Secure is genuinely present -- a browser drops __Secure- without Secure exactly as it drops __Host-. PR 908 measured Secure present on both opt-out arms; that was re-verified here rather than relied on, and every new test asserts the Secure attribute in the same row as the name, so the two cannot be graded apart. The cleartext arm keeps the bare name for that reason and is pinned as the negative control.

PR 868 made session_cookie_name the single resolver every set, clear and read site threads through. No second copy of its expression was introduced: the set sites still call the resolvers, which is where the new branch lives.

Tests, and each was proved RED first

packaging/messagefoundry-webconsole/tests/test_ui_cookie_prefix_wire.py (new). Extends PR 908's pattern rather than asserting a string: a real uvicorn on an ephemeral loopback port, wired through the shipped TLS chain (ensure_api_tls_material -> build_api_ssl_context -> uvicorn's ssl_context_factory, the same three calls serve makes), a real login POST, reading the response's own Set-Cookie. The scheme is then a fact about the socket, not a literal handed to an ASGI transport.

Arm Wire Before After
shipped default (mints TLS) + opt-out https mf_session, Secure set __Secure-mf_session
declared terminator + opt-out http, exposure_protected mf_session, Secure set __Secure-mf_session
shipped default, hardening on https __Host-mf_session unchanged (control)
cleartext, undeclared http mf_session, no Secure unchanged (control)

Measured red before the code moved: AssertionError: assert 'mf_session' == '__Secure-mf_session' on both opt-out arms, with the two controls green in the same run -- so the file was not vacuously red.

test_ui_hardening.py. test_the_opt_out_drops_host_only_and_keeps_the_secure_prefix is new and parametrised over three postures, covering the flow cookie (which the wire test's login leg does not emit) and asserting the resolver never returns __Host- under the opt-out. Two existing tests moved from the bare name to __Secure-mf_session; both were red before the change and green after.

tests/test_cli.py. The opt-out report assertion now requires the __Secure- names. A report naming a cookie the browser is not holding would send an operator hunting the wrong thing.

The re-score trigger PR 908 flagged: already restored, and not duplicated

PR 908's added text carries "Restore that trigger." with its reasoning, so this pass does not repeat it. What it does add is upstream of that: the item's "Scope corrections recorded here so they are not re-derived" paragraph still asserted the ground for the 2026-08-20 strike (__Secure- requires Secure exactly as __Host- does, so no reachable state has one available and not the other) as current fact. That is now marked struck in place, left standing rather than deleted because it was acted on -- a reader who meets the strike needs to find its reason and find it refuted.

The two now-unreachable cleartext tests: ANNOTATED, not changed

PR 908 flagged test_http_cookie_is_byte_identical and test_loopback_http_engages_headers_but_keeps_plain_cookie as making a reader conclude the shipped default is cleartext. Annotated, not fixed. Each docstring now says no messagefoundry serve posture has reached that branch since ADR 0172, and points at the wire-level file for what actually ships. They are kept rather than deleted because they are the control that stops an unconditional rename -- deleting them would remove the only assertion that a genuinely cleartext origin must keep the bare name.

Merge note: expect a conflict with PR 908

PR 908 is open, docs-only, and inserts at the end of #1117's block; so does this PR's new entry. Both are pure insertions before ## 1118., so git will very likely conflict there. The resolution is to keep both, PR 908's first. This PR's in-place edit to the scope-corrections paragraph does not overlap PR 908's diff. Land 908 first if you have the choice.

Checks

Run from this worktree with the primary checkout's interpreter (no .venv here; import source confirmed to resolve to this worktree, printed beside sys.executable, not the primary).

  • ruff format --check . -- 1257 files already formatted
  • ruff check on every changed file -- passed
  • mypy messagefoundry messagefoundry_webconsole (strict) -- Success, 301 source files. CI type-checks those two trees only, so the new test file is outside its scope.
  • pytest packaging/messagefoundry-webconsole/tests -- 429 passed, 3 skipped
  • pytest tests/test_cli.py -- 98 passed
  • pytest tests/test_api_tls.py tests/test_security_static.py tests/test_threat_model_doc_drift.py -- 148 passed, 89 skipped
  • pytest tests/test_backlog_status_check.py test_backlog_citation_check.py test_doc_guards_lane.py test_cp1252_console_safety.py test_asvs_rescore_handoff.py test_ledger_check.py test_link_resolution.py -- 192 passed
  • backlog_status_check.py --quiet, ledger_check.py docs/BACKLOG.md, dangling_citation_check.py, asvs_tally_lint.py, verdict_divergence_check.py, control_char_check.py, scan_forbidden.py -- all exit 0
  • All pre-commit hooks passed at commit time.

Skipped: the rest of the engine suite, and every hosted-runner-only leg (windows-service-smoke among them). Nothing in this diff touches the service, the store or a transport. /simplify was run on the changed code -- it folded the negative-control arm back onto the shared server fixture and removed a **kwargs construction plus its type ignore.

Not done, deliberately

  • The scorecard re-score. It lives in the vault and is somebody else's act, so #1117 stays open.
  • The browser measurement #1118 names -- whether a browser STORES a prefixed cookie over https served with an untrusted self-signed chain -- is still unrun. It is now load-bearing for the opt-out fallback as well as the shipped default, since both are prefixed names over the same minted chain.
  • No ADR allocated. An owner ruling is recorded in the item; no decision record was needed.

… __Secure- is the fallback (BACKLOG #1117)

Owner ruling 2026-09-05. It had never been written down whether the org
opt-out means "no __Host-" or "no cookie-name prefix at all", and the code
took the second reading, so both /ui cookies went bare on every opt-out
posture and ASVS 3.3.1's second limb failed there.

session_cookie_name and oidc_flow_cookie_name now answer in three branches:
the transport decides first, and only then does the hatch choose between
__Host- and __Secure-. The set sites reach both names through those same
resolvers, so PR 868's single-resolver guarantee is unchanged.

The fallback is taken only where Secure is genuinely set, asserted rather
than assumed: a browser drops a __Secure- cookie without Secure exactly as
it drops a __Host- one. Name and Secure key on the same effective_https
predicate through separate calls, because #1117 forbids inferring either
conjunct from the other.

The serve-time opt-out report named the bare cookies; it now names the
__Secure- twins and why Secure is what keeps them writable.

New wire-level tests serve /ui under a real uvicorn on an ephemeral
loopback port through the shipped TLS wiring and read the response's own
Set-Cookie. Both opt-out arms were proved red first.
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 6, 2026
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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant