Skip to content

Commit 46ea10a

Browse files
wshallwshallwshallwshallclaude
authored
fix(webconsole): key the cookie deletions on the same conjunct as the set (BACKLOG #1117) (#756)
The set site keys Secure on effective_https alone; the clear site inferred it from the resolved cookie NAME, which is effective_https AND browser_hardening_enabled(). With the org opt-out set over https -- a startable posture -- session_cookie_name returned the bare name, the clear took the unprefixed branch, and Starlette's delete_cookie defaults (secure=False, httponly=False, samesite="lax") revoked a Secure + HttpOnly + Strict cookie with a bare one. Three attributes lost rather than one: the 2026-08-20 research named Secure, and the same branch also drops HttpOnly and downgrades SameSite from Strict to Lax. clear_session_cookie and clear_oidc_flow_cookie now take secure from the same effective_https call the set sites use, with no branch at all, so the two sites cannot drift apart again by an edit to either. Nothing is deployed, so nothing is exposed today. On a first deployment this would reach the org-opt-out and declared-proxy postures. Tests: a posture matrix comparing the clear's guards against the set's across all four postures, with a positive control in the same run that Secure does move with the scheme, plus an end-to-end logout on the posture that used to lose the attributes. Seven of the new cases failed before the change. The BACKLOG entry additionally records the measurement that ADR 0172 already killed this item's founding premise: the default startable configuration now mints a self-signed pair and serves https, so both limbs of ASVS 3.3.1 hold on the shipped default with no line of _auth.py changed. That measurement is a record, not a re-score -- the scorecard move stays an act on the record of record. Co-authored-by: wshallwshall <mefordev@messagefoundry.org> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent fd44b0f commit 46ea10a

3 files changed

Lines changed: 198 additions & 15 deletions

File tree

docs/BACKLOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7689,6 +7689,18 @@ filing.
76897689

76907690
**Proposed work, by subject, all unallocated:** the browser-acceptance measurement, observed as STORED across Chrome, Edge, Firefox and Safari, which gates the re-key mechanism and must run before it is designed; the transport mechanism, shared with the 3.3.3 item and already filed as #1276; correcting the in-tree premise that a browser rejects a Secure or `__Host-` cookie over http (`_auth.py:113-114`, `_security.py:24-25`, `_security.py:112-118`, ADR 0143, and two contradicting sites in the withheld runbook), which is worth doing whatever the mechanism because three cells stand on it; fixing the clear-path opt-out asymmetry so the set and clear sites consult the same conjuncts; grading and repairing the two deletion blocks; adding the OIDC flow cookie and the Kerberos SSO route to whatever record grades this cell; striking "introduction of a `__Secure-` fallback name" from the cell's re-score triggers, since `__Secure-` requires Secure exactly as `__Host-` does and both cookies already ship `path="/"` with no Domain (`_auth.py:746`, `:823`), so no reachable state has one available and not the other; and reporting the browser-hardening opt-out on stderr at startup, which is shared work across several cells.
76917691

7692+
**Measured 2026-09-03 at `c2237d783`: this item's FOUNDING PREMISE is FALSE, and the third mechanism the research named as dissolving the contest is the one that shipped.** The premise was that "the default startable configuration is not an https context". It now is. ADR 0172 landed the #1276 mint-and-serve half: `ensure_api_tls_material` (`messagefoundry/api/tls.py:87`) mints a self-signed pair when no `[api].tls_cert_file` is configured, wired unconditionally into `uvicorn.run` at `messagefoundry/__main__.py:2885` and `:2901`. Driven rather than read: `ApiSettings()` still defaults `host='127.0.0.1'` (`config/settings.py:731`) and `serve_ui=True` (`:739`), and against a temp state dir that call returns a minted pair. The socket is https, so `effective_https` (`_auth.py:91`) is true and the default `/ui` login writes `__Host-mf_session=...; HttpOnly; Path=/; SameSite=strict; Secure` -- both limbs of the pinned verb, on the shipped default, with no line of `_auth.py` changed. The mechanism contest is therefore MOOT rather than resolved: nobody chose between the re-key and the transport, and the transport landed under a different item.
7693+
7694+
**The one topology ADR 0172 excludes does not reopen it.** The enumeration is bounded by the function rather than by recollection: `ensure_api_tls_material` has three return paths, and each lands `effective_https` true. An operator certificate returns it unchanged and sets `tls_enabled`; no certificate mints and the socket is https; `tls_terminated_upstream` returns no material (`api/tls.py:128-129`) and the engine speaks plaintext to the declared proxy, but `config/settings.py:979` refuses to START that topology without `trusted_proxies`, so `exposure_protected` (`:850`) is true there. Measured on the first and third arms in one run: a minted pair on the default, `material=None` with `exposure_protected=True` on the declared-proxy arm. The undeclared-proxy topology in this item's Severity paragraph also stops being a silent posture, because the engine serves only TLS on that bind -- a proxy speaking cleartext to it fails the handshake instead of downgrading quietly.
7695+
7696+
**Do NOT read that as a pass, and do not re-score on it alone.** Two things survive. The residual #1118 named is unrun: whether a browser STORES a `__Host-` cookie over https served with an untrusted self-signed chain. Until someone runs it the shipped default is correct by construction and unverified in a browser, which is weaker than pass. And both limbs still key on one boolean, so this item's structural complaint outlives its factual one.
7697+
7698+
**Landed 2026-09-03: the clear-path asymmetry, which holds under either mechanism, and the loss was three attributes rather than one.** The set site keys Secure on `effective_https` alone; the clear site inferred it from the resolved NAME, which is `effective_https AND browser_hardening_enabled()`. With the org opt-out set over https -- a startable posture -- `session_cookie_name` returned the bare name, the clear took the unprefixed branch, and Starlette's `delete_cookie` defaults (`secure=False, httponly=False, samesite="lax"`) revoked a Secure + HttpOnly + Strict cookie with a bare one. The research named Secure; the same branch also drops HttpOnly and downgrades SameSite from Strict to Lax. `clear_session_cookie` (`_auth.py:777`) and `clear_oidc_flow_cookie` (`:864`) now take `secure` from the same `effective_https` call the set site uses, with no branch at all, so the two sites cannot drift apart again by an edit to either. Pinned by a posture matrix in `packaging/messagefoundry-webconsole/tests/test_ui_hardening.py` comparing the clear's guards against the set's across all four postures, with a positive control in the same run that Secure does move with the scheme.
7699+
7700+
**The rewritten docstring did not make the asymmetry false, and re-measuring it was the point.** The `clear_session_cookie` docstring had been rewritten since the 2026-08-20 research to state that the `__Host-` deletion carries Secure. That was true, and it did not cover the reachable third state -- the bare name over https under the opt-out -- which is exactly where the loss happened. A docstring describing two of three branches reads to the next person as a completeness claim.
7701+
7702+
**Scope corrections recorded here so they are not re-derived.** At least these four, established by the 2026-08-20 research and re-checked at `c2237d783`: the two deletion blocks are ungraded by the cell; a third session-minting route exists on the Kerberos SSO leg (`messagefoundry_webconsole/routes/sso.py:87`), beyond the two the record enumerates; the OIDC flow cookie fails both limbs on the same postures and is named by neither this item nor the cell; and `__Secure-` requires Secure exactly as `__Host-` does, so no reachable state has one available and not the other. The clear-path fix above closes one line of the proposed work and grades none of the rest -- the scorecard move stays an act on the record of record, not a consequence of this entry.
7703+
76927704
## 1118. research an honest pass for ASVS 3.3.3 -- keeping the __Host- prefix on the shipped default without breaking a cleartext loopback login
76937705

76947706
> 🔢 **Re-scored 2026-08-20 -> P2.** Value **6/10** · Difficulty **4/10** · _quick win_. Verified at HEAD: both cookies resolve to their __Host- twin only under effective_https AND browser_hardening_enabled (messagefoundry_webconsole/_auth.py:739 and :815), HOST_COOKIE_NAME at :70 documents that cleartext keeps the bare name by design, and the opt-out env at :77 reverts to the bare name with nothing at start reporting the reversion. Difficulty 4 covers a research pass that has to settle a loopback-TLS or auto-TLS posture question for the default /ui bind rather than edit cookie code, with cannot-honestly-pass a valid outcome. _(was 6/10 · 4/10.)_

messagefoundry_webconsole/_auth.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -778,10 +778,18 @@ def clear_session_cookie(response: Response, request: Request) -> None:
778778
"""End this browser's session: delete the session cookie AND stamp
779779
:data:`CLEAR_SITE_DATA_HEADER` (ASVS 14.3.1). Pairs with a server-side revoke.
780780
781-
Deletes whichever name this scheme uses (:func:`session_cookie_name`). Over cleartext loopback the
782-
DELETION stays byte-identical to the pre-#192 clear (``delete_cookie(COOKIE_NAME, path="/")``); the
783-
``__Host-`` deletion additionally carries Secure so the browser accepts the expiry (a ``__Host-``
784-
cookie is only writable — expiry included — over a Secure connection).
781+
Deletes whichever name this scheme uses (:func:`session_cookie_name`), carrying **the same guards
782+
:func:`set_session_cookie` wrote**: Secure keyed on :func:`effective_https`, plus HttpOnly and
783+
SameSite=Strict. A ``__Host-`` cookie is only writable — expiry included — over a Secure
784+
connection, so keying the deletion the same way is also what makes the expiry land.
785+
786+
**Why the deletion reads ``effective_https`` and not the resolved name (BACKLOG #1117).** The two
787+
are different conjuncts: the set keys Secure on ``effective_https`` alone and the NAME on
788+
``effective_https AND browser_hardening_enabled()``. Inferring Secure from the name therefore lost
789+
it whenever the two disagreed — with the org opt-out set over https, this took the unprefixed
790+
branch into Starlette's ``delete_cookie`` defaults (``secure=False, httponly=False,
791+
samesite="lax"``) and revoked a Secure + HttpOnly + Strict cookie with a bare one. One expression,
792+
no branch, so the two sites cannot drift apart again by an edit to either.
785793
786794
The header is set HERE rather than beside each call site because deleting the session cookie IS
787795
the browser-visible end of a session: fusing them makes the 14.3.1 control structurally
@@ -792,11 +800,13 @@ def clear_session_cookie(response: Response, request: Request) -> None:
792800
change) are terminations; a future caller that deletes the cookie without ending the session would
793801
be the anomaly and must justify itself, not the other way round.
794802
"""
795-
name = session_cookie_name(request)
796-
if name == COOKIE_NAME:
797-
response.delete_cookie(COOKIE_NAME, path="/")
798-
else:
799-
response.delete_cookie(name, path="/", secure=True, httponly=True, samesite="strict")
803+
response.delete_cookie(
804+
session_cookie_name(request),
805+
path="/",
806+
secure=effective_https(request.app.state, request.url.scheme),
807+
httponly=True,
808+
samesite="strict",
809+
)
800810
response.headers[CLEAR_SITE_DATA_HEADER] = CLEAR_SITE_DATA_VALUE
801811

802812

@@ -854,12 +864,20 @@ def set_oidc_flow_cookie(
854864
def clear_oidc_flow_cookie(response: Response, request: Request) -> None:
855865
"""Delete the flow cookie. Called on EVERY terminal callback response, success or failure: the
856866
server-side flow is single-use, so a surviving cookie would make the next callback present a flow
857-
id that no longer resolves — a confusing ``flow_binding_missing`` on an otherwise clean retry."""
858-
name = oidc_flow_cookie_name(request)
859-
if name == FLOW_COOKIE_NAME:
860-
response.delete_cookie(FLOW_COOKIE_NAME, path="/")
861-
else:
862-
response.delete_cookie(name, path="/", secure=True, httponly=True, samesite="lax")
867+
id that no longer resolves — a confusing ``flow_binding_missing`` on an otherwise clean retry.
868+
869+
Carries the same guards :func:`set_oidc_flow_cookie` wrote, keyed on the same
870+
:func:`effective_https` call — see :func:`clear_session_cookie` for why the resolved NAME is the
871+
wrong thing to infer Secure from (BACKLOG #1117). ``SameSite=Lax`` mirrors the set for the same
872+
reason the set has it: the IdP's redirect back is a top-level cross-site GET.
873+
"""
874+
response.delete_cookie(
875+
oidc_flow_cookie_name(request),
876+
path="/",
877+
secure=effective_https(request.app.state, request.url.scheme),
878+
httponly=True,
879+
samesite="lax",
880+
)
863881

864882

865883
# --- L5a: WebAuthn RP identity (ADR 0068 §7) --------------------------------------

packaging/messagefoundry-webconsole/tests/test_ui_hardening.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,25 @@
88
from __future__ import annotations
99

1010
import re
11+
from types import SimpleNamespace
1112

1213
import httpx
1314
import pytest
15+
from starlette.requests import Request as StarletteRequest
16+
from starlette.responses import Response as StarletteResponse
1417

1518
from messagefoundry.api import create_app
1619
from messagefoundry.auth import Role
1720
from messagefoundry.auth.service import AuthService
1821
from messagefoundry.config.settings import AuthSettings
1922
from messagefoundry.pipeline import Engine
23+
from messagefoundry_webconsole._auth import (
24+
BROWSER_HARDENING_OPT_OUT_ENV,
25+
clear_oidc_flow_cookie,
26+
clear_session_cookie,
27+
set_oidc_flow_cookie,
28+
set_session_cookie,
29+
)
2030

2131
PW = "a-strong-test-passphrase" # >=15, no app/vendor terms — satisfies the ASVS policy (WP-3)
2232
_NONCE_RE = re.compile(r"script-src 'nonce-([A-Za-z0-9_-]+)' 'strict-dynamic'")
@@ -335,3 +345,146 @@ async def test_opt_out_reverts_to_legacy_over_https(
335345
assert "window.isSecureContext" not in page.text # opt-out reverts -> banner not emitted
336346
# the reverted plain cookie still authenticates (name resolver agrees on read)
337347
assert (await c.get("/ui")).status_code == 200
348+
349+
350+
# --- BACKLOG #1117: the CLEAR site consults the same conjuncts as the SET site --------------------
351+
#
352+
# The set site keys Secure on ``effective_https`` ALONE and the name on ``effective_https AND
353+
# browser_hardening_enabled()``. The clear site used to infer Secure from the resolved NAME, so under
354+
# the org opt-out over https it took the unprefixed branch and reached Starlette's ``delete_cookie``
355+
# defaults (``secure=False, httponly=False, samesite="lax"``) -- dropping three attributes the set
356+
# had just written. These pin the two sites to one expression.
357+
358+
359+
def _cookie_request(scheme: str, *, exposure_protected: bool = False) -> StarletteRequest:
360+
"""A bare Request carrying only what the cookie helpers read: the scheme and ``app.state``."""
361+
return StarletteRequest(
362+
{
363+
"type": "http",
364+
"http_version": "1.1",
365+
"method": "POST",
366+
"scheme": scheme,
367+
"path": "/ui/logout",
368+
"raw_path": b"/ui/logout",
369+
"query_string": b"",
370+
"root_path": "",
371+
"headers": [(b"host", b"t")],
372+
"client": ("127.0.0.1", 1234),
373+
"server": ("t", 443 if scheme == "https" else 80),
374+
"app": SimpleNamespace(
375+
state=SimpleNamespace(exposure_protected=exposure_protected, loopback=False)
376+
),
377+
}
378+
)
379+
380+
381+
#: The attributes a browser reads as security posture. Deliberately NOT the whole attribute set: a
382+
#: deletion also carries ``max-age``/``expires``, which the set legitimately does not.
383+
_GUARD_ATTRS = ("secure", "httponly", "samesite")
384+
385+
386+
def _guards(response: StarletteResponse) -> tuple[str, dict[str, str]]:
387+
"""The cookie NAME plus its security attributes, from the one ``Set-Cookie`` on the response."""
388+
lines = [v.decode() for k, v in response.raw_headers if k == b"set-cookie"]
389+
assert len(lines) == 1, lines
390+
head, _, rest = lines[0].partition(";")
391+
attrs: dict[str, str] = {}
392+
for part in rest.split(";"):
393+
if not part.strip():
394+
continue
395+
key, _, value = part.strip().partition("=")
396+
if key.strip().lower() in _GUARD_ATTRS:
397+
attrs[key.strip().lower()] = value.strip().lower()
398+
return head.split("=", 1)[0], attrs
399+
400+
401+
_POSTURES = [
402+
pytest.param("https", True, id="https-hardened"),
403+
pytest.param("https", False, id="https-org-opt-out"),
404+
pytest.param("http", True, id="cleartext-hardened"),
405+
pytest.param("http", False, id="cleartext-org-opt-out"),
406+
]
407+
408+
409+
@pytest.mark.parametrize(("scheme", "hardening"), _POSTURES)
410+
def test_session_clear_carries_the_same_guards_as_the_set(
411+
scheme: str, hardening: bool, monkeypatch: pytest.MonkeyPatch
412+
) -> None:
413+
"""On EVERY posture the session deletion carries the same name, Secure, HttpOnly and SameSite as
414+
the emission it revokes. A deletion that drops Secure is a cookie written without Secure, which is
415+
what ASVS 3.3.1 grades -- and one that drops HttpOnly hands script a name the set had hidden."""
416+
if hardening:
417+
monkeypatch.delenv(BROWSER_HARDENING_OPT_OUT_ENV, raising=False)
418+
else:
419+
monkeypatch.setenv(BROWSER_HARDENING_OPT_OUT_ENV, "1")
420+
request = _cookie_request(scheme)
421+
422+
written = StarletteResponse()
423+
set_session_cookie(written, "a-token", request=request)
424+
cleared = StarletteResponse()
425+
clear_session_cookie(cleared, request)
426+
427+
set_name, set_attrs = _guards(written)
428+
clear_name, clear_attrs = _guards(cleared)
429+
# the positive control: the instrument DOES see Secure move with the scheme, so an all-passing
430+
# comparison below cannot be two empty dicts agreeing with each other.
431+
assert ("secure" in set_attrs) is (scheme == "https"), set_attrs
432+
assert clear_name == set_name
433+
assert clear_attrs == set_attrs
434+
435+
436+
@pytest.mark.parametrize(("scheme", "hardening"), _POSTURES)
437+
def test_oidc_flow_clear_carries_the_same_guards_as_the_set(
438+
scheme: str, hardening: bool, monkeypatch: pytest.MonkeyPatch
439+
) -> None:
440+
"""The same symmetry for the ADR 0142 flow cookie, whose clear runs on EVERY terminal callback --
441+
so it is the more frequently emitted of the two deletions, not the rarer one."""
442+
if hardening:
443+
monkeypatch.delenv(BROWSER_HARDENING_OPT_OUT_ENV, raising=False)
444+
else:
445+
monkeypatch.setenv(BROWSER_HARDENING_OPT_OUT_ENV, "1")
446+
request = _cookie_request(scheme)
447+
448+
written = StarletteResponse()
449+
set_oidc_flow_cookie(written, "a-flow-id", request=request, max_age=300)
450+
cleared = StarletteResponse()
451+
clear_oidc_flow_cookie(cleared, request)
452+
453+
set_name, set_attrs = _guards(written)
454+
clear_name, clear_attrs = _guards(cleared)
455+
assert ("secure" in set_attrs) is (scheme == "https"), set_attrs
456+
assert clear_name == set_name
457+
assert clear_attrs == set_attrs
458+
459+
460+
def test_session_clear_follows_exposure_protected_not_only_the_wire_scheme() -> None:
461+
"""The ``tls_terminated_upstream`` topology ADR 0172 deliberately excludes: the engine speaks
462+
plaintext to a declared proxy, so the wire scheme is http while the BROWSER's origin is https and
463+
``exposure_protected`` is true. The set writes Secure there; so must the clear."""
464+
request = _cookie_request("http", exposure_protected=True)
465+
written = StarletteResponse()
466+
set_session_cookie(written, "a-token", request=request)
467+
cleared = StarletteResponse()
468+
clear_session_cookie(cleared, request)
469+
assert "secure" in _guards(written)[1] # control: the set really does key on the declaration
470+
assert _guards(cleared) == _guards(written)
471+
472+
473+
async def test_opt_out_logout_over_https_still_deletes_with_secure(
474+
engine: Engine, monkeypatch: pytest.MonkeyPatch
475+
) -> None:
476+
"""End to end through the real logout route, on the posture that used to lose three attributes:
477+
the org opt-out over https."""
478+
monkeypatch.setenv(BROWSER_HARDENING_OPT_OUT_ENV, "1")
479+
service = await _service(engine)
480+
await _add(service, "op", Role.OPERATOR)
481+
async with _client(engine, service, scheme="https") as c:
482+
await _login(c, "op")
483+
out = await c.post("/ui/logout")
484+
assert out.status_code == 303
485+
set_cookie = out.headers["set-cookie"]
486+
low = set_cookie.lower()
487+
assert set_cookie.split("=", 1)[0] == "mf_session" # opt-out keeps the bare name
488+
assert "secure" in low and "httponly" in low and "samesite=strict" in low
489+
# and the deletion really ended the session
490+
assert (await c.get("/ui")).status_code in (302, 303, 401)

0 commit comments

Comments
 (0)