mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-24 21:46:48 +03:00
- Attachments: reject a declared v2 attachment size above the upload limit, keep display sizes within the largest unit, and make download links valid for 1 minute like upstream. - Ciphers: moving a personal cipher into an organization needs collection write access or full access, like upstream. - Events: /events/collect only accepts the client event types upstream accepts. - Client IP: for list headers like X-Forwarded-For, use the rightmost address that isn't in IP_HEADER_TRUSTED_PROXIES, reading every occurrence. - Prelogin: unknown emails get stable KDF settings from upstream's list. - Login with device: an auth request can be used for one login, like upstream. - Directory Connector import: never revokes, restores or removes Owners, like upstream. - SSO: check the PKCE verifier again when the code exchange is reused during 2FA, and accept the same redirect URIs as upstream (desktop localhost callback for AppImage and dev builds, CLI on ports 8065-8070). - Identity: send the merged master password policy in the 2FA response and use PascalCase policy keys in identity responses, like upstream. Organization API key login no longer needs device fields. Recovery code login normalises the code, uses upstream's error message, logs a failed 2FA event and sends a "two-step login recovered" email. - Add GET /api/accounts/keys. - Return errors instead of panicking on malformed event dates, cipher data, key rotation cipher ids, bulk confirm entries and YubiKey OTPs.
361 lines
12 KiB
Rust
361 lines
12 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: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
|
|
if !CONFIG.org_events_enabled() {
|
|
return;
|
|
}
|
|
log_user_event_impl(event_type, 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() {
|
|
debug!("events_days_retain is not configured, abort");
|
|
return;
|
|
}
|
|
|
|
if let Ok(conn) = pool.get().await {
|
|
Event::clean_events(&conn).await.ok();
|
|
} else {
|
|
error!("Failed to get DB connection while trying to cleanup the events table");
|
|
}
|
|
}
|