Browse Source

Send API cleanup: remove legacy endpoints and align access with upstream (#7806)

pull/6833/merge
Daniel García 4 days ago
committed by GitHub
parent
commit
f4f1a8e105
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      src/api/core/accounts.rs
  2. 192
      src/api/core/sends.rs
  3. 6
      src/auth/send.rs
  4. 1
      src/db/models/cipher.rs
  5. 30
      src/db/models/send.rs

8
src/api/core/accounts.rs

@ -32,7 +32,7 @@ use crate::{
use super::{ use super::{
ciphers::{CipherData, update_cipher_from_data}, ciphers::{CipherData, update_cipher_from_data},
sends::{SendData, update_send_from_data}, sends::SendData,
}; };
pub fn routes() -> Vec<rocket::Route> { pub fn routes() -> Vec<rocket::Route> {
@ -982,11 +982,13 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// Update send data // Update send data
for send_data in data.account_data.sends { for send_data in data.account_data.sends {
let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) else {
err!("Send doesn't exist") err!("Send doesn't exist")
}; };
update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?; // Like upstream, only the key changes on rotation
send.akey = send_data.key;
send.save(&conn).await?;
} }
// Update cipher data // Update cipher data

192
src/api/core/sends.rs

@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host, SendHeaders}, auth::{Headers, Host, SendHeaders},
config::PathType, config::PathType,
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
@ -46,11 +46,8 @@ pub fn routes() -> Vec<rocket::Route> {
get_sends, get_sends,
get_send, get_send,
post_send, post_send,
post_send_file,
post_access, post_access,
post_access_legacy,
post_access_file, post_access_file,
post_access_file_legacy,
put_send, put_send,
delete_send, delete_send,
put_remove_password, put_remove_password,
@ -73,7 +70,7 @@ pub async fn purge_sends(pool: DbPool) {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SendData { pub struct SendData {
r#type: i32, r#type: i32,
key: String, pub key: String,
password: Option<String>, password: Option<String>,
max_access_count: Option<NumberOrString>, max_access_count: Option<NumberOrString>,
expiration_date: Option<DateTime<Utc>>, expiration_date: Option<DateTime<Utc>>,
@ -201,7 +198,7 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
enforce_disable_hide_email_policy(&data, &headers, &conn).await?; enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
if data.r#type == SendType::File as i32 { if data.r#type == SendType::File as i32 {
err!("File sends should use /api/sends/file") err!("File sends should use /api/sends/file/v2")
} }
let mut send = create_send(data, headers.user.uuid)?; let mut send = create_send(data, headers.user.uuid)?;
@ -218,93 +215,11 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
} }
#[derive(FromForm)]
struct UploadData<'f> {
model: Json<SendData>,
data: TempFile<'f>,
}
#[derive(FromForm)] #[derive(FromForm)]
struct UploadDataV2<'f> { struct UploadDataV2<'f> {
data: TempFile<'f>, data: TempFile<'f>,
} }
// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2).
// This method still exists to support older clients, probably need to remove it sometime.
// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167
// 2025: This endpoint doesn't seem to exists anymore in the latest version
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
enforce_disable_send_policy(&headers, &conn).await?;
let UploadData {
model,
data,
} = data.into_inner();
let model = model.into_inner();
let Some(size) = data.len().to_i64() else {
err!("Invalid send size");
};
if size < 0 {
err!("Send size can't be negative")
}
enforce_disable_hide_email_policy(&model, &headers, &conn).await?;
let size_limit = match CONFIG.user_send_limit() {
Some(0) => err!("File uploads are disabled"),
Some(limit_kb) => {
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else {
err!("Existing sends overflow")
};
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
err!("Send size overflow");
};
if left <= 0 {
err!("Send storage limit reached! Delete some sends to free up space")
}
i64::clamp(left, 0, SIZE_525_MB)
}
None => SIZE_525_MB,
};
if size > size_limit {
err!("Send storage limit exceeded with this file");
}
let mut send = create_send(model, headers.user.uuid)?;
if send.atype != SendType::File as i32 {
err!("Send content is not a file");
}
let file_id = crate::crypto::generate_send_file_id();
save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() {
o.insert(String::from("id"), Value::String(file_id));
o.insert(String::from("size"), Value::Number(size.into()));
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size)));
}
send.data = serde_json::to_string(&data_value)?;
// Save the changes in the database
send.save(&conn).await?;
nt.send_send_update(
UpdateType::SyncSendCreate,
&send,
&send.update_users_revision(&conn).await,
&headers.device,
&conn,
)
.await;
Ok(Json(send.to_json()))
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165 // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
#[post("/sends/file/v2", data = "<data>")] #[post("/sends/file/v2", data = "<data>")]
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult { async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult {
@ -450,63 +365,16 @@ async fn post_send_file_v2_data(
#[post("/sends/access")] #[post("/sends/access")]
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() { if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
process_access(send, conn, nt).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendAccessData {
pub password: Option<String>,
}
// Legacy since web-2026.6.0
#[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access_legacy(
access_id: &str,
data: Json<SendAccessData>,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
err_code!(SEND_INACCESSIBLE_MSG, 404);
}
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)),
None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401),
}
}
// Files are incremented during the download // Files are incremented during the download
if send.atype == SendType::Text as i32 { if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? {
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
} else {
send.save(&conn).await?;
}
process_access(send, conn, nt).await process_access(send, conn, nt).await
} }
@ -531,55 +399,27 @@ async fn post_access_file(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() { if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
process_access_file(send, file_id, host, conn, nt).await check_send_file_id(&send, &file_id)?;
} if !send.register_access(&conn).await? {
// Legacy since web-2026.6.0
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy(
send_id: SendId,
file_id: SendFileId,
data: Json<SendAccessData>,
host: Host,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
process_access_file(send, file_id, host, conn, nt).await
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
if send.password_hash.is_some() { fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult {
match data.into_inner().password { if send.atype != SendType::File as i32 {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } err!("Send is not a file type send.");
Some(_) => err!("Invalid password."),
None => err_code!("Password not provided", 401),
}
} }
match serde_json::from_str::<SendFileData>(&send.data) {
if !send.register_access(&conn).await? { Ok(data) if &data.id == file_id => Ok(()),
err_code!(SEND_INACCESSIBLE_MSG, 404) _ => err_code!(SEND_INACCESSIBLE_MSG, 404),
} }
process_access_file(send, file_id, host, conn, nt).await
} }
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
@ -642,7 +482,7 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
} }
pub async fn update_send_from_data( async fn update_send_from_data(
send: &mut Send, send: &mut Send,
data: SendData, data: SendData,
headers: &Headers, headers: &Headers,

6
src/auth/send.rs

@ -75,7 +75,7 @@ impl SendTokens {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
}; };
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else { let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
}; };
@ -103,10 +103,6 @@ impl SendTokens {
} }
} }
if !send.register_access(conn).await? {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
Ok(Self { Ok(Self {
access_claims: generate_send_access_claims(&send_id), access_claims: generate_send_access_claims(&send_id),
}) })

1
src/db/models/cipher.rs

@ -711,6 +711,7 @@ impl Cipher {
// Only allow group access via a confirmed membership, since // Only allow group access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked. // groups_users rows are kept when a membership is revoked.
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage)) .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn) .load::<(bool, bool, bool)>(conn)
.expect("Error getting group access restrictions") .expect("Error getting group access restrictions")

