Make the Secure cookie attribute configurable for TLS-less dev stands

Session cookies were hardcoded Secure, which is correct for prod but
silently breaks every non-localhost HTTP dev deployment: browsers (and
any spec-compliant HTTP client, verified with curl against a container IP
and against dev.netrunner-vpn.com directly) refuse to store a Secure
cookie received over plain HTTP unless the host is localhost. The dev VPS
serves everything over plain HTTP with no reverse-proxy/TLS by design, so
every login there was silently failing to persist a session at all —
this is a bigger, more fundamental gap than the cookie-sharing-with-the-
landing question that led to finding it.

Added COOKIE_SECURE (defaults true) alongside the existing COOKIE_DOMAIN,
logs a loud warning when disabled, and turned it off for the dev-remote
and local docker-dev env templates. Verified end-to-end against a
non-localhost address that the cookie now actually gets stored and a
follow-up authenticated request succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 19:20:14 +07:00
parent a7607e4b33
commit acfcdbe90e
6 changed files with 62 additions and 8 deletions
+26 -8
View File
@@ -53,20 +53,37 @@ fn cookie_domain_attr(state: &ApiState) -> String {
}
}
/// `; Secure` почти всегда обязателен — но на dev-стендах без TLS браузер
/// молча выбрасывает Secure-cookie, полученную по голому HTTP (кроме
/// `localhost`), и сессия не переживает ни одного запроса. См. `ApiState::cookie_secure`.
fn cookie_secure_attr(state: &ApiState) -> &'static str {
if state.cookie_secure {
"; Secure"
} else {
""
}
}
/// Ставит обе сессионные cookie: короткоживущий access-JWT на весь `/`, и
/// непрозрачный refresh-токен, ограниченный путём `/api/v1/auth/refresh`
/// (чтобы он не улетал на каждый обычный запрос — там он не нужен, а
/// поверхность атаки только растёт).
fn set_session_cookies(headers: &mut HeaderMap, state: &ApiState, session: &Session) {
let domain = cookie_domain_attr(state);
let secure = cookie_secure_attr(state);
let access_cookie = format!(
"{}={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age={}{}",
ACCESS_COOKIE, session.access_token, ACCESS_MAX_AGE_SEC, domain
"{}={}; HttpOnly{}; SameSite=Lax; Path=/; Max-Age={}{}",
ACCESS_COOKIE, session.access_token, secure, ACCESS_MAX_AGE_SEC, domain
);
let refresh_cookie = format!(
"{}={}; HttpOnly; Secure; SameSite=Lax; Path={}; Max-Age={}{}",
REFRESH_COOKIE, session.refresh_token, REFRESH_COOKIE_PATH, REFRESH_MAX_AGE_SEC, domain
"{}={}; HttpOnly{}; SameSite=Lax; Path={}; Max-Age={}{}",
REFRESH_COOKIE,
session.refresh_token,
secure,
REFRESH_COOKIE_PATH,
REFRESH_MAX_AGE_SEC,
domain
);
headers.append(SET_COOKIE, access_cookie.parse().unwrap());
@@ -76,13 +93,14 @@ fn set_session_cookies(headers: &mut HeaderMap, state: &ApiState, session: &Sess
/// Немедленно затирает обе cookie (logout / отозванная сессия).
fn clear_session_cookies(headers: &mut HeaderMap, state: &ApiState) {
let domain = cookie_domain_attr(state);
let secure = cookie_secure_attr(state);
let clear_access = format!(
"{}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0{}",
ACCESS_COOKIE, domain
"{}=; HttpOnly{}; SameSite=Lax; Path=/; Max-Age=0{}",
ACCESS_COOKIE, secure, domain
);
let clear_refresh = format!(
"{}=; HttpOnly; Secure; SameSite=Lax; Path={}; Max-Age=0{}",
REFRESH_COOKIE, REFRESH_COOKIE_PATH, domain
"{}=; HttpOnly{}; SameSite=Lax; Path={}; Max-Age=0{}",
REFRESH_COOKIE, secure, REFRESH_COOKIE_PATH, domain
);
headers.append(SET_COOKIE, clear_access.parse().unwrap());
headers.append(SET_COOKIE, clear_refresh.parse().unwrap());