mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-26 04:06:48 +02:00
* 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>
364 lines
13 KiB
Rust
364 lines
13 KiB
Rust
use std::net::IpAddr;
|
|
|
|
use chrono::NaiveDateTime;
|
|
use rocket::{Route, form::FromForm, serde::json::Json};
|
|
use serde_json::Value;
|
|
|
|
use crate::{
|
|
CONFIG,
|
|
api::{ApiResult, EmptyResult, JsonResult},
|
|
auth::{AdminHeaders, Headers},
|
|
db::{
|
|
DbConn, DbPool,
|
|
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
|
|
},
|
|
error::MapResult,
|
|
util::parse_date,
|
|
};
|
|
|
|
/// ###############################################################################################################
|
|
/// /api routes
|
|
pub fn routes() -> Vec<Route> {
|
|
routes![get_org_events, get_cipher_events, get_user_events,]
|
|
}
|
|
|
|
#[derive(FromForm)]
|
|
struct EventRange {
|
|
start: String,
|
|
end: String,
|
|
#[field(name = "continuationToken")]
|
|
continuation_token: Option<String>,
|
|
}
|
|
|
|
impl EventRange {
|
|
fn date_range(&self) -> ApiResult<(NaiveDateTime, NaiveDateTime)> {
|
|
let start_date = parse_date(&self.start).map_res("Invalid start date")?;
|
|
let end_date = parse_date(self.continuation_token.as_ref().unwrap_or(&self.end)).map_res("Invalid end date")?;
|
|
Ok((start_date, end_date))
|
|
}
|
|
}
|
|
|
|
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
|
|
#[get("/organizations/<org_id>/events?<data..>")]
|
|
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
|
|
// Return an empty vec when we org events are disabled.
|
|
// This prevents client errors
|
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
|
let (start_date, end_date) = data.date_range()?;
|
|
|
|
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
|
.await
|
|
.iter()
|
|
.map(Event::to_json)
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(Json(json!({
|
|
"data": events_json,
|
|
"object": "list",
|
|
"continuationToken": get_continuation_token(&events_json),
|
|
})))
|
|
}
|
|
|
|
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
|
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
|
// Return an empty vec when org events are disabled.
|
|
// This prevents client errors
|
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
|
|
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
|
|
{
|
|
let (start_date, end_date) = data.date_range()?;
|
|
|
|
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(Json(json!({
|
|
"data": events_json,
|
|
"object": "list",
|
|
"continuationToken": get_continuation_token(&events_json),
|
|
})))
|
|
}
|
|
|
|
#[get("/organizations/<org_id>/users/<member_id>/events?<data..>")]
|
|
async fn get_user_events(
|
|
org_id: OrganizationId,
|
|
member_id: MembershipId,
|
|
data: EventRange,
|
|
headers: AdminHeaders,
|
|
conn: DbConn,
|
|
) -> JsonResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
// Return an empty vec when we org events are disabled.
|
|
// This prevents client errors
|
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
|
let (start_date, end_date) = data.date_range()?;
|
|
|
|
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
|
.await
|
|
.iter()
|
|
.map(Event::to_json)
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(Json(json!({
|
|
"data": events_json,
|
|
"object": "list",
|
|
"continuationToken": get_continuation_token(&events_json),
|
|
})))
|
|
}
|
|
|
|
fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
|
|
// When the length of the vec equals the max page_size there probably is more data
|
|
// When it is less, then all events are loaded.
|
|
#[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")]
|
|
if events_json.len() == Event::PAGE_SIZE as usize {
|
|
if let Some(last_event) = events_json.last() {
|
|
last_event["date"].as_str()
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// ###############################################################################################################
|
|
/// /events routes
|
|
pub fn main_routes() -> Vec<Route> {
|
|
routes![post_events_collect,]
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct EventCollection {
|
|
// Mandatory
|
|
r#type: i32,
|
|
date: String,
|
|
|
|
// Optional
|
|
cipher_id: Option<CipherId>,
|
|
organization_id: Option<OrganizationId>,
|
|
}
|
|
|
|
// Upstream:
|
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Events/Controllers/CollectController.cs
|
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs
|
|
#[post("/collect", format = "application/json", data = "<data>")]
|
|
async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers, conn: DbConn) -> EmptyResult {
|
|
if !CONFIG.org_events_enabled() {
|
|
return Ok(());
|
|
}
|
|
|
|
for event in data.iter() {
|
|
let event_date = parse_date(&event.date).map_res("Invalid event date")?;
|
|
// Only the event types upstream accepts from clients, the rest are written server-side.
|
|
match event.r#type {
|
|
t if t == EventType::UserClientExportedVault as i32 => {
|
|
log_user_event_impl(
|
|
event.r#type,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
Some(event_date),
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
}
|
|
t if t == EventType::OrganizationClientExportedVault as i32 => {
|
|
// Only allow logging events for an organization the user is actually a member of.
|
|
if let Some(org_id) = &event.organization_id
|
|
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
|
|
{
|
|
log_event_impl(
|
|
event.r#type,
|
|
org_id,
|
|
org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
Some(event_date),
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
// Only the vault notification banner click is accepted from clients. The rest of
|
|
// the 1500..=1599 range is written server-side and must not be forgeable by a client.
|
|
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
|
|
if let Some(org_id) = &event.organization_id
|
|
&& let Some(membership) =
|
|
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
|
|
{
|
|
log_event_impl(
|
|
event.r#type,
|
|
&membership.uuid,
|
|
org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
Some(event_date),
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
// Client-side cipher events (viewed, copied, toggled, autofilled).
|
|
1107..=1114 | 1117 | 1119..=1132 => {
|
|
// The cipher determines the organization the event is logged to, so make sure the
|
|
// user can actually access it instead of trusting the provided cipher uuid.
|
|
if let Some(cipher_uuid) = &event.cipher_id
|
|
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
|
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
|
&& let Some(org_id) = cipher.organization_uuid
|
|
{
|
|
log_event_impl(
|
|
event.r#type,
|
|
cipher_uuid,
|
|
&org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
Some(event_date),
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
|
|
if !CONFIG.org_events_enabled() {
|
|
return;
|
|
}
|
|
log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await;
|
|
}
|
|
|
|
async fn log_user_event_impl(
|
|
event_type: i32,
|
|
user_id: &UserId,
|
|
device_type: i32,
|
|
event_date: Option<NaiveDateTime>,
|
|
ip: &IpAddr,
|
|
conn: &DbConn,
|
|
) {
|
|
let memberships = Membership::find_confirmed_by_user(user_id, conn).await;
|
|
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
|
|
|
// Upstream saves the event also without any org_id.
|
|
let mut event = Event::new(event_type, event_date);
|
|
event.user_uuid = Some(user_id.clone());
|
|
event.act_user_uuid = Some(user_id.clone());
|
|
event.device_type = Some(device_type);
|
|
event.ip_address = Some(ip.to_string());
|
|
events.push(event);
|
|
|
|
// For each org a user is a member of store these events per org
|
|
for membership in memberships {
|
|
let mut event = Event::new(event_type, event_date);
|
|
event.user_uuid = Some(user_id.clone());
|
|
event.org_uuid = Some(membership.org_uuid);
|
|
event.org_user_uuid = Some(membership.uuid);
|
|
event.act_user_uuid = Some(user_id.clone());
|
|
event.device_type = Some(device_type);
|
|
event.ip_address = Some(ip.to_string());
|
|
events.push(event);
|
|
}
|
|
|
|
Event::save_user_event(events, conn).await.unwrap_or(());
|
|
}
|
|
|
|
pub async fn log_event(
|
|
event_type: EventType,
|
|
source_uuid: &str,
|
|
org_id: &OrganizationId,
|
|
act_user_id: &UserId,
|
|
device_type: i32,
|
|
ip: &IpAddr,
|
|
conn: &DbConn,
|
|
) {
|
|
if !CONFIG.org_events_enabled() {
|
|
return;
|
|
}
|
|
log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
|
}
|
|
|
|
#[expect(clippy::too_many_arguments)]
|
|
async fn log_event_impl(
|
|
event_type: i32,
|
|
source_uuid: &str,
|
|
org_id: &OrganizationId,
|
|
act_user_id: &UserId,
|
|
device_type: i32,
|
|
event_date: Option<NaiveDateTime>,
|
|
ip: &IpAddr,
|
|
conn: &DbConn,
|
|
) {
|
|
// Create a new empty event
|
|
let mut event = Event::new(event_type, event_date);
|
|
match event_type {
|
|
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
|
// Cipher Events
|
|
1100..=1199 => {
|
|
event.cipher_uuid = Some(source_uuid.to_owned().into());
|
|
}
|
|
// Collection Events
|
|
1300..=1399 => {
|
|
event.collection_uuid = Some(source_uuid.to_owned().into());
|
|
}
|
|
// Group Events
|
|
1400..=1499 => {
|
|
event.group_uuid = Some(source_uuid.to_owned().into());
|
|
}
|
|
// Org User Events
|
|
1500..=1599 => {
|
|
event.org_user_uuid = Some(source_uuid.to_owned().into());
|
|
}
|
|
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
|
// Policy Events
|
|
1700..=1799 => {
|
|
event.policy_uuid = Some(source_uuid.to_owned().into());
|
|
}
|
|
// Ignore others
|
|
_ => {}
|
|
}
|
|
|
|
event.org_uuid = Some(org_id.clone());
|
|
event.act_user_uuid = Some(act_user_id.clone());
|
|
event.device_type = Some(device_type);
|
|
event.ip_address = Some(ip.to_string());
|
|
event.save(conn).await.unwrap_or(());
|
|
}
|
|
|
|
pub async fn event_cleanup_job(pool: DbPool) {
|
|
debug!("Start events cleanup job");
|
|
if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
|
|
debug!("events_days_retain is not configured or set to 0, skipping");
|
|
return;
|
|
}
|
|
|
|
let Ok(conn) = pool.get().await else {
|
|
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}");
|
|
}
|
|
}
|