Misc fixes regarding jobs and config (#7858)

* Fix several scheduled job issues

Several jobs could cause a panic, or stop without running through all
items because of this panic. This could cause missed items until that
panic somehow resolved it self, and probably would never work fully
again.

This could cause emergency access for example to never work again.
Besides this, emergency access was also able to be panic if the
wait-time was out-of-bounds and date/time calculation might fail.
This is also fixed now by using `checked` functions and continue upon
those issues so all awaiting items will be handled.

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix several possible panics and overflows

Adjust several integer types to be a lower or unsigned type.
Most types were too large, only because the function which used this
value needed that specific type. But, this allowed too large values
which either caused panics or might cause a DoS.

This does however might possible cause configurations which have larger
values, which are probably way too large anyway to be not loaded or use
their defaults. During the config loading, either via env or json, it
will show error logs regarding these items.

Also fixed an issue with protected actions via OTP, it increased the
attempt before it validated if the attempt limit was reached. This could
cause an issue when set to `1`, that way it was never allowed, and `3`
times would actually be `2`.

Also added several config validation checks which otherwise would have
prevent Vaultwarden to start, or login, so these will probably not cause
issues, else users would already have issues.

Signed-off-by: BlackDex <black.dex@gmail.com>

* Show config errors on diagnostics page

With the previous commits it is now possible to add a warning if their
rare issues with loading of the config, either environment variables
which we were not able to parse correctly, or if loading the
`config.json` failed instead of just showing it wasn't used, while it
does exists, which might be confusing.

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
This commit is contained in:
Mathijs van Veluw authored and GitHub committed 2026-10-22 14:20:03 +02:00
1 parent f4696877b9
commit b71cb7a832
25 files changed
+482 -209

No files matched your search

+3 -3
View File
@@ -78,7 +78,7 @@
# DB_CONNECTION_RETRIES=15 # DB_CONNECTION_RETRIES=15
## Database timeout ## Database timeout
## Timeout when acquiring database connection ## Timeout in seconds when acquiring database connection
# DATABASE_TIMEOUT=30 # DATABASE_TIMEOUT=30
## Database idle timeout ## Database idle timeout
@@ -173,7 +173,7 @@
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start. ## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *" # EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
## Number of days to retain events stored in the database. ## Number of days to retain events stored in the database.
## If unset (the default), events are kept indefinitely and the scheduled job is disabled! ## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled!
# EVENTS_DAYS_RETAIN= # EVENTS_DAYS_RETAIN=
## ##
## Cron schedule of the job that cleans old auth requests from the auth request. ## Cron schedule of the job that cleans old auth requests from the auth request.
@@ -233,7 +233,7 @@
# USER_SEND_LIMIT= # USER_SEND_LIMIT=
## Number of days to wait before auto-deleting a trashed item. ## Number of days to wait before auto-deleting a trashed item.
## If unset (the default), trashed items are not auto-deleted. ## If unset (the default) or 0, trashed items are not auto-deleted.
## This setting applies globally, so make sure to inform all users of any changes to this setting. ## This setting applies globally, so make sure to inform all users of any changes to this setting.
# TRASH_AUTO_DELETE_DAYS= # TRASH_AUTO_DELETE_DAYS=
+9 -2
View File
@@ -201,12 +201,15 @@ fn post_admin_login(
// If the token is invalid, redirect to login page // If the token is invalid, redirect to login page
if validate_token(&data.token) { if validate_token(&data.token) {
// If the token received is valid, generate JWT and save it as a cookie // If the token received is valid, generate JWT and save it as a cookie
let claims = generate_admin_claims(); let claims = match generate_admin_claims() {
Ok(claims) => claims,
Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))),
};
let jwt = encode_jwt(&claims); let jwt = encode_jwt(&claims);
let cookie = Cookie::build((COOKIE_NAME, jwt)) let cookie = Cookie::build((COOKIE_NAME, jwt))
.path(admin_path()) .path(admin_path())
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime())) .max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime())))
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.http_only(true) .http_only(true)
.secure(secure.https); .secure(secure.https);
@@ -779,6 +782,8 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
.into_keys() .into_keys()
.collect(); .collect();
let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", ");
let diagnostics_json = json!({ let diagnostics_json = json!({
"dns_resolved": dns_resolved, "dns_resolved": dns_resolved,
"current_release": VERSION, "current_release": VERSION,
@@ -801,7 +806,9 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
"db_version": get_sql_server_version(&conn).await, "db_version": get_sql_server_version(&conn).await,
"admin_url": format!("{}/diagnostics", admin_url()), "admin_url": format!("{}/diagnostics", admin_url()),
"overrides": &CONFIG.get_overrides().join(", "), "overrides": &CONFIG.get_overrides().join(", "),
"config_file_status": &CONFIG.get_config_file_status(),
"template_overrides": check_template_overrides().join(", "), "template_overrides": check_template_overrides().join(", "),
"invalid_env_vars": invalid_env_vars,
"invalid_feature_flags": invalid_feature_flags, "invalid_feature_flags": invalid_feature_flags,
"host_arch": env::consts::ARCH, "host_arch": env::consts::ARCH,
"host_os": env::consts::OS, "host_os": env::consts::OS,
+146 -74
View File
@@ -1,4 +1,4 @@
use chrono::{TimeDelta, Utc}; use chrono::Utc;
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
@@ -15,8 +15,8 @@ use crate::{
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
}, },
}, },
mail, mail,
@@ -136,19 +136,21 @@ async fn post_emergency_access(
let data: EmergencyAccessUpdateData = data.into_inner(); let data: EmergencyAccessUpdateData = data.into_inner();
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
err!("Invalid emergency access type.")
};
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let Some(mut emergency_access) = let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
else { else {
err!("Emergency access not valid.") err!("Emergency access not valid.")
}; };
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { emergency_access.atype = new_type as i32;
new_type as i32
} else {
err!("Invalid emergency access type.")
};
emergency_access.atype = new_type;
emergency_access.wait_time_days = data.wait_time_days; emergency_access.wait_time_days = data.wait_time_days;
if data.key_encrypted.is_some() { if data.key_encrypted.is_some() {
emergency_access.key_encrypted = data.key_encrypted; emergency_access.key_encrypted = data.key_encrypted;
@@ -210,11 +212,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let email = data.email.to_lowercase(); let email = data.email.to_lowercase();
let wait_time_days = data.wait_time_days; let wait_time_days = data.wait_time_days;
let emergency_access_status = EmergencyAccessStatus::Invited as i32; if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
new_type as i32
} else {
err!("Invalid emergency access type.") err!("Invalid emergency access type.")
}; };
@@ -260,6 +262,8 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
err!(format!("Grantee user already invited: {}", &grantee_user.email)) err!(format!("Grantee user already invited: {}", &grantee_user.email))
} }
let new_type = new_type as i32;
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
let mut new_emergency_access = let mut new_emergency_access =
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days); EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
new_emergency_access.save(&conn).await?; new_emergency_access.save(&conn).await?;
@@ -292,7 +296,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon
}; };
if emergency_access.status != EmergencyAccessStatus::Invited as i32 { if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
err!("The grantee user is already accepted or confirmed to the organization"); err!("The grantee user is already accepted or confirmed for emergency access");
} }
let Some(email) = emergency_access.email.clone() else { let Some(email) = emergency_access.email.clone() else {
@@ -453,11 +457,24 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
let Some(mut emergency_access) = let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
else { else {
err!("Emergency access not valid.") err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email)
)
}; };
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 { if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
err!("Emergency access not valid.") err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email)
)
}
if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) {
err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days)
)
} }
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
@@ -490,11 +507,17 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
let Some(mut emergency_access) = let Some(mut emergency_access) =
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
else { else {
err!("Emergency access not valid.") err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email)
)
}; };
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 { if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
err!("Emergency access not valid.") err!(
"Emergency access not valid.",
format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email)
)
} }
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else { let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
@@ -747,53 +770,72 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
return; return;
} }
if let Ok(conn) = pool.get().await { let Ok(conn) = pool.get().await else {
error!("Failed to get DB connection for emergency time-outs!");
return;
};
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
if emergency_access_list.is_empty() { if emergency_access_list.is_empty() {
debug!("No emergency request timeout to approve"); debug!("No emergency request timeout to approve");
return;
} }
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
for mut emer in emergency_access_list { for mut emer in emergency_access_list {
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) match emer.is_recovery_allowed(&now) {
let recovery_allowed_at = Ok(true) => {} // Recovery is allowed
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap(); Ok(false) => continue,
if recovery_allowed_at.le(&now) { Err(e) => {
error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
continue;
}
}
// Get grantor user to send Accepted email
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
continue;
};
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
error!("Missing grantee for emergency access '{}'", emer.uuid);
continue;
};
// Get grantee user to send Accepted email
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
continue;
};
// Only update the access status // Only update the access status
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active // Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn) let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
.await let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
.expect("Unable to update emergency access status"); if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
continue;
}
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
// get grantor user to send Accepted email if let Err(e) = mail::send_emergency_access_recovery_timed_out(
let grantor_user =
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
// get grantee user to send Accepted email
let grantee_user =
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
.await
.expect("Grantee user not found");
mail::send_emergency_access_recovery_timed_out(
&grantor_user.email, &grantor_user.email,
&grantee_user.name, &grantee_user.name,
emer.get_type_as_str(), emer.get_type_as_str(),
) )
.await .await
.expect("Error on sending email"); {
error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid);
}
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name) if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await
.await {
.expect("Error on sending email"); error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid);
} }
} }
} }
} else {
error!("Failed to get DB connection while searching emergency request timed out");
}
} }
pub async fn emergency_notification_reminder_job(pool: DbPool) { pub async fn emergency_notification_reminder_job(pool: DbPool) {
@@ -802,55 +844,85 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
return; return;
} }
if let Ok(conn) = pool.get().await { let Ok(conn) = pool.get().await else {
error!("Failed to get DB connection for emergency reminders!");
return;
};
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
if emergency_access_list.is_empty() { if emergency_access_list.is_empty() {
debug!("No emergency request reminder notification to send"); debug!("No emergency request reminder notification to send");
return;
} }
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
for mut emer in emergency_access_list { for mut emer in emergency_access_list {
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) let Ok(deadline_at) = emer.deadline_at() else {
// Calculate the day before the recovery will become active error!("Invalid deadline for emergency access '{}'", emer.uuid);
let final_recovery_reminder_at = continue;
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap(); };
// Calculate if a day has passed since the previous notification, else no notification has been sent before
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
last_notification_at + TimeDelta::try_days(1).unwrap() error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
continue;
};
// Only send reminders during the last 24 hours, else continue
if now < final_reminder_at || now >= deadline_at {
continue;
}
// Check if we did not sent a recovery mail in the past 24h already, else skip it.
// In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
// This is to prevent possible missed reminders, this might cause an extra reminder,
// but that is better then no reminder at all.
let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
} else { } else {
now now
}; };
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
// Only send a reminder if the `next_reminder_at` is less then or equal to `now`
// Else we can skip the current emergency access this run.
if next_reminder_at.gt(&now) {
continue;
}
// Get grantor user to send Reminder email
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
continue;
};
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
error!("Missing grantee for emergency access '{}'", emer.uuid);
continue;
};
// Get grantee user to send Reminder email
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
continue;
};
// Only update the last notification date // Only update the last notification date
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active // Updating the whole record could cause issues when the emergency_request_timeout_job is also active
emer.update_last_notification_date_and_save(&now, &conn) if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await {
.await error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
.expect("Unable to update emergency access notification date"); continue;
}
if CONFIG.mail_enabled() { if CONFIG.mail_enabled()
// get grantor user to send Accepted email && let Err(e) = mail::send_emergency_access_recovery_reminder(
let grantor_user =
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
// get grantee user to send Accepted email
let grantee_user =
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
.await
.expect("Grantee user not found");
mail::send_emergency_access_recovery_reminder(
&grantor_user.email, &grantor_user.email,
&grantee_user.name, &grantee_user.name,
emer.get_type_as_str(), emer.get_type_as_str(),
"1", // This notification is only triggered one day before the activation "1", // This notification is only triggered one day before the activation
) )
.await .await
.expect("Error on sending email"); {
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
} }
} }
}
} else {
error!("Failed to get DB connection while searching emergency notification reminder");
}
} }
+8 -5
View File
@@ -347,14 +347,17 @@ async fn log_event_impl(
pub async fn event_cleanup_job(pool: DbPool) { pub async fn event_cleanup_job(pool: DbPool) {
debug!("Start events cleanup job"); debug!("Start events cleanup job");
if CONFIG.events_days_retain().is_none() { if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
debug!("events_days_retain is not configured, abort"); debug!("events_days_retain is not configured or set to 0, skipping");
return; return;
} }
if let Ok(conn) = pool.get().await { let Ok(conn) = pool.get().await else {
Event::clean_events(&conn).await.ok();
} else {
error!("Failed to get DB connection while trying to cleanup the events table"); error!("Failed to get DB connection while trying to cleanup the events table");
return;
};
if let Err(e) = Event::clean_events(&conn).await {
error!("Failed to cleanup the events table: {e}");
} }
} }
+2 -2
View File
@@ -294,7 +294,7 @@ pub async fn validate_email_code_str(
if !crypto::ct_eq(issued_token, token) { if !crypto::ct_eq(issued_token, token) {
email_data.add_attempt(); email_data.add_attempt();
if email_data.attempts >= CONFIG.email_attempts_limit() { if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
email_data.reset_token(); email_data.reset_token();
} }
twofactor.data = email_data.to_json(); twofactor.data = email_data.to_json();
@@ -313,7 +313,7 @@ pub async fn validate_email_code_str(
twofactor.save(conn).await?; twofactor.save(conn).await?;
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc(); let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
let max_time = CONFIG.email_expiration_time().cast_signed(); let max_time = i64::from(CONFIG.email_expiration_time());
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
err!( err!(
"Token has expired", "Token has expired",
+14 -4
View File
@@ -196,7 +196,7 @@ pub async fn enforce_2fa_policy_for_org(
pub async fn send_incomplete_2fa_notifications(pool: DbPool) { pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
debug!("Sending notifications for incomplete 2FA logins"); debug!("Sending notifications for incomplete 2FA logins");
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
return; return;
} }
@@ -206,11 +206,21 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
}; };
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap(); let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
let time_before = now - time_limit; error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
return;
};
let Some(time_before) = now.checked_sub_signed(time_limit) else {
error!("Failed to calculate the timestamp for sending incomplete 2fa attempts");
return;
};
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await; let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
for login in incomplete_logins { for login in incomplete_logins {
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found"); let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
error!("User '{}', not found!", login.user_uuid);
continue;
};
info!( info!(
"User {} did not complete a 2FA login within the configured time limit. IP: {}", "User {} did not complete a 2FA login within the configured time limit. IP: {}",
user.email, login.ip_address user.email, login.ip_address
+5 -4
View File
@@ -128,23 +128,24 @@ pub async fn validate_protected_action_otp(
.await .await
.map_res("Protected action token not found, try sending the code again or restart the process")?; .map_res("Protected action token not found, try sending the code again or restart the process")?;
let mut pa_data = ProtectedActionData::from_json(&pa.data)?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
pa_data.add_attempt();
pa.data = pa_data.to_json();
// Fail after x attempts if the token has been used too many times. // Fail after x attempts if the token has been used too many times.
// Don't delete it, as we use it to keep track of attempts. // Don't delete it, as we use it to keep track of attempts.
if pa_data.attempts >= CONFIG.email_attempts_limit() { if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
err!("Token has expired") err!("Token has expired")
} }
// Check if the token has expired (Using the email 2fa expiration time) // Check if the token has expired (Using the email 2fa expiration time)
let max_time = CONFIG.email_expiration_time().cast_signed(); let max_time = i64::from(CONFIG.email_expiration_time());
if pa_data.time_since_sent().num_seconds() > max_time { if pa_data.time_since_sent().num_seconds() > max_time {
pa.delete(conn).await?; pa.delete(conn).await?;
err!("Token has expired") err!("Token has expired")
} }
if !crypto::ct_eq(&pa_data.token, otp) { if !crypto::ct_eq(&pa_data.token, otp) {
pa_data.add_attempt();
pa.data = pa_data.to_json();
pa.save(conn).await?; pa.save(conn).await?;
err!("Token is invalid") err!("Token is invalid")
} }
+3 -3
View File
@@ -62,7 +62,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
// Generate the cookie store // Generate the cookie store
let cookie_store = Arc::new(Jar::default()); let cookie_store = Arc::new(Jar::default());
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout()); let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout()));
let pool_idle_timeout = Duration::from_secs(10); let pool_idle_timeout = Duration::from_secs(10);
// Reuse the client between requests // Reuse the client between requests
get_reqwest_client_builder(true) get_reqwest_client_builder(true)
@@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
None None
} }
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> { async fn file_is_expired(path: &str, ttl: u32) -> Result<bool, Error> {
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
let meta = operator.stat(path).await?; let meta = operator.stat(path).await?;
let modified = let modified =
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?; meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
let age = SystemTime::now().duration_since(modified.into())?; let age = SystemTime::now().duration_since(modified.into())?;
Ok(ttl > 0 && ttl <= age.as_secs()) Ok(ttl > 0 && u64::from(ttl) <= age.as_secs())
} }
async fn icon_is_negcached(path: &str) -> bool { async fn icon_is_negcached(path: &str) -> bool {
+2 -2
View File
@@ -447,9 +447,9 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
if user.last_verifying_at.is_none() if user.last_verifying_at.is_none()
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
> CONFIG.signups_verify_resend_time().cast_signed() > i64::from(CONFIG.signups_verify_resend_time())
{ {
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed(); let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
if resend_limit == 0 || user.login_verify_count < resend_limit { if resend_limit == 0 || user.login_verify_count < resend_limit {
// We want to send another email verification if we require signups to verify // We want to send another email verification if we require signups to verify
// their email address, and we haven't sent them a reminder in a while... // their email address, and we haven't sent them a reminder in a while...
+42 -24
View File
@@ -314,6 +314,17 @@ impl LoginJwtClaims {
} }
} }
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
};
let Some(exp) = now.checked_add_signed(hours_delta) else {
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
};
Ok(exp)
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct InviteJwtClaims { pub struct InviteJwtClaims {
// Not before // Not before
@@ -337,19 +348,19 @@ pub fn generate_invite_claims(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
invited_by_email: Option<String>, invited_by_email: Option<String>,
) -> InviteJwtClaims { ) -> Result<InviteJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let exp = calculate_invite_expire_hours(&time_now)?;
InviteJwtClaims { Ok(InviteJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_INVITE_ISSUER.to_string(), iss: JWT_INVITE_ISSUER.to_string(),
sub: user_id, sub: user_id,
email, email,
org_id, org_id,
member_id, member_id,
invited_by_email, invited_by_email,
} })
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -375,19 +386,19 @@ pub fn generate_emergency_access_invite_claims(
emer_id: EmergencyAccessId, emer_id: EmergencyAccessId,
grantor_name: String, grantor_name: String,
grantor_email: String, grantor_email: String,
) -> EmergencyAccessInviteJwtClaims { ) -> Result<EmergencyAccessInviteJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let exp = calculate_invite_expire_hours(&time_now)?;
EmergencyAccessInviteJwtClaims { Ok(EmergencyAccessInviteJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(), iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
sub: user_id, sub: user_id,
email, email,
emer_id, emer_id,
grantor_name, grantor_name,
grantor_email, grantor_email,
} })
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -521,36 +532,43 @@ impl BasicJwtClaims {
} }
} }
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let exp = calculate_invite_expire_hours(&time_now)?;
BasicJwtClaims { Ok(BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_DELETE_ISSUER.to_string(), iss: JWT_DELETE_ISSUER.to_string(),
sub: uuid, sub: uuid,
} })
} }
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims { pub fn generate_verify_email_claims(user_id: &UserId) -> Result<BasicJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let exp = calculate_invite_expire_hours(&time_now)?;
BasicJwtClaims { Ok(BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_VERIFYEMAIL_ISSUER.to_string(), iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
sub: user_id.to_string(), sub: user_id.to_string(),
} })
} }
pub fn generate_admin_claims() -> BasicJwtClaims { pub fn generate_admin_claims() -> Result<BasicJwtClaims, Error> {
let time_now = Utc::now(); let time_now = Utc::now();
BasicJwtClaims { let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else {
err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
};
let Some(exp) = time_now.checked_add_signed(minutes_delta) else {
err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
};
Ok(BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(), exp: exp.timestamp(),
iss: JWT_ADMIN_ISSUER.to_string(), iss: JWT_ADMIN_ISSUER.to_string(),
sub: "admin_panel".to_owned(), sub: "admin_panel".to_owned(),
} })
} }
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims { pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
+107 -33
View File
@@ -111,6 +111,7 @@ macro_rules! make_config {
_usr: ConfigBuilder, _usr: ConfigBuilder,
_overrides: Vec<&'static str>, _overrides: Vec<&'static str>,
_config_file_status: ConfigFileStatus,
} }
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html // Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
@@ -259,11 +260,18 @@ macro_rules! make_config {
builder builder
} }
async fn from_file() -> Result<Self, Error> { async fn from_file() -> Result<Option<Self>, Error> {
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
let config_bytes = operator.read(&CONFIG_FILENAME).await?; let config_bytes = match operator.read(&CONFIG_FILENAME).await {
Ok(bytes) => bytes,
// No config file is fine, should not return any error
Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None),
// Any other error might be an issue and should be passed back to the caller
Err(e) => return Err(e.into()),
};
let builder = serde_json::from_slice(&config_bytes.to_vec())?;
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE); println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into) Ok(Some(builder))
} }
fn clear_non_editable(&mut self) { fn clear_non_editable(&mut self) {
@@ -440,6 +448,8 @@ macro_rules! make_config {
"domain_origin", "domain_origin",
"domain_path", "domain_path",
"domain", "domain",
"duo_ikey",
"duo_host",
"helo_name", "helo_name",
"org_creation_users", "org_creation_users",
"signups_domains_whitelist", "signups_domains_whitelist",
@@ -451,6 +461,8 @@ macro_rules! make_config {
"sso_authority", "sso_authority",
"sso_callback_path", "sso_callback_path",
"sso_client_id", "sso_client_id",
"yubico_client_id",
"yubico_server",
]; ];
let cfg = { let cfg = {
@@ -483,6 +495,10 @@ macro_rules! make_config {
}; };
overrides overrides
} }
pub fn get_config_file_status(&self) -> ConfigFileStatus {
self.inner.read().unwrap()._config_file_status
}
} }
}; };
} }
@@ -539,7 +555,7 @@ make_config! {
jobs { jobs {
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run. /// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
/// Set to 0 to globally disable scheduled jobs. /// Set to 0 to globally disable scheduled jobs.
job_poll_interval_ms: u64, false, def, 30_000; job_poll_interval_ms: u32, false, def, 30_000;
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date. /// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
/// Defaults to hourly. Set blank to disable this job. /// Defaults to hourly. Set blank to disable this job.
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned(); send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
@@ -598,16 +614,16 @@ make_config! {
user_send_limit: i64, true, option; user_send_limit: i64, true, option;
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item. /// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make /// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make
/// sure to inform all users of any changes to this setting. /// sure to inform all users of any changes to this setting.
trash_auto_delete_days: i64, true, option; trash_auto_delete_days: u16, true, option;
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is /// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one /// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
/// where the correct master password was provided but the required 2FA step was not completed, /// where the correct master password was provided but the required 2FA step was not completed,
/// which potentially indicates a master password compromise. Set to 0 to disable this check. /// which potentially indicates a master password compromise. Set to 0 to disable this check.
/// This setting applies globally to all users. /// This setting applies globally to all users.
incomplete_2fa_time_limit: i64, true, def, 3; incomplete_2fa_time_limit: u16, true, def, 3;
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service. /// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external /// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
@@ -620,9 +636,9 @@ make_config! {
/// this will prevent logins from succeeding until the address has been verified /// this will prevent logins from succeeding until the address has been verified
signups_verify: bool, true, def, false; signups_verify: bool, true, def, false;
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds) /// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
signups_verify_resend_time: u64, true, def, 3_600; signups_verify_resend_time: u32, true, def, 3_600;
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit) /// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
signups_verify_resend_limit: u32, true, def, 6; signups_verify_resend_limit: u16, true, def, 6;
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled /// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
signups_domains_whitelist: String, true, def, String::new(); signups_domains_whitelist: String, true, def, String::new();
/// Enable event logging |> Enables event logging for organizations. /// Enable event logging |> Enables event logging for organizations.
@@ -634,7 +650,7 @@ make_config! {
invitations_allowed: bool, true, def, true; invitations_allowed: bool, true, def, true;
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token, /// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
/// email verification token and deletion request token will expire (must be at least 1) /// email verification token and deletion request token will expire (must be at least 1)
invitation_expiration_hours: u32, false, def, 120; invitation_expiration_hours: u16, false, def, 120;
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users. /// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
emergency_access_allowed: bool, true, def, true; emergency_access_allowed: bool, true, def, true;
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users. /// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
@@ -655,8 +671,8 @@ make_config! {
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
invitation_org_name: String, true, def, "Vaultwarden".to_owned(); invitation_org_name: String, true, def, "Vaultwarden".to_owned();
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely. /// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely.
events_days_retain: i64, false, option; events_days_retain: u16, false, option;
}, },
client { client {
@@ -694,13 +710,13 @@ make_config! {
/// Temporary redirects are useful while testing different icon services, but once a service /// Temporary redirects are useful while testing different icon services, but once a service
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes /// has been decided on, consider using permanent redirects for cacheability. The legacy codes
/// are currently better supported by the Bitwarden clients. /// are currently better supported by the Bitwarden clients.
icon_redirect_code: u32, true, def, 302; icon_redirect_code: u16, true, def, 302;
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed /// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
icon_cache_ttl: u64, true, def, 2_592_000; icon_cache_ttl: u32, true, def, 2_592_000;
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again. /// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
icon_cache_negttl: u64, true, def, 259_200; icon_cache_negttl: u32, true, def, 259_200;
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon. /// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
icon_download_timeout: u64, true, def, 10; icon_download_timeout: u16, true, def, 10;
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead /// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
icon_blacklist_regex: String, false, option; icon_blacklist_regex: String, false, option;
@@ -751,11 +767,11 @@ make_config! {
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
db_connection_retries: u32, false, def, 15; db_connection_retries: u32, false, def, 15;
/// Timeout when acquiring database connection /// Timeout in seconds when acquiring database connection
database_timeout: u64, false, def, 30; database_timeout: u32, false, def, 30;
/// Timeout in seconds before idle connections to the database are closed /// Timeout in seconds before idle connections to the database are closed
database_idle_timeout: u64, false, def, 600; database_idle_timeout: u32, false, def, 600;
/// Database connection max pool size /// Database connection max pool size
database_max_conns: u32, false, def, 10; database_max_conns: u32, false, def, 10;
@@ -776,22 +792,22 @@ make_config! {
allowed_connect_src: String, true, def, String::new(); allowed_connect_src: String, true, def, String::new();
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
login_ratelimit_seconds: u64, false, def, 60; login_ratelimit_seconds: u16, false, def, 60;
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
login_ratelimit_max_burst: u32, false, def, 10; login_ratelimit_max_burst: u16, false, def, 10;
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints /// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
unauthenticated_ratelimit_seconds: u64, false, def, 60; unauthenticated_ratelimit_seconds: u16, false, def, 60;
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one /// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
unauthenticated_ratelimit_max_burst: u32, false, def, 50; unauthenticated_ratelimit_max_burst: u16, false, def, 50;
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in /// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
admin_ratelimit_seconds: u64, false, def, 300; admin_ratelimit_seconds: u16, false, def, 300;
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` /// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
admin_ratelimit_max_burst: u32, false, def, 3; admin_ratelimit_max_burst: u16, false, def, 3;
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes). /// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
admin_session_lifetime: i64, true, def, 20; admin_session_lifetime: u32, true, def, 20;
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false; org_groups_enabled: bool, false, def, false;
@@ -844,8 +860,8 @@ make_config! {
sso_master_password_policy: String, true, option; sso_master_password_policy: String, true, option;
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) /// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
sso_auth_only_not_session: bool, true, def, false; sso_auth_only_not_session: bool, true, def, false;
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache /// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
sso_client_cache_expiration: u64, true, def, 0; sso_client_cache_expiration: u32, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false; sso_debug_tokens: bool, true, def, false;
}, },
@@ -903,7 +919,7 @@ make_config! {
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
smtp_auth_mechanism: String, true, option; smtp_auth_mechanism: String, true, option;
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
smtp_timeout: u64, true, def, 15; smtp_timeout: u16, true, def, 15;
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
helo_name: String, true, option; helo_name: String, true, option;
/// Embed images as email attachments. /// Embed images as email attachments.
@@ -925,9 +941,9 @@ make_config! {
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
email_token_size: u8, true, def, 6; email_token_size: u8, true, def, 6;
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
email_expiration_time: u64, true, def, 600; email_expiration_time: u32, true, def, 600;
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent /// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
email_attempts_limit: u64, true, def, 3; email_attempts_limit: u8, true, def, 3;
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy /// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
email_2fa_enforce_on_verified_invite: bool, true, def, false; email_2fa_enforce_on_verified_invite: bool, true, def, false;
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed /// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
@@ -983,7 +999,15 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
} }
if cfg.database_min_conns > cfg.database_max_conns { if cfg.database_min_conns > cfg.database_max_conns {
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`."); err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`.");
}
if cfg.database_timeout < 1 {
err!("`DATABASE_TIMEOUT` must be at least 1 second");
}
if cfg.database_idle_timeout < 1 {
err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second");
} }
if let Some(log_file) = &cfg.log_file if let Some(log_file) = &cfg.log_file
@@ -1274,6 +1298,30 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression") err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
} }
if cfg.login_ratelimit_seconds < 1 {
err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.login_ratelimit_max_burst < 1 {
err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1");
}
if cfg.admin_ratelimit_seconds < 1 {
err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.admin_ratelimit_max_burst < 1 {
err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1");
}
if cfg.unauthenticated_ratelimit_seconds < 1 {
err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second");
}
if cfg.unauthenticated_ratelimit_max_burst < 1 {
err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1");
}
if !cfg.disable_admin_token { if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() { match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => { Some(t) if t.starts_with("$argon2") => {
@@ -1443,11 +1491,34 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"pm-32009-new-item-types", "pm-32009-new-item-types",
]; ];
#[derive(Clone, Copy, Serialize)]
pub enum ConfigFileStatus {
NotFound,
Loaded,
Error,
}
impl Config { impl Config {
pub async fn load() -> Result<Self, Error> { pub async fn load() -> Result<Self, Error> {
// Loading from env and file // Loading from env and file
let env = ConfigBuilder::from_env(); let env = ConfigBuilder::from_env();
let usr = ConfigBuilder::from_file().await.unwrap_or_default(); let (usr, config_file_status) = match ConfigBuilder::from_file().await {
Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded),
Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound),
Err(e) => {
println!(
"[ERROR] Failed to load configuration from `{}`\n\
#####################################################################################\n\
# The file seems to exist but might contain invalid JSON or invalid values. #\n\
# Check the syntax and make sure every value is valid and within its allowed range. #\n\
# This file is not used until those errors are resolved. #\n\
#####################################################################################\n\
{e:?}\n",
*CONFIG_FILE
);
(ConfigBuilder::default(), ConfigFileStatus::Error)
}
};
// Create merged config, config file overwrites env // Create merged config, config file overwrites env
let mut overrides = Vec::new(); let mut overrides = Vec::new();
@@ -1467,6 +1538,7 @@ impl Config {
_env: env, _env: env,
_usr: usr, _usr: usr,
_overrides: overrides, _overrides: overrides,
_config_file_status: config_file_status,
}), }),
}) })
} }
@@ -1500,6 +1572,7 @@ impl Config {
writer.config = config; writer.config = config;
writer._usr = builder; writer._usr = builder;
writer._overrides = overrides; writer._overrides = overrides;
writer._config_file_status = ConfigFileStatus::Loaded;
} }
//Save to file //Save to file
@@ -1591,6 +1664,7 @@ impl Config {
writer.config = config; writer.config = config;
writer._usr = usr; writer._usr = usr;
writer._overrides = Vec::new(); writer._overrides = Vec::new();
writer._config_file_status = ConfigFileStatus::NotFound;
} }
// Invalidate CSS Cache because several config items might have impact on the rendered CSS // Invalidate CSS Cache because several config items might have impact on the rendered CSS
+3 -3
View File
@@ -212,8 +212,8 @@ impl DbPool {
let pool = Pool::builder() let pool = Pool::builder()
.max_size(max_conns) .max_size(max_conns)
.min_idle(Some(CONFIG.database_min_conns())) .min_idle(Some(CONFIG.database_min_conns()))
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout()))) .idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
.connection_timeout(Duration::from_secs(CONFIG.database_timeout())) .connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
.connection_customizer(Box::new(DbConnOptions { .connection_customizer(Box::new(DbConnOptions {
init_stmts: conn_type.get_init_stmts(), init_stmts: conn_type.get_init_stmts(),
})) }))
@@ -233,7 +233,7 @@ impl DbPool {
// Get a connection from the pool // Get a connection from the pool
pub async fn get(&self) -> Result<DbConn, Error> { pub async fn get(&self) -> Result<DbConn, Error> {
let duration = Duration::from_secs(CONFIG.database_timeout()); let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await { let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
Ok(p) => p.expect("Semaphore should be open"), Ok(p) => p.expect("Semaphore should be open"),
Err(_) => { Err(_) => {
+1 -1
View File
@@ -197,7 +197,7 @@ impl AuthRequest {
pub async fn purge_expired_auth_requests(conn: &DbConn) { pub async fn purge_expired_auth_requests(conn: &DbConn) {
// delete auth requests older than 15 minutes which is functionally equivalent to upstream: // delete auth requests older than 15 minutes which is functionally equivalent to upstream:
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql // https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap(); let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15);
for auth_request in Self::find_created_before(&expiry_time, conn).await { for auth_request in Self::find_created_before(&expiry_time, conn).await {
auth_request.delete(conn).await.ok(); auth_request.delete(conn).await.ok();
} }
+13 -3
View File
@@ -494,14 +494,24 @@ impl Cipher {
/// Purge all ciphers that are old enough to be auto-deleted. /// Purge all ciphers that are old enough to be auto-deleted.
pub async fn purge_trash(conn: &DbConn) { pub async fn purge_trash(conn: &DbConn) {
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() { let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
debug!("trash_auto_delete_days is not configured or set to 0, skipping");
return;
};
let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
error!("Unable to calculate auto delete days!");
return;
};
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap(); let Some(dt) = now.checked_sub_signed(days) else {
error!("Unable to calculate trash purge date!");
return;
};
for cipher in Self::find_deleted_before(&dt, conn).await { for cipher in Self::find_deleted_before(&dt, conn).await {
cipher.delete(conn).await.ok(); cipher.delete(conn).await.ok();
} }
} }
}
pub async fn move_to_folder( pub async fn move_to_folder(
&self, &self,
+45 -12
View File
@@ -1,4 +1,4 @@
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, TimeDelta, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::prelude::*;
use serde_json::Value; use serde_json::Value;
@@ -112,8 +112,26 @@ impl EmergencyAccess {
"object": "emergencyAccessGranteeDetails", "object": "emergencyAccessGranteeDetails",
})) }))
} }
pub fn deadline_at(&self) -> Result<NaiveDateTime, crate::Error> {
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) {
err!("Emergency access wait time is out-of-bounds!")
}
let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?;
TimeDelta::try_days(i64::from(self.wait_time_days))
.and_then(|w| initiated_at.checked_add_signed(w))
.map_res("Emergency access deadline is invalid")
}
pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result<bool, crate::Error> {
Ok(self.deadline_at()?.le(now))
}
} }
pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive<i32> = 1..=90;
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub enum EmergencyAccessType { pub enum EmergencyAccessType {
View = 0, View = 0,
@@ -169,27 +187,42 @@ impl EmergencyAccess {
pub async fn update_access_status_and_save( pub async fn update_access_status_and_save(
&mut self, &mut self,
status: i32, from_status: i32,
to_status: i32,
date: &NaiveDateTime, date: &NaiveDateTime,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
// Update the grantee so that it will refresh it's status. let uuid = self.uuid.clone();
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await; let updated_at = *date;
self.status = status; let res = conn
date.clone_into(&mut self.updated_at); .run(move |conn| {
conn.run(move |conn| {
crate::util::retry( crate::util::retry(
|| { || {
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) diesel::update(
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date))) emergency_access::table
.filter(emergency_access::uuid.eq(&uuid))
.filter(emergency_access::status.eq(from_status)),
)
.set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at)))
.execute(conn) .execute(conn)
}, },
10, 10,
) )
.map_res("Error updating emergency access status")
}) })
.await .await;
match res {
Ok(1) => {} // One record updated, which is valid
Ok(_) => err!("Emergency access status was changed already"),
Err(e) => err!("Error updating emergency access status", format!("{e}")),
}
self.status = to_status;
self.updated_at = updated_at;
// Update the grantee so that it will refresh it's status.
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
Ok(())
} }
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
+11 -5
View File
@@ -347,17 +347,23 @@ impl Event {
} }
pub async fn clean_events(conn: &DbConn) -> EmptyResult { pub async fn clean_events(conn: &DbConn) -> EmptyResult {
if let Some(days_to_retain) = CONFIG.events_days_retain() { let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else {
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap(); return Ok(());
};
let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else {
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
};
let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else {
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
};
conn.run(move |conn| { conn.run(move |conn| {
diesel::delete(event::table.filter(event::event_date.lt(dt))) diesel::delete(event::table.filter(event::event_date.lt(dt)))
.execute(conn) .execute(conn)
.map_res("Error cleaning old events") .map_res("Error cleaning old events")
}) })
.await .await
} else {
Ok(())
}
} }
} }
+3 -1
View File
@@ -24,7 +24,9 @@ pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; pub use self::emergency_access::{
EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType,
};
pub use self::event::{Event, EventType}; pub use self::event::{Event, EventType};
pub use self::favorite::Favorite; pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::folder::{Folder, FolderCipher, FolderId};
+2 -2
View File
@@ -37,7 +37,7 @@ impl TwoFactorIncomplete {
ip: &ClientIp, ip: &ClientIp,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
return Ok(()); return Ok(());
} }
@@ -66,7 +66,7 @@ impl TwoFactorIncomplete {
} }
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
return Ok(()); return Ok(());
} }
+6 -6
View File
@@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str()) let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
.port(CONFIG.smtp_port()) .port(CONFIG.smtp_port())
.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))); .timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout()))));
// Determine security // Determine security
let smtp_client = if CONFIG.smtp_security() == *"off" { let smtp_client = if CONFIG.smtp_security() == *"off" {
@@ -169,7 +169,7 @@ pub async fn send_password_hint(address: &str, hint: Option<String>) -> EmptyRes
} }
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_delete_claims(user_id.to_string()); let claims = generate_delete_claims(user_id.to_string())?;
let delete_token = encode_jwt(&claims); let delete_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
@@ -187,7 +187,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult
} }
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_verify_email_claims(user_id); let claims = generate_verify_email_claims(user_id)?;
let verify_email_token = encode_jwt(&claims); let verify_email_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
@@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
} }
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
let claims = generate_verify_email_claims(user_id); let claims = generate_verify_email_claims(user_id)?;
let verify_email_token = encode_jwt(&claims); let verify_email_token = encode_jwt(&claims);
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
@@ -292,7 +292,7 @@ pub async fn send_invite(
org_id.clone(), org_id.clone(),
member_id.clone(), member_id.clone(),
invited_by_email, invited_by_email,
); )?;
let invite_token = encode_jwt(&claims); let invite_token = encode_jwt(&claims);
let mut query = url::Url::parse("https://query.builder").unwrap(); let mut query = url::Url::parse("https://query.builder").unwrap();
{ {
@@ -351,7 +351,7 @@ pub async fn send_emergency_access_invite(
emer_id.clone(), emer_id.clone(),
String::from(grantor_name), String::from(grantor_name),
String::from(grantor_email), String::from(grantor_email),
); )?;
// Build the query here to ensure proper escaping // Build the query here to ensure proper escaping
let mut query = url::Url::parse("https://query.builder").unwrap(); let mut query = url::Url::parse("https://query.builder").unwrap();
+4 -2
View File
@@ -729,7 +729,7 @@ fn schedule_jobs(pool: db::DbPool) {
// Cleanup the event table of records x days old. // Cleanup the event table of records x days old.
if CONFIG.org_events_enabled() if CONFIG.org_events_enabled()
&& !CONFIG.event_cleanup_schedule().is_empty() && !CONFIG.event_cleanup_schedule().is_empty()
&& CONFIG.events_days_retain().is_some() && CONFIG.events_days_retain().is_some_and(|d| d != 0)
{ {
sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || { sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || {
runtime.spawn(api::event_cleanup_job(pool.clone())); runtime.spawn(api::event_cleanup_job(pool.clone()));
@@ -754,7 +754,9 @@ fn schedule_jobs(pool: db::DbPool) {
// tick, the one that was added earlier will run first. // tick, the one that was added earlier will run first.
loop { loop {
sched.tick(); sched.tick();
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms()))); runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from(
CONFIG.job_poll_interval_ms(),
))));
} }
}) })
.expect("Error spawning job scheduler thread"); .expect("Error spawning job scheduler thread");
+6 -6
View File
@@ -7,20 +7,20 @@ use crate::{CONFIG, Error};
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>; type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| { static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds()); let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds()));
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst"); let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst");
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst)) RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
}); });
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| { static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds()); let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds()));
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst"); let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst");
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
}); });
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| { static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds()); let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds()));
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst()) let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst()))
.expect("Non-zero unauthenticated ratelimit burst"); .expect("Non-zero unauthenticated ratelimit burst");
RateLimiter::keyed( RateLimiter::keyed(
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst), Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
+1 -1
View File
@@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_own
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| { static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
moka::sync::Cache::builder() moka::sync::Cache::builder()
.max_capacity(1) .max_capacity(1)
.time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())) .time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration())))
.build() .build()
}); });
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> = static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
+9 -2
View File
@@ -93,7 +93,9 @@ async function generateSupportString(event, dj) {
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`; supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
supportString += `* Database type: ${dj.db_type}\n`; supportString += `* Database type: ${dj.db_type}\n`;
supportString += `* Database version: ${dj.db_version}\n`; supportString += `* Database version: ${dj.db_version}\n`;
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`; supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`;
supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n";
supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`;
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`; supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`; supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
if (dj.ip_header_exists) { if (dj.ip_header_exists) {
@@ -116,7 +118,7 @@ async function generateSupportString(event, dj) {
} }
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`; supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
if (dj.invalid_feature_flags != "") { if (dj.invalid_feature_flags != "") {
supportString += "* Invalid feature flags: true\n"; supportString += "* Invalid feature flags: true :x:\n";
} }
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
@@ -132,6 +134,11 @@ async function generateSupportString(event, dj) {
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n"; supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
supportString += "<details><summary>Show Config & Details</summary>\n"; supportString += "<details><summary>Show Config & Details</summary>\n";
// Show invalid environment variables if they exists
if (dj.invalid_env_vars != "") {
supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`;
}
// Add overrides if they exists // Add overrides if they exists
if (dj.overrides != "") { if (dj.overrides != "") {
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
+17 -3
View File
@@ -69,12 +69,26 @@
</dd> </dd>
<dt class="col-sm-5">Uses config.json</dt> <dt class="col-sm-5">Uses config.json</dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
{{#if page_data.overrides}} {{#if (eq page_data.config_file_status "Loaded")}}
<span class="d-inline"><b>Yes</b></span> <span class="d-inline"><b>Yes</b></span>
{{#if page_data.overrides}}
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.&#013;&#010;{{page_data.overrides}}">Details</span> <span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.&#013;&#010;{{page_data.overrides}}">Details</span>
{{/if}} {{/if}}
{{#unless page_data.overrides}} {{~else if (eq page_data.config_file_status "Error")}}
<span class="d-block"><b>No</b></span> <span class="d-inline"><b>Errors</b></span>
<span class="badge bg-danger abbr-badge" title="Config file could not be loaded and was ignored. Check server logs for details.">Details</span>
{{~else}}
<span class="d-inline"><b>No</b></span>
{{/if}}
</dd>
<dt class="col-sm-5">Environment variables are valid</dt>
<dd class="col-sm-7">
{{#if page_data.invalid_env_vars}}
<span class="d-inline"><b>No</b></span>
<span class="badge bg-danger abbr-badge" title="Invalid environment variables are found and defaults are used instead.&#013;&#010;{{page_data.invalid_env_vars}}">Details</span>
{{/if}}
{{#unless page_data.invalid_env_vars}}
<span class="d-block"><b>Yes</b></span>
{{/unless}} {{/unless}}
</dd> </dd>
<dt class="col-sm-5">Uses custom templates</dt> <dt class="col-sm-5">Uses custom templates</dt>
+20 -6
View File
@@ -210,7 +210,7 @@ impl Fairing for Cors {
pub struct Cached<R> { pub struct Cached<R> {
response: R, response: R,
is_immutable: bool, is_immutable: bool,
ttl: u64, ttl: u32,
} }
impl<R> Cached<R> { impl<R> Cached<R> {
@@ -230,7 +230,7 @@ impl<R> Cached<R> {
} }
} }
pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached<R> { pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached<R> {
Self { Self {
response, response,
is_immutable, is_immutable,
@@ -251,7 +251,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
res.set_raw_header("Cache-Control", cache_control_header); res.set_raw_header("Cache-Control", cache_control_header);
let time_now = Local::now(); let time_now = Local::now();
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap(); let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap();
res.set_raw_header("Expires", format_datetime_http(&expiry_time)); res.set_raw_header("Expires", format_datetime_http(&expiry_time));
Ok(res) Ok(res)
} }
@@ -447,20 +447,34 @@ pub fn get_env_str_value(key: &str) -> Option<String> {
} }
} }
pub fn get_env<V>(key: &str) -> Option<V> pub static INVALID_ENV_VARS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
pub fn get_env<V>(key: &'static str) -> Option<V>
where where
V: FromStr, V: FromStr,
{ {
try_parse_string(get_env_str_value(key)) let value = get_env_str_value(key);
let value_is_some = value.is_some();
let parsed: Option<V> = try_parse_string(value);
if parsed.is_none() && value_is_some {
println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead.");
INVALID_ENV_VARS.lock().unwrap().push(key);
}
parsed
} }
pub fn get_env_bool(key: &str) -> Option<bool> { pub fn get_env_bool(key: &'static str) -> Option<bool> {
const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"]; const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"];
const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"]; const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"];
match get_env_str_value(key) { match get_env_str_value(key) {
Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true), Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true),
Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false), Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false),
Some(_) => {
println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead.");
INVALID_ENV_VARS.lock().unwrap().push(key);
None
}
_ => None, _ => None,
} }
} }