mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-14 17:36:05 +02:00
List attachments in sync with a stable URL
Sync and the cipher responses list each attachment with an unsigned URL of upstream's form, which no longer changes on every sync nor costs a presign per attachment on S3, and which isn't served without a token. The attachment routes sign it: the cipher route and its admin variant, and the emergency access view's route the web vault downloads through, which is added. The admin variant also signs it for a member who can open the item themselves, because the web vault's fallback to the listed URL no longer works.
This commit is contained in:
4 files changed
+75
-13
No files matched your search
@@ -1565,15 +1565,16 @@ async fn get_attachment_data(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if scope == CipherAccessScope::OrganizationAdmin {
|
if scope == CipherAccessScope::OrganizationAdmin {
|
||||||
// NotFound as in Bitwarden, the web vault then falls back to the attachment's own URL
|
// NotFound as in Bitwarden. Its web vault then falls back to the attachment's URL from the listing, which isn't
|
||||||
|
// served here without a token, so a member who can open the item themselves gets the signed URL instead.
|
||||||
let Some(org_id) = cipher.organization_uuid.as_ref() else {
|
let Some(org_id) = cipher.organization_uuid.as_ref() else {
|
||||||
err_code!("Cipher is not owned by an organization", Status::NotFound.code)
|
err_code!("Cipher is not owned by an organization", Status::NotFound.code)
|
||||||
};
|
};
|
||||||
if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn)
|
let is_admin = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn)
|
||||||
.await
|
.await
|
||||||
.is_some_and(|member| member.can_edit_any_collection())
|
.is_some_and(|member| member.can_edit_any_collection())
|
||||||
|| !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await
|
&& cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await;
|
||||||
{
|
if !is_admin && !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await {
|
||||||
err_code!("Cipher is not accessible", Status::NotFound.code)
|
err_code!("Cipher is not accessible", Status::NotFound.code)
|
||||||
}
|
}
|
||||||
} else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await {
|
} else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await {
|
||||||
@@ -1581,7 +1582,9 @@ async fn get_attachment_data(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match Attachment::find_by_id(&attachment_id, &conn).await {
|
match Attachment::find_by_id(&attachment_id, &conn).await {
|
||||||
Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)),
|
Some(attachment) if cipher_id == attachment.cipher_uuid => {
|
||||||
|
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
|
||||||
|
}
|
||||||
Some(_) => err!("Attachment doesn't belong to cipher"),
|
Some(_) => err!("Attachment doesn't belong to cipher"),
|
||||||
None => err!("Attachment doesn't exist"),
|
None => err!("Attachment doesn't exist"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
|
Attachment, AttachmentId, Cipher, CipherId, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess,
|
||||||
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType,
|
||||||
|
OrgPolicy, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -42,6 +43,7 @@ pub fn routes() -> Vec<Route> {
|
|||||||
takeover_emergency_access,
|
takeover_emergency_access,
|
||||||
password_emergency_access,
|
password_emergency_access,
|
||||||
view_emergency_access,
|
view_emergency_access,
|
||||||
|
get_emergency_access_attachment,
|
||||||
policies_emergency_access,
|
policies_emergency_access,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -616,6 +618,46 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An attachment of the grantor's for a grantee with approved view access, with a `url` to download it from: the view
|
||||||
|
/// lists the attachments with one that only names the file. Only the grantor's own ciphers, the ones the view lists.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Auth/Controllers/EmergencyAccessController.cs#L219-L226>
|
||||||
|
#[get("/emergency-access/<emer_id>/<cipher_id>/attachment/<attachment_id>")]
|
||||||
|
async fn get_emergency_access_attachment(
|
||||||
|
emer_id: EmergencyAccessId,
|
||||||
|
cipher_id: CipherId,
|
||||||
|
attachment_id: AttachmentId,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
|
let Some(emergency_access) =
|
||||||
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
|
else {
|
||||||
|
err!("Emergency access not valid.")
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) {
|
||||||
|
err!("Emergency access not valid.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||||
|
err!("Cipher doesn't exist")
|
||||||
|
};
|
||||||
|
if cipher.user_uuid.as_ref() != Some(&emergency_access.grantor_uuid) || cipher.organization_uuid.is_some() {
|
||||||
|
err!("Cipher is not accessible")
|
||||||
|
}
|
||||||
|
|
||||||
|
match Attachment::find_by_id(&attachment_id, &conn).await {
|
||||||
|
Some(attachment) if attachment.cipher_uuid == cipher_id => {
|
||||||
|
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
|
||||||
|
}
|
||||||
|
Some(_) => err!("Attachment doesn't belong to cipher"),
|
||||||
|
None => err!("Attachment doesn't exist"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/takeover")]
|
#[post("/emergency-access/<emer_id>/takeover")]
|
||||||
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|||||||
@@ -65,16 +65,33 @@ impl Attachment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn to_json(&self, host: &str) -> Result<Value, crate::Error> {
|
/// The attachment as sync and the cipher responses list it. Its `url` is a leftover from the old download flow.
|
||||||
Ok(json!({
|
/// Clients get a signed URL from the attachment routes (`to_json_for_download`) instead, only the web vault and the
|
||||||
|
/// CLI read this one, as a fallback when those routes answer 404.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Models/Response/AttachmentResponseModel.cs#L24-L29>
|
||||||
|
pub fn to_json(&self, host: &str) -> Value {
|
||||||
|
self.json_with_url(&format!("{host}/attachments/{}/{}", self.cipher_uuid, self.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attachment with a `url` the client can download it from for a short while, for the attachment routes
|
||||||
|
/// (`GET /ciphers/{id}/attachment/{attachmentId}`, its admin variant, the emergency access one).
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Controllers/CiphersController.cs#L1699-L1717>
|
||||||
|
pub async fn to_json_for_download(&self, host: &str) -> Result<Value, crate::Error> {
|
||||||
|
Ok(self.json_with_url(&self.get_url(host).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_with_url(&self, url: &str) -> Value {
|
||||||
|
json!({
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"url": self.get_url(host).await?,
|
"url": url,
|
||||||
"fileName": self.file_name,
|
"fileName": self.file_name,
|
||||||
"size": self.file_size.to_string(),
|
"size": self.file_size.to_string(),
|
||||||
"sizeName": crate::util::get_display_size(self.file_size),
|
"sizeName": crate::util::get_display_size(self.file_size),
|
||||||
"key": self.akey,
|
"key": self.akey,
|
||||||
"object": "attachment"
|
"object": "attachment"
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ impl Cipher {
|
|||||||
{
|
{
|
||||||
let mut attachments_json_vec = vec![];
|
let mut attachments_json_vec = vec![];
|
||||||
for attachment in attachments {
|
for attachment in attachments {
|
||||||
attachments_json_vec.push(attachment.to_json(host).await?);
|
attachments_json_vec.push(attachment.to_json(host));
|
||||||
}
|
}
|
||||||
attachments_json = Value::Array(attachments_json_vec);
|
attachments_json = Value::Array(attachments_json_vec);
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ impl Cipher {
|
|||||||
if !attachments.is_empty() {
|
if !attachments.is_empty() {
|
||||||
let mut attachments_json_vec = vec![];
|
let mut attachments_json_vec = vec![];
|
||||||
for attachment in attachments {
|
for attachment in attachments {
|
||||||
attachments_json_vec.push(attachment.to_json(host).await?);
|
attachments_json_vec.push(attachment.to_json(host));
|
||||||
}
|
}
|
||||||
attachments_json = Value::Array(attachments_json_vec);
|
attachments_json = Value::Array(attachments_json_vec);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user