Session Fixation in Axum with Firestore
Session Fixation in Axum with Firestore — how this specific combination creates or exposes the vulnerability
Session fixation occurs when an application assigns a user a session identifier before authentication and fails to rotate that identifier after login. In an Axum application that uses Google Firestore as the session store, the risk arises from a mismatch between how session identifiers are created, stored, and validated. Firestore documents representing sessions often contain the session ID, user ID, expiry, and potentially sensitive metadata. If Axum issues a session cookie with a predictable or user-supplied value and stores it directly in Firestore without regeneration post-authentication, an attacker can force a known session ID onto a victim and later hijack the authenticated session.
Consider an Axum handler that creates a session document in Firestore before login:
use axum::{routing::get, Router, extract::State, response::IntoResponse};
use google_cloud_rust::firestore::client::Client;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
struct Session {
id: String,
user_id: Option,
expires_at: chrono::DateTime<chrono::Utc>,
}
async fn create_session(client: &Client, session_id: String) -> Result<(), Box<dyn std::error::Error>> {
let session = Session {
id: session_id,
user_id: None,
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
};
client.create(&session).await?;
Ok(())
}
async fn login_handler(
State(client): State<Client>,
axum::extract::Form(params): axum::extract::Form<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let candidate = params.get("session_id").cloned().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
create_session(&client, candidate).await.unwrap();
// Authentication check omitted for illustration
"login form processed"
}
In this pattern, if candidate is taken directly from user-controlled input (e.g., a form field or header), an attacker can set the session ID before authentication. The Firestore document then stores that attacker-chosen ID. After the victim logs in, the attacker can use the known ID to access the authenticated session, provided the session validation logic only checks Firestore for the existence of a valid, non-expired session with that ID. This is a classic session fixation condition.
The combination of Axum’s flexibility in reading cookies and Firestore’s document-oriented model amplifies the issue if session state is not treated as immutable until post-authentication regeneration. Axum does not enforce any session lifecycle semantics; it is the developer’s responsibility to ensure that session identifiers are freshly generated after successful authentication and that old identifiers are invalidated in Firestore. Without explicit rotation, the initial (attacker-supplied) ID remains valid.
Additionally, if Firestore security rules or IAM permissions are misconfigured, an attacker may be able to enumerate or tamper with session documents, further facilitating fixation or session hijacking. For example, if session documents are readable by any authenticated user but not properly scoped, an attacker could list sessions and confirm whether a chosen ID has been claimed after login.
Real-world analogs include misconfigured session management in other frameworks; for instance, frameworks that do not rotate session IDs after privilege changes are flagged by OWASP Session Management violations. In Axum with Firestore, the remediation is to treat session creation as an authentication-time operation, not a pre-authentication convenience.
Firestore-Specific Remediation in Axum — concrete code fixes
To mitigate session fixation in Axum with Firestore, ensure that session identifiers are regenerated immediately after successful authentication and that the old identifier is removed or invalidated in Firestore. Below are concrete, idiomatic code examples demonstrating a secure approach.
1. Use a cryptographically secure random generator for new session IDs and always rotate after login.
use axum::{routing::post, Extension, Router};
use google_cloud_rust::firestore::client::Client;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize, Clone)]
struct Session {
id: String,
user_id: String,
expires_at: chrono::DateTime<chrono::Utc>,
}
async fn store_session(client: &Client, session: &Session) -> Result<(), Box<dyn std::error::Error>> {
client.create(session).await?;
Ok(())
}
async fn delete_session(client: &Client, session_id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_by_id(session_id).await?;
Ok(())
}
async fn login(
Extension(client): Extension<Client>,
axum::extract::Form(form): axum::extract::Form<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let username = form.get("username").ok_or_else(|| ())?;
let password = form.get("password").ok_or_else(|| ())?;
// Validate credentials against your user store (omitted)
let user_id = validate_user(username, password).await?;
// Invalidate any pre-existing session for this user (if applicable)
// ...
// Generate a fresh session ID after authentication
let new_session_id = Uuid::new_v4().to_string();
let session = Session {
id: new_session_id.clone(),
user_id: user_id.clone(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
};
// Store the new session in Firestore
store_session(&client, &session).await?;
// Set a HttpOnly, Secure cookie with the new session ID
// axum::response::Response::into_response() would include Set-Cookie header
format!("Logged in, session: {}", new_session_id)
}
2. Ensure that session validation only accepts IDs that map to authenticated user records and are not pre-authentication fixtures. When reading from Firestore, verify that the session’s user_id matches the authenticated principal and that the session has not been revoked.
async fn get_session(
client: &Client,
session_id: &str,
) -> Result<Option<Session>, Box<dyn std::error::Error>> {
let session: Option<Session> = client.read_by_id(session_id).await?;
Ok(session)
}
async fn require_session(
client: Client,
session_id: String,
) -> Result<String, (axum::http::StatusCode, String)> { // returns user_id
match get_session(&client, &session_id).await {
Ok(Some(session)) if session.user_id.is_empty() => Err((StatusCode::UNAUTHORIZED, "invalid".into())),
Ok(Some(session)) => Ok(session.user_id),
_ => Err((StatusCode::UNAUTHORIZED, "missing".into())),
}
}
3. Rotate the session ID on privilege changes (e.g., after password change or role escalation) by creating a new Firestore document and deleting the old one.
async fn rotate_session(client: &Client, old_id: &str, user_id: &str) -> Result<String, Box<dyn std::error::Error>> {
delete_session(client, old_id).await?;
let new_id = Uuid::new_v4().to_string();
let session = Session {
id: new_id.clone(),
user_id: user_id.to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
};
store_session(client, &session).await?;
Ok(new_id)
}
These patterns ensure that session identifiers are not attacker-controllable and are tightly bound to authenticated user state in Firestore. By coupling session lifecycle management with Firestore document operations, you reduce the window for fixation and simplify auditing.