30
src/db/models/send.rs

@ -140,6 +140,10 @@ impl Send {
None None
} }
fn access_id(&self) -> String {
BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes())
}
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
let mut data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data).unwrap_or_default(); let mut data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data).unwrap_or_default();
@ -150,7 +154,7 @@ impl Send {
json!({ json!({
"id": self.uuid, "id": self.uuid,
"accessId": BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()), "accessId": self.access_id(),
"type": self.atype, "type": self.atype,
"name": self.name, "name": self.name,
@ -182,7 +186,7 @@ impl Send {
} }
json!({ json!({
"id": self.uuid, "id": self.access_id(),
"type": self.atype, "type": self.atype,
"name": self.name, "name": self.name,
@ -256,7 +260,7 @@ impl Send {
/// Whether the Send is currently within its validity window: not disabled, not past its /// Whether the Send is currently within its validity window: not disabled, not past its
/// expiration date, and not past its deletion date. Does not consider `max_access_count` /// expiration date, and not past its deletion date. Does not consider `max_access_count`
/// (consumed at token issuance) or the password. /// (counted on each access) or the password.
pub fn is_accessible(&self) -> bool { pub fn is_accessible(&self) -> bool {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
if self.disabled { if self.disabled {
@ -309,19 +313,6 @@ impl Send {
Ok(()) Ok(())
} }
pub async fn find_by_access_id(access_id: &str, conn: &DbConn) -> Option<Self> {
let Ok(uuid_vec) = BASE64URL_NOPAD.decode(access_id.as_bytes()) else {
return None;
};
let uuid = match Uuid::from_slice(&uuid_vec) {
Ok(u) => SendId::from(u.to_string()),
Err(_) => return None,
};
Self::find_by_uuid(&uuid, conn).await
}
pub async fn find_by_uuid(uuid: &SendId, conn: &DbConn) -> Option<Self> { pub async fn find_by_uuid(uuid: &SendId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| sends::table.filter(sends::uuid.eq(uuid)).first::<Self>(conn).ok()).await conn.run(move |conn| sends::table.filter(sends::uuid.eq(uuid)).first::<Self>(conn).ok()).await
} }
@ -361,13 +352,6 @@ impl Send {
Some(total) Some(total)
} }
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
sends::table.filter(sends::organization_uuid.eq(org_uuid)).load::<Self>(conn).expect("Error loading sends")
})
.await
}
pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec<Self> { pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec<Self> {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
conn.run(move |conn| { conn.run(move |conn| {

Loading…
Cancel
Save