Skip to content

Console: serve the static shell past the remote lifetime to its own session (#1006) - #1008

Merged
eaitbrahim merged 2 commits into
mainfrom
fix/1006-shell-past-lifetime
Oct 14, 2026
Merged

eaitbrahim merged 2 commits into
mainfrom
fix/1006-shell-past-lifetime

Conversation

@eaitbrahim

@eaitbrahim eaitbrahim commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1006

What changed

On keel serve --external-host, past the 12 h session lifetime (#648), a GET/HEAD of the client shell carrying this session's cookie is now served instead of refused, so a browser with no cached console loads the page and reaches the expired-Pulse HALT that #985 added.

  • KeelHandler._is_a_sessioned_shell_read (new), consulted inside _admitted. do_GET is untouched (CodeQL alerts point at it).
  • "Shell" = membership of the decoded path in staticfiles.shell_request_paths(STATIC_ROOT), a list built from the package side: each shipped file with a known Content-Type, /, and every CLIENT_ROUTES / RETIRED_CLIENT_ROUTES name. The request path never reaches a filesystem call (that keeps CodeQL from seeing a second path-injection flow), and non-canonical spellings (/js/../index.html, /pulse/) are not exempted, so they're refused as expired. Anything under /api/ (events included) and /switch/ is excluded by prefix first, so a file planted under the static root cannot reopen one.
  • No change to /api/*: every read and write keeps the lifetime except Remote session lifetime (#648) refuses HALT too: a keel serve --external-host console can't halt after 12h until restarted #983 (tokened halt POST) and Console: keep the HALT control on Pulse after the remote session lifetime expires (client half of #983) #985 (cookie-matched expired /api/pulse carries {halt_token, resume_command}). The gates token stays served nowhere (Console: the HALT token served on /api/pulse also releases resume routes #968). The shell is the package's files byte for byte, with no template, so there's no data and no token in it.
  • Docstrings (server, security.REMOTE_SESSION_MAX_AGE_SECONDS, gates.HALT, staticfiles.resolve_client_route), docs/remote-access.md (now "three exceptions, all for the stop"), and the prose test that used / to demonstrate the lifetime (now /api/config).

Ruling (the issue's held question)

Ruling: the shell is served past the lifetime to this session's cookie only, not to any browser. Why: before the lifetime a cookieless request is refused the shell (test_static_assets_are_behind_the_same_admission_as_every_other_page: "not exempted ... just because it holds no secrets today"). Serving it to anyone after expiry would make an expired server more open to strangers than a live one. A cookieless browser gains nothing anyway, because the Pulse refusal draws HALT only for the cookie. A first visit through the printed ?token= address gets the cookie from the exchange, which runs before admission, so it's covered. The issue's premise that "a loopback server already serves it freely" doesn't hold: loopback gates the shell on the cookie too. Cost if wrong: a cookieless browser past the lifetime keeps getting the plain-text "Session expired" (which names keel kill) instead of a page that couldn't halt either.

Tests (TDD: written first, failed with 403 "Session expired")

  • Every shell path (each shipped file plus mount plus all client routes, enumerated from the package, ≥20) answers 200 to its own cookie past the lifetime and contains none of the session, halt, gates or CSRF tokens.
  • No cookie or wrong cookie past the lifetime: every shell path gets 403 "Session expired", with no tokens.
  • /?token= past the lifetime → 303 + cookie → shell 200 → /api/pulse 403 carrying the halt token.
  • /api/events, /api/config, /api/pulse/, /switch/8766 and unmapped paths stay "Session expired".
  • A planted static root with api/pulse.json, api/events.js, switch/8766.html and keel.bin reopens none of them, and a POST to a shell path keeps the lifetime.
  • Before the lifetime nothing changes (cookie 200, none 403 "Not authorised").
  • HEAD of a shell path is pinned too (round 1).
  • Mutation-checked: removing the exemption, the cookie check, the prefix exclusion (API, switch and both), the content-type check, the verb check or the client-route arm each fails a test, and so does appending a halt or gates token to served shell bytes.

Verified (final head)

  • Full pytest green (8478 passed, 3 skipped), ruff and format clean, and bare mypy clean. No JS changed; node --check is clean on all static JS anyway.
  • Real browser (fresh Chromium profile, 0 service workers, temp DB, port 18906, remote session already expired): /?token= loads the page, Pulse shows "Session expired" with HALT, the HALT click engages, and a mode=ro read of the temp DB gives kill_switch|true.

🤖 Generated with Claude Code

…ession (#1006)

On `keel serve --external-host`, a browser with no cached console was refused
the page itself after the 12 h lifetime (#648), so it never reached the
expired-Pulse HALT that #985 serves. A GET/HEAD of a shell path -- a shipped
file under the static root or a client route, by the same resolution
`_serve_static` performs -- carrying THIS session's cookie now skips the
lifetime (`KeelHandler._is_a_sessioned_shell_read`, called from `_admitted`;
`do_GET` is untouched).

The shell is the package's files unaltered: no data, no token. Every `/api/`
read (events included), `/switch/`, unmapped paths and every write keep the
lifetime; a request without the cookie is refused the shell as before, so an
expired session is never more open to a stranger than a live one. The token
exchange already ran before admission, so a first visit via the printed
address gets the cookie and then the page.

Docstrings, docs/remote-access.md and the prose test that used `/` to show
the lifetime are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eaitbrahim eaitbrahim self-assigned this Oct 14, 2026

@eaitbrahim eaitbrahim left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-1 review. One test gap (low). The exemption itself holds: I probed //api/pulse, /api%2fpulse, /%61pi/pulse, /%2e%2e/..., ..%2f, //switch/8766, /api, /switch, trailing slashes, query strings, ?token=x and README.md past the lifetime with GET and HEAD, with and without the cookie. Nothing outside the shell is admitted, and every cookieless answer is the plain Session expired.

Comment thread keel/web/server.py
…quest (#1006)

CodeQL counted a second user-controlled flow into resolve_static_asset.
_is_a_sessioned_shell_read now compares the unquoted request path against
staticfiles.shell_request_paths, enumerated from the package side, so the
request path never reaches a filesystem call. Adds the HEAD pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit f37bd19 into main Oct 14, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/1006-shell-past-lifetime branch October 14, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console: serve the static shell past the remote session lifetime so a first visit still gets HALT

1 participant