steid

@jamesgill /

fix: session cookie survives plain-HTTP localhost in dev

The claim and login flows worked but every page rendered signed out. The
database showed three unexpired session rows -- the server was issuing
sessions correctly and the browser was discarding each one.

Cause: Topcoat's CookieTokenStore is hardened with __Host- and Secure, and
Secure requires a trustworthy origin. Browsers disagree about whether
plain-HTTP localhost qualifies, and where it doesn't the cookie is dropped
with no error on either side -- the failure is completely silent.

CookieTokenStore hardcodes override_secure(true) and override_prefix_host()
and exposes only the cookie name, so relaxing it means our own TokenStore.
InsecureCookieTokenStore is the same cookie without Secure and without the
prefix; HttpOnly and SameSite=Lax are kept, since they cost nothing over
HTTP and still block script access and cross-site POSTs.

Off by default. STEID_INSECURE_COOKIES=true opts in, and boot prints a
warning when it's on. The cookie is deliberately named steid-dev-session so
one issued in dev can never be mistaken for one from the hardened store.

Verified with the flag on: claim signs in, logout clears, login signs back
in, and Set-Cookie carries no Secure attribute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 1 month agoparent14223e7Browse files264c436eb4df73672be95e46afa15719cd143ed7

4 files changed+124 −2

