Align API behaviour with upstream and return errors on malformed input (#7810)

- 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.
This commit is contained in:
Daniel García authored and GitHub committed 2026-10-05 14:38:01 +02:00
1 parent 1f99d9d276
commit 895ec2b1e7
24 files changed
+381 -154

No files matched your search

+42 -3
View File
@@ -42,6 +42,7 @@ pub fn routes() -> Vec<rocket::Route> {
post_profile,
put_avatar,
get_public_keys,
get_keys,
post_keys,
post_password,
post_set_password,
@@ -568,6 +569,24 @@ async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> Js
})))
}
#[get("/accounts/keys")]
fn get_keys(headers: Headers) -> JsonResult {
let user = headers.user;
// The SDK reads a 404 as the user having no key pair yet
if user.private_key.is_none() || user.public_key.is_none() {
err_code!("User has no key pair", Status::NotFound.code)
}
Ok(Json(json!({
"key": (!user.akey.is_empty()).then_some(&user.akey),
"publicKey": user.public_key,
"privateKey": user.private_key,
"accountKeys": user.account_keys_json(),
"object": "keys",
})))
}
#[post("/accounts/keys", data = "<data>")]
async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: KeysData = data.into_inner();
@@ -994,7 +1013,8 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// Update cipher data
for cipher_data in data.account_data.ciphers {
if cipher_data.organization_id.is_none() {
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
let Some(saved_cipher) =
cipher_data.id.as_ref().and_then(|id| existing_ciphers.iter_mut().find(|c| &c.uuid == id))
else {
err!("Cipher doesn't exist")
};
@@ -1360,14 +1380,33 @@ pub struct PreloginData {
email: String,
}
/// Like upstream, an unknown email gets KDF settings picked from a list of common ones by a keyed hash of the email,
/// so they stay the same between requests. The salt stays null, as for existing users.
fn unknown_email_kdf(email: &str) -> (i32, i32, Option<i32>, Option<i32>) {
const PBKDF2: i32 = UserKdfType::Pbkdf2 as i32;
const ARGON2ID: i32 = UserKdfType::Argon2id as i32;
// Same list as upstream, the default is in it twice to give it more weight
const KDF_SETTINGS: [(i32, i32, Option<i32>, Option<i32>); 6] = [
(User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
(User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
(PBKDF2, 100_000, None, None),
(PBKDF2, 5_000, None, None),
(ARGON2ID, 3, Some(64), Some(4)),
(ARGON2ID, 6, Some(32), Some(4)),
];
KDF_SETTINGS[crate::auth::prelogin_kdf_index(email, KDF_SETTINGS.len())]
}
pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: PreloginData = data.into_inner();
// Normalized once, so the lookup and the settings for an unknown email use the same value
let email = data.email.trim().to_lowercase();
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&email, &conn).await {
Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism),
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
None => unknown_email_kdf(&email),
};
Ok(Json(json!({
+14 -7
View File
@@ -29,7 +29,7 @@ use crate::{
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
};
use super::folders::FolderData;
use super::{folders::FolderData, sends::SIZE_525_MB};
pub fn routes() -> Vec<Route> {
// Note that many routes have an `admin` variant; this seems to be
@@ -441,10 +441,10 @@ pub async fn update_cipher_from_data(
// We do not mind which data is in it, the keep our model more flexible when there are upstream changes.
// But, we at least know we do not need to store and return this specific key.
fn clean_cipher_data(mut json_data: Value) -> Value {
if json_data.is_array() {
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| {
f.as_object_mut().unwrap().remove("response");
});
if let Some(items) = json_data.as_array_mut() {
for item in items.iter_mut().filter_map(Value::as_object_mut) {
item.remove("response");
}
}
json_data
}
@@ -486,9 +486,10 @@ pub async fn update_cipher_from_data(
Some(member) => {
// A non-empty list of collections implies the caller already validated the user's write
// access to them, so we can move the cipher into the organization on that basis.
// Write access to the cipher itself only counts when it already belongs to this organization.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|| member.has_full_access()
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
|| (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await)
{
cipher.organization_uuid = Some(org_id);
// After some discussion in PR #1329 re-added the user_uuid = None again.
@@ -552,7 +553,9 @@ pub async fn update_cipher_from_data(
let type_data = if let Some(mut data) = type_data_opt {
// Remove the 'Response' key from the base object.
data.as_object_mut().unwrap().remove("response");
if let Some(data_obj) = data.as_object_mut() {
data_obj.remove("response");
}
// Remove the 'Response' key from every Uri.
if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone());
@@ -1182,6 +1185,10 @@ async fn post_attachment_v2(
if file_size < 0 {
err!("Attachment size can't be negative")
}
// Matches the "file" upload limit in main.rs, nothing larger can be uploaded
if file_size > SIZE_525_MB {
err!("Max file size is 525 MB.")
}
let attachment_id = crypto::generate_attachment_id();
let attachment =
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
+1 -1
View File
@@ -225,7 +225,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("Grantee user does not exist: {email}"))
err!(format!("Grantee user does not exist: {}", email.escape_debug()))
}
if !CONFIG.is_email_domain_allowed(&email) {
+20 -23
View File
@@ -6,12 +6,13 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{EmptyResult, JsonResult},
api::{ApiResult, EmptyResult, JsonResult},
auth::{AdminHeaders, Headers},
db::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
},
error::MapResult,
util::parse_date,
};
@@ -29,6 +30,14 @@ struct EventRange {
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 {
@@ -39,12 +48,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
// 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 = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = data.date_range()?;
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
.await
@@ -69,12 +73,7 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade
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 = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
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 {
@@ -102,12 +101,7 @@ async fn get_user_events(
// 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 = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = data.date_range()?;
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
.await
@@ -168,9 +162,10 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
}
for event in data.iter() {
let event_date = parse_date(&event.date);
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 {
1000..=1099 => {
t if t == EventType::UserClientExportedVault as i32 => {
log_user_event_impl(
event.r#type,
&headers.user.uuid,
@@ -181,7 +176,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
)
.await;
}
1600..=1699 => {
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()
@@ -219,7 +214,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.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
@@ -240,6 +236,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
_ => {}
}
}
Ok(())
+5 -3
View File
@@ -1028,7 +1028,7 @@ async fn send_invite(
let user = match User::find_by_mail(email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("User does not exist: {email}"))
err!(format!("User does not exist: {}", email.escape_debug()))
}
if !CONFIG.is_email_domain_allowed(email) {
@@ -1046,7 +1046,7 @@ async fn send_invite(
}
Some(user) => {
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() {
err!(format!("User already in organization: {email}"))
err!(format!("User already in organization: {}", email.escape_debug()))
}
if !CONFIG.mail_enabled() {
@@ -1321,7 +1321,9 @@ async fn bulk_confirm_invite(
match data.keys {
Some(keys) => {
for invite in keys {
let member_id = invite.id.unwrap();
let Some(member_id) = invite.id else {
err!("Member id is required")
};
let user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
Ok(()) => String::new(),
+4 -22
View File
@@ -58,25 +58,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let org_id = token.0;
let data = data.into_inner();
// Like upstream, the import never removes Owners, so it doesn't revoke or restore them either.
for user_data in &data.members {
let mut user_created: bool = false;
if user_data.deleted {
// If user is marked for deletion and it exists, revoke it
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
// Only revoke a user if it is not the last confirmed owner
let revoked = if member.atype == MembershipType::Owner
&& member.status == MembershipStatus::Confirmed as i32
{
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't revoke the last owner");
false
} else {
member.revoke()
}
} else {
member.revoke()
};
let revoked = member.atype != MembershipType::Owner && member.revoke();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
if revoked || ext_modified {
member.save(&conn).await?;
@@ -84,7 +72,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
}
// If user is part of the organization, restore it
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
let mut restored = member.restore();
let mut restored = member.atype != MembershipType::Owner && member.restore();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does.
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
@@ -180,14 +168,8 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
for member in Membership::find_by_org(&org_id, &conn).await {
if let Some(ref user_external_id) = member.external_id
&& !sync_members.contains(user_external_id)
&& member.atype != MembershipType::Owner
{
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
// Removing owner, check that there is at least one other confirmed owner
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't delete the last owner");
continue;
}
}
member.delete(&conn).await?;
}
}
+4 -4
View File
@@ -23,7 +23,7 @@ use crate::{
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
let dt = DateTime::UNIX_EPOCH.naive_utc();
Device {
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
created_at: dt,
@@ -39,7 +39,7 @@ static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
});
// The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues
const SIZE_525_MB: i64 = 550_502_400;
pub(crate) const SIZE_525_MB: i64 = 550_502_400;
pub fn routes() -> Vec<rocket::Route> {
routes![
@@ -328,8 +328,8 @@ async fn post_send_file_v2_data(
"Send file name does not match.",
format!(
"Expected file name '{}' got '{}'",
send_data.fileName,
raw_file_name.dangerous_unsafe_unsanitized_raw()
send_data.fileName.escape_debug(),
raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug()
)
),
_ => err!("Send file name does not match or is not provided."),
+3 -1
View File
@@ -217,7 +217,9 @@ pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> Emp
}
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata");
let response_id = &response[..12];
let Some(response_id) = response.get(..12) else {
err!("Invalid Yubikey OTP");
};
if !yubikey_metadata.keys.contains(&response_id.to_owned()) {
err!("Given Yubikey is not registered");