aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/login.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 14:21:25 +0000
committertv <tv@krebsco.de>2026-11-08 15:33:48 +0100
commit3fed04ee13ff27e440bf0db5c9fa1efed3b2a7a4 (patch)
treef31c7cf85f07d17ba1de81c5c42f6e094f8b36b0 /src/api/login.rs
parentd1754ca2d9519eaa78f978594cb81a2562970bca (diff)
login: the session cookie Secure behind a TLS proxy
Review finding 12, browsers: the session cookie had no Secure flag. Behind a TLS proxy, the token it carries went over plain HTTP if someone typed http://. POST /auth/session now sets Secure when the request came over TLS, as the proxy says (X-Forwarded-Proto, or Forwarded's proto, of the first hop). A client saying so itself only keeps its own cookie off plain HTTP. operations.md's nginx example sets the header.
Diffstat (limited to 'src/api/login.rs')
-rw-r--r--src/api/login.rs55
1 files changed, 54 insertions, 1 deletions
diff --git a/src/api/login.rs b/src/api/login.rs
index 1faadf7..73304e8 100644
--- a/src/api/login.rs
+++ b/src/api/login.rs
@@ -365,6 +365,7 @@ pub(crate) async fn refresh(
pub(crate) async fn session(
State(state): State<AppState>,
token: Option<Extension<Token>>,
+ headers: axum::http::HeaderMap,
body: Option<Json<LimitRequest>>,
) -> Result<Response, ApiError> {
let claims = refreshed(&state, token, body).await?;
@@ -374,8 +375,9 @@ pub(crate) async fn session(
.as_ref()
.ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?;
let max_age = claims.exp.saturating_sub(claims.iat) / 1000;
+ let secure = if over_tls(&headers) { "; Secure" } else { "" };
let cookie = format!(
- "{SESSION_COOKIE}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}",
+ "{SESSION_COOKIE}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{secure}",
token::issue(key, &claims)
);
if let Ok(value) = axum::http::HeaderValue::from_str(&cookie) {
@@ -386,6 +388,37 @@ pub(crate) async fn session(
Ok(response)
}
+/// Whether the request came over TLS, as a reverse proxy in front says
+/// (`X-Forwarded-Proto`, `Forwarded`): its session cookie is then sent over TLS only
+/// (`Secure`), not over plain HTTP should someone type `http://`. A client saying so
+/// itself only keeps its own cookie off plain HTTP.
+fn over_tls(headers: &axum::http::HeaderMap) -> bool {
+ let said = |name: &str| {
+ headers
+ .get_all(name)
+ .iter()
+ .filter_map(|value| value.to_str().ok())
+ .collect::<Vec<_>>()
+ .join(",")
+ };
+ let forwarded_proto = said("x-forwarded-proto");
+ let forwarded = said("forwarded");
+ forwarded_proto
+ .split(',')
+ .next()
+ .is_some_and(|proto| proto.trim().eq_ignore_ascii_case("https"))
+ || forwarded
+ .split(',')
+ .next()
+ .into_iter()
+ .flat_map(|first| first.split(';'))
+ .filter_map(|pair| pair.trim().split_once('='))
+ .any(|(name, value)| {
+ name.eq_ignore_ascii_case("proto")
+ && value.trim_matches('"').eq_ignore_ascii_case("https")
+ })
+}
+
/// `DELETE /auth/session`: the session cookie forgotten (the token itself stays valid
/// until it expires; `POST /auth/logout` ends every one). A stale one is ignored anyway.
pub(crate) async fn end_session() -> Response {
@@ -550,6 +583,26 @@ mod tests {
assert!(!within(&[cap(&["notes"], &["read", "grant"])], &earlier));
}
+ /// Over TLS as the proxy says: the first hop's protocol, either header.
+ #[test]
+ fn over_tls_as_the_proxy_says() {
+ let with = |name: &'static str, value: &'static str| {
+ let mut headers = axum::http::HeaderMap::new();
+ headers.insert(name, axum::http::HeaderValue::from_static(value));
+ over_tls(&headers)
+ };
+ assert!(with("x-forwarded-proto", "https"));
+ assert!(with("x-forwarded-proto", "HTTPS, http"));
+ assert!(!with("x-forwarded-proto", "http"));
+ assert!(with(
+ "forwarded",
+ "for=1.2.3.4;proto=https;host=example.org"
+ ));
+ assert!(with("forwarded", "proto=\"https\""));
+ assert!(!with("forwarded", "proto=http, proto=https"));
+ assert!(!over_tls(&axum::http::HeaderMap::new()));
+ }
+
/// A token asked to live less ends its session with it; another, the session goes on.
#[test]
fn a_short_token_ends_its_session() {