src/application/config.rs+8 −0View file
@@ -18,6 +18,14 @@ pub struct AppConfig {
1818 #[allow(dead_code)]
1919 #[serde(default = "default_data_dir")]
2020 pub data_dir: PathBuf,
21+
22+ /// Drops `Secure` and the `__Host-` prefix from the session cookie so it survives
23+ /// plain-HTTP `localhost`.
24+ ///
25+ /// Development only. With this on, the session cookie travels unencrypted and
26+ /// anyone on the path can lift it.
27+ #[serde(default)]
28+ pub insecure_cookies: bool,
2129 }
2230
2331 fn default_database_url() -> String {
src/infrastructure/web/mod.rs+1 −0View file
@@ -3,4 +3,5 @@
33 pub mod context;
44 pub mod layout;
55 pub mod pages;
6+pub mod session_cookie;
67 pub mod setup;
src/infrastructure/web/session_cookie.rs+89 −0View file
@@ -0,0 +1,89 @@
1+//! A relaxed session cookie for local HTTP development.
2+//!
3+//! Topcoat's [`CookieTokenStore`](topcoat::session::CookieTokenStore) is hardened:
4+//! `__Host-` prefixed, `Secure`, `HttpOnly`, `SameSite=Lax`, scoped to `/`. `Secure`
5+//! means the browser only stores it over a trustworthy origin — and browsers disagree
6+//! about whether plain-HTTP `localhost` counts. Where it doesn't, the server issues a
7+//! session, the browser silently drops it, and every page renders signed out with no
8+//! error anywhere.
9+//!
10+//! This store is the same cookie without `Secure` and without the `__Host-` prefix, so
11+//! it survives `http://localhost`. It is strictly worse and exists only for dev.
12+
13+use std::{borrow::Cow, time::Duration};
14+
15+use topcoat::{
16+ context::Cx,
17+ cookie::{Cookie, Cookies, SameSite},
18+ session::{TokenStore, TokenStoreFuture},
19+};
20+
21+/// Name of the relaxed cookie. Deliberately not `session`, so a cookie issued in dev
22+/// can never be mistaken for one issued by the hardened store.
23+pub const INSECURE_SESSION_COOKIE_NAME: &str = "steid-dev-session";
24+
25+/// Carries the session token in a cookie that works over plain HTTP.
26+///
27+/// **Never enable this on anything reachable from a network.** Without `Secure` the
28+/// cookie is sent over unencrypted connections, where anyone on the path can read it
29+/// and use it to impersonate the session.
30+pub struct InsecureCookieTokenStore {
31+ name: Cow<'static, str>,
32+}
33+
34+impl InsecureCookieTokenStore {
35+ pub fn new() -> Self {
36+ Self::default()
37+ }
38+}
39+
40+impl Default for InsecureCookieTokenStore {
41+ fn default() -> Self {
42+ Self {
43+ name: Cow::Borrowed(INSECURE_SESSION_COOKIE_NAME),
44+ }
45+ }
46+}
47+
48+/// Everything the hardened store does except `secure` and the `__Host-` prefix.
49+/// `HttpOnly` and `SameSite=Lax` are kept — they cost nothing over HTTP and still
50+/// block script access and cross-site POSTs.
51+fn cookies(cx: &Cx) -> impl Cookies {
52+ topcoat::cookie::cookies(cx)
53+ .override_same_site(SameSite::Lax)
54+ .override_http_only(true)
55+ .override_path("/")
56+}
57+
58+impl TokenStore for InsecureCookieTokenStore {
59+ fn read<'a>(&'a self, cx: &'a Cx) -> TokenStoreFuture<'a, Option<topcoat::session::Token>> {
60+ Box::pin(async move {
61+ let Some(cookie) = cookies(cx).get(&self.name) else {
62+ return Ok(None);
63+ };
64+ Ok(topcoat::session::Token::decode(cookie.value_trimmed()).ok())
65+ })
66+ }
67+
68+ fn write<'a>(
69+ &'a self,
70+ cx: &'a Cx,
71+ token: topcoat::session::Token,
72+ max_age: Duration,
73+ ) -> TokenStoreFuture<'a, ()> {
74+ Box::pin(async move {
75+ let max_age = topcoat::cookie::time::Duration::try_from(max_age)?;
76+ cookies(cx)
77+ .override_max_age(max_age)
78+ .add(Cookie::new(self.name.clone(), token.encode()));
79+ Ok(())
80+ })
81+ }
82+
83+ fn delete<'a>(&'a self, cx: &'a Cx) -> TokenStoreFuture<'a, ()> {
84+ Box::pin(async move {
85+ cookies(cx).remove(self.name.clone());
86+ Ok(())
87+ })
88+ }
89+}
src/main.rs+26 −2View file
@@ -1,7 +1,11 @@
11 use steid::{
22 application::{AppConfig, is_claimed},
33 domain::SetupToken,
4 infrastructure::{self, repository::SqliteUserRepo, web::context::SetupState},
4+ infrastructure::{
5+ self,
6+ repository::SqliteUserRepo,
7+ web::{context::SetupState, session_cookie::InsecureCookieTokenStore},
8+ },
59 };
610 use topcoat::{
711 cookie::RouterBuilderCookieExt,
@@ -18,7 +22,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
1822
1923 let mut builder = Router::builder()
2024 .cookies()
21 .sessions(SessionConfig::default())
25+ .sessions(session_config(config.insecure_cookies))
2226 .discover()
2327 .app_context(config)
2428 .app_context(pool.clone());
@@ -36,6 +40,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
3640 Ok(())
3741 }
3842
43+/// Builds the session configuration.
44+///
45+/// The hardened default requires a trustworthy origin for its `Secure` cookie;
46+/// browsers disagree about whether plain-HTTP localhost qualifies, and where it
47+/// doesn't the cookie is dropped silently and every page renders signed out.
48+fn session_config(insecure_cookies: bool) -> SessionConfig {
49+ if insecure_cookies {
50+ eprintln!();
51+ eprintln!(" !! STEID_INSECURE_COOKIES is on: the session cookie has no Secure");
52+ eprintln!(" !! flag and travels unencrypted. Local development only.");
53+ eprintln!();
54+
55+ SessionConfig::builder()
56+ .token_store(InsecureCookieTokenStore::new())
57+ .build()
58+ } else {
59+ SessionConfig::default()
60+ }
61+}
62+
3963 /// Prints the claim instructions. The only time the token is ever revealed.
4064 fn announce_setup(token: &SetupToken) {
4165 println!();