diff --git a/Cargo.lock b/Cargo.lock index d77755e9..d7fa8c4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,9 +490,9 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" [[package]] name = "cc" -version = "1.2.23" +version = "1.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" +checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" dependencies = [ "shlex", ] @@ -1640,11 +1640,10 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d" dependencies = [ - "futures-util", "http 1.3.1", "hyper 1.6.0", "hyper-util", @@ -1673,9 +1672,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" dependencies = [ "bytes", "futures-channel", @@ -3282,9 +3281,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" [[package]] name = "ryu" @@ -4145,11 +4144,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 119ecd1e..85f331a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +95,7 @@ ring = "0.17.14" subtle = "2.6.1" # UUID generation -uuid = { version = "1.16.0", features = ["v4"] } +uuid = { version = "1.17.0", features = ["v4"] } # Date and time libraries chrono = { version = "0.4.41", features = ["clock", "serde"], default-features = false } diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 216bcfa1..bc2b81d9 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -697,6 +697,9 @@ async fn _delete_organization_collection( headers: &ManagerHeaders, conn: &mut DbConn, ) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } let Some(collection) = Collection::find_by_uuid_and_org(col_id, org_id, conn).await else { err!("Collection not found", "Collection does not exist or does not belong to this organization") }; @@ -909,7 +912,7 @@ struct OrgIdData { #[get("/ciphers/organization-details?")] async fn get_org_details(data: OrgIdData, headers: OrgMemberHeaders, mut conn: DbConn) -> JsonResult { - if data.organization_id != headers.org_id { + if data.organization_id != headers.membership.org_uuid { err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); } @@ -1196,6 +1199,9 @@ async fn reinvite_member( headers: AdminHeaders, mut conn: DbConn, ) -> EmptyResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } _reinvite_member(&org_id, &member_id, &headers.user.email, &mut conn).await } @@ -1413,6 +1419,9 @@ async fn _confirm_invite( conn: &mut DbConn, nt: &Notify<'_>, ) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } if key.is_empty() || member_id.is_empty() { err!("Key or UserId is not set, unable to process request"); } @@ -1735,6 +1744,9 @@ async fn _delete_member( conn: &mut DbConn, nt: &Notify<'_>, ) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } let Some(member_to_delete) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("User to delete isn't member of the organization") }; @@ -1829,16 +1841,20 @@ struct RelationsData { value: usize, } +// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/ImportCiphersController.cs#L62 #[post("/ciphers/import-organization?", data = "")] async fn post_org_import( query: OrgIdData, data: Json, - headers: AdminHeaders, + headers: OrgMemberHeaders, mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - let data: ImportData = data.into_inner(); let org_id = query.organization_id; + if org_id != headers.membership.org_uuid { + err!("Organization not found", "Organization id's do not match"); + } + let data: ImportData = data.into_inner(); // Validate the import before continuing // Bitwarden does not process the import if there is one item invalid. @@ -1851,8 +1867,20 @@ async fn post_org_import( let mut collections: Vec = Vec::with_capacity(data.collections.len()); for col in data.collections { let collection_uuid = if existing_collections.contains(&col.id) { - col.id.unwrap() + let col_id = col.id.unwrap(); + // When not an Owner or Admin, check if the member is allowed to access the collection. + if headers.membership.atype < MembershipType::Admin + && !Collection::can_access_collection(&headers.membership, &col_id, &mut conn).await + { + err!(Small, "The current user isn't allowed to manage this collection") + } + col_id } else { + // We do not allow users or managers which can not manage all collections to create new collections + // If there is any collection other than an existing import collection, abort the import. + if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { + err!(Small, "The current user isn't allowed to create new collections") + } let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); new_collection.save(&mut conn).await?; new_collection.uuid @@ -1875,7 +1903,17 @@ async fn post_org_import( // Always clear folder_id's via an organization import cipher_data.folder_id = None; let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); - update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None).await.ok(); + update_cipher_from_data( + &mut cipher, + cipher_data, + &headers, + Some(collections.clone()), + &mut conn, + &nt, + UpdateType::None, + ) + .await + .ok(); ciphers.push(cipher.uuid); } @@ -2420,6 +2458,9 @@ async fn _revoke_member( headers: &AdminHeaders, conn: &mut DbConn, ) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { Some(mut member) if member.status > MembershipStatus::Revoked as i32 => { if member.user_uuid == headers.user.uuid { @@ -2527,6 +2568,9 @@ async fn _restore_member( headers: &AdminHeaders, conn: &mut DbConn, ) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { Some(mut member) if member.status < MembershipStatus::Accepted as i32 => { if member.user_uuid == headers.user.uuid { @@ -2679,6 +2723,9 @@ async fn post_groups( data: Json, mut conn: DbConn, ) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } @@ -2708,6 +2755,9 @@ async fn put_group( headers: AdminHeaders, mut conn: DbConn, ) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } @@ -2824,6 +2874,9 @@ async fn _delete_group( headers: &AdminHeaders, conn: &mut DbConn, ) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } @@ -2853,6 +2906,9 @@ async fn bulk_delete_groups( headers: AdminHeaders, mut conn: DbConn, ) -> EmptyResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } @@ -2916,6 +2972,9 @@ async fn put_group_members( data: Json>, mut conn: DbConn, ) -> EmptyResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } @@ -3100,7 +3159,7 @@ async fn get_organization_public_key( headers: OrgMemberHeaders, mut conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &mut conn).await else { @@ -3324,6 +3383,9 @@ async fn _api_key( headers: AdminHeaders, mut conn: DbConn, ) -> JsonResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; diff --git a/src/auth.rs b/src/auth.rs index 062db86c..c86c0a41 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -694,17 +694,6 @@ impl<'r> FromRequest<'r> for AdminHeaders { } } -impl From for Headers { - fn from(h: AdminHeaders) -> Headers { - Headers { - host: h.host, - device: h.device, - user: h.user, - ip: h.ip, - } - } -} - // col_id is usually the fourth path param ("/organizations//collections/"), // but there could be cases where it is a query value. // First check the path, if this is not a valid uuid, try the query values. @@ -874,8 +863,10 @@ impl<'r> FromRequest<'r> for OwnerHeaders { pub struct OrgMemberHeaders { pub host: String, + pub device: Device, pub user: User, - pub org_id: OrganizationId, + pub membership: Membership, + pub ip: ClientIp, } #[rocket::async_trait] @@ -887,8 +878,10 @@ impl<'r> FromRequest<'r> for OrgMemberHeaders { if headers.is_member() { Outcome::Success(Self { host: headers.host, + device: headers.device, user: headers.user, - org_id: headers.membership.org_uuid, + membership: headers.membership, + ip: headers.ip, }) } else { err_handler!("You need to be a Member of the Organization to call this endpoint") @@ -896,6 +889,17 @@ impl<'r> FromRequest<'r> for OrgMemberHeaders { } } +impl From for Headers { + fn from(h: OrgMemberHeaders) -> Headers { + Headers { + host: h.host, + device: h.device, + user: h.user, + ip: h.ip, + } + } +} + // // Client IP address detection // diff --git a/src/error.rs b/src/error.rs index 754bece3..c215b8ff 100644 --- a/src/error.rs +++ b/src/error.rs @@ -59,6 +59,8 @@ use yubico::yubicoerror::YubicoError as YubiErr; #[derive(Serialize)] pub struct Empty {} +pub struct Small {} + // Error struct // Contains a String error message, meant for the user and an enum variant, with an error of different types. // @@ -69,6 +71,7 @@ make_error! { Empty(Empty): _no_source, _serialize, // Used to represent err! calls Simple(String): _no_source, _api_error, + Small(Small): _no_source, _api_error_small, // Used in our custom http client to handle non-global IPs and blocked domains CustomHttpClient(CustomHttpClientError): _has_source, _api_error, @@ -132,6 +135,12 @@ impl Error { self } + #[must_use] + pub fn with_kind(mut self, kind: ErrorKind) -> Self { + self.error = kind; + self + } + #[must_use] pub const fn with_code(mut self, code: u16) -> Self { self.error_code = code; @@ -200,6 +209,18 @@ fn _api_error(_: &impl std::any::Any, msg: &str) -> String { _serialize(&json, "") } +fn _api_error_small(_: &impl std::any::Any, msg: &str) -> String { + let json = json!({ + "message": msg, + "validationErrors": null, + "exceptionMessage": null, + "exceptionStackTrace": null, + "innerExceptionMessage": null, + "object": "error" + }); + _serialize(&json, "") +} + // // Rocket responder impl // @@ -212,8 +233,7 @@ use rocket::response::{self, Responder, Response}; impl Responder<'_, 'static> for Error { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { match self.error { - ErrorKind::Empty(_) => {} // Don't print the error in this situation - ErrorKind::Simple(_) => {} // Don't print the error in this situation + ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Small(_) => {} // Don't print the error in this situation _ => error!(target: "error", "{self:#?}"), }; @@ -228,6 +248,10 @@ impl Responder<'_, 'static> for Error { // #[macro_export] macro_rules! err { + ($kind:ident, $msg:expr) => {{ + error!("{}", $msg); + return Err($crate::error::Error::new($msg, $msg).with_kind($crate::error::ErrorKind::$kind($crate::error::$kind {}))); + }}; ($msg:expr) => {{ error!("{}", $msg); return Err($crate::error::Error::new($msg, $msg));