diff --git a/playwright/tests/send.spec.ts b/playwright/tests/send.spec.ts new file mode 100644 index 00000000..d27c3ffc --- /dev/null +++ b/playwright/tests/send.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; +import * as OTPAuth from "otpauth"; + +import * as utils from "../global-utils"; +import { createAccount } from './setups/user'; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('Send', async ({ browser, page }) => { + await createAccount(test, page, users.user1); + + const send_url = await test.step('Create', async () => { + await page.getByRole('link', { name: 'Send' }).click(); + await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Text' }).click(); + + await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test'); + await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test'); + await page.getByRole('button', { name: 'Save' }).click(); + + await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); + + return await page.evaluate(() => navigator.clipboard.readText()); + }); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await test.step('View', async () => { + await page2.goto(send_url, { waitUntil: 'domcontentloaded' }); + await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); + await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible(); + }); + + const pwd_url = await test.step('Create with password', async () => { + await page.getByRole('link', { name: 'Send' }).click(); + await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Text' }).click(); + + await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password'); + await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password'); + await page.getByRole('combobox', { name: 'Who can view' }).click(); + await page.getByText('Anyone with a password set by you').click(); + await page.getByRole('textbox', { name: 'Password (required)' }).fill('password'); + + await page.getByRole('button', { name: 'Save' }).click(); + await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); + + return await page.evaluate(() => navigator.clipboard.readText()); + }); + + await test.step('View with password', async () => { + await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' }); + await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible(); + await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password'); + await page2.getByRole('button', { name: 'Continue' }).click(); + await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); + await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible(); + }); +}); diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 2a7e06c1..fb3ee48f 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, - auth::{ClientIp, Headers, Host}, + auth::{ClientIp, Headers, Host, SendHeaders}, config::PathType, db::{ DbConn, DbPool, @@ -48,7 +48,9 @@ pub fn routes() -> Vec { post_send, post_send_file, post_access, + post_access_legacy, post_access_file, + post_access_file_legacy, put_send, delete_send, put_remove_password, @@ -78,6 +80,7 @@ pub struct SendData { deletion_date: DateTime, disabled: bool, hide_email: Option, + emails: Option, // Data field name: String, @@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { ); } + if data.emails.is_some() { + err!("Sends with email verification is not supported"); + } + let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); send.user_uuid = Some(user_id); send.notes = data.notes; @@ -371,7 +378,7 @@ pub struct SendFileData { } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195 -#[post("/sends//file/", format = "multipart/form-data", data = "")] +#[post("/sends//file/", format = "multipart/form-data", data = "", rank = 2)] async fn post_send_file_v2_data( send_id: SendId, file_id: SendFileId, @@ -441,14 +448,23 @@ async fn post_send_file_v2_data( Ok(()) } +#[post("/sends/access")] +async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult { + let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + err_code!(SEND_INACCESSIBLE_MSG, 404) + }; + process_access(send, conn, nt).await +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendAccessData { pub password: Option, } +// Legacy since web-2026.6.0 #[post("/sends/access/", data = "")] -async fn post_access( +async fn post_access_legacy( access_id: &str, data: Json, conn: DbConn, @@ -494,6 +510,10 @@ async fn post_access( send.save(&conn).await?; + process_access(send, conn, nt).await +} + +async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult { nt.send_send_update( UpdateType::SyncSendUpdate, &send, @@ -506,8 +526,23 @@ async fn post_access( Ok(Json(send.to_json_access(&conn).await)) } -#[post("/sends//access/file/", data = "")] +#[post("/sends/access/file/", rank = 1)] async fn post_access_file( + file_id: SendFileId, + headers: SendHeaders, + host: Host, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + err_code!(SEND_INACCESSIBLE_MSG, 404) + }; + process_access_file(send, file_id, host, conn, nt).await +} + +// Legacy since web-2026.6.0 +#[post("/sends//access/file/", data = "")] +async fn post_access_file_legacy( send_id: SendId, file_id: SendFileId, data: Json, @@ -551,6 +586,10 @@ async fn post_access_file( send.save(&conn).await?; + 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 { nt.send_send_update( UpdateType::SyncSendUpdate, &send, @@ -563,7 +602,7 @@ async fn post_access_file( Ok(Json(json!({ "object": "send-fileDownload", "id": file_id, - "url": download_url(&host, &send_id, &file_id).await?, + "url": download_url(&host, &send.uuid, &file_id).await?, }))) } @@ -601,6 +640,10 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: err!("Send not found", "Send send_id is invalid or does not belong to user") }; + if data.emails.is_some() { + err!("Sends with email verification is not supported"); + } + update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) diff --git a/src/api/identity.rs b/src/api/identity.rs index 3962827d..1597698f 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -31,8 +31,8 @@ use crate::{ DbConn, models::{ AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, - OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, - UserId, + OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, + TwoFactorType, User, UserId, }, }, error::MapResult, @@ -108,6 +108,19 @@ async fn login( sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await } "authorization_code" => err!("SSO sign-in is not available"), + "send_access" => { + check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; + check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?; + + let tokens = auth::SendTokens::generate_tokens( + data.send_id.as_ref().unwrap(), + data.password_hash_b64, + &client_header.ip, + &conn, + ) + .await?; + Ok(Json(tokens.to_json())) + } t => err!("Invalid type", t), }; @@ -1144,6 +1157,10 @@ struct ConnectData { code: Option, #[field(name = uncased("code_verifier"))] code_verifier: Option, + + // Needed for send access + send_id: Option, + password_hash_b64: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth.rs b/src/auth.rs index 2ad95036..88a59b4b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,8 @@ +#[path = "auth/send.rs"] +pub mod send; +pub type SendTokens = send::SendTokens; +pub type SendHeaders = send::SendHeaders; + use std::{ env, net::IpAddr, @@ -487,6 +492,16 @@ pub struct BasicJwtClaims { pub sub: String, } +impl BasicJwtClaims { + pub fn expires_in(&self) -> i64 { + self.exp - Utc::now().timestamp() + } + + pub fn token(&self) -> String { + encode_jwt(&self) + } +} + pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); diff --git a/src/auth/send.rs b/src/auth/send.rs new file mode 100644 index 00000000..84500b6a --- /dev/null +++ b/src/auth/send.rs @@ -0,0 +1,156 @@ +use chrono::{TimeDelta, Utc}; + +use rocket::request::{FromRequest, Outcome, Request}; + +use crate::{ + api::ApiResult, + auth, + auth::{BasicJwtClaims, ClientIp}, + db::{ + DbConn, + models::{Send, SendId}, + }, + error::{Error, ErrorKind}, +}; + +fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { + let time_now = Utc::now(); + BasicJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(), + iss: auth::JWT_SEND_ISSUER.to_string(), + sub: format!("{send_id}"), + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SendTokens { + pub access_claims: BasicJwtClaims, +} + +impl SendTokens { + pub fn as_send_id(access_id: &str) -> Option { + data_encoding::BASE64URL_NOPAD + .decode(access_id.as_bytes()) + .ok() + .and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string()))) + } + + pub fn to_json(&self) -> serde_json::Value { + json!({ + "access_token": self.access_claims.token(), + "expires_in": self.access_claims.expires_in(), + "token_type": "Bearer", + "scope": "api.send.access", + }) + } + + fn expected_error(msg: &str, error_type: &str) -> ApiResult { + let err = json!({ + "kind": "expected_server", + "error": "invalid_request", + "send_access_error_type": error_type, + }); + + Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent()) + } + + fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult { + let err = json!({ + "kind": "expected_server", + "error": "invalid_grant", + "send_access_error_type": error_type, + }); + + Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent)) + } + + pub async fn generate_tokens( + access_id: &str, + password: Option, + ip: &ClientIp, + conn: &DbConn, + ) -> ApiResult { + let Some(send_id) = Self::as_send_id(access_id) else { + 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 { + return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); + }; + + if let Some(max_access_count) = send.max_access_count + && send.access_count >= max_access_count + { + return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); + } + + if let Some(expiration) = send.expiration_date + && Utc::now().naive_utc() >= expiration + { + return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true); + } + + if Utc::now().naive_utc() >= send.deletion_date { + return Self::invalid_error(&format!("Send {send_id}, past deletion"), "send_id_invalid", true); + } + + if send.disabled { + return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true); + } + + if send.password_hash.is_some() { + match password { + Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } + Some(_) => { + return Self::invalid_error( + &format!("Send {send_id}, Invalid password from {}", ip.ip), + "password_hash_b64_invalid", + false, + ); + } + None => return Self::expected_error("Password required", "password_hash_b64_required"), + } + } + + send.access_count += 1; + send.save(conn).await?; + + Ok(Self { + access_claims: generate_send_access_claims(&send_id), + }) + } +} + +pub struct SendHeaders { + pub send_id: SendId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for SendHeaders { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = request.headers(); + + // Get access_token + let access_token: &str = if let Some(a) = headers.get_one("Authorization") { + if let Some(split) = a.rsplit("Bearer ").next() { + split + } else { + err_handler!("No access token provided") + } + } else { + err_handler!("No access token provided") + }; + + // Check JWT token is valid and get send_id + let Ok(claims) = auth::decode_send(access_token) else { + err_handler!("Invalid claim") + }; + + Outcome::Success(SendHeaders { + send_id: claims.sub.into(), + }) + } +} diff --git a/src/error.rs b/src/error.rs index d075dfe5..ecbc8199 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,14 +15,14 @@ macro_rules! make_error { #[derive(Debug)] pub struct ErrorEvent { pub event: EventType } - pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option } + pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option, silent: bool } $(impl From<$ty> for Error { fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) } })+ $(impl> From<(S, $ty)> for Error { fn from(val: (S, $ty)) -> Self { - Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None } + Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None, silent: false } } })+ impl StdError for Error { @@ -172,6 +172,18 @@ impl Error { pub fn message(&self) -> &str { &self.message } + + #[must_use] + pub fn silent(mut self) -> Self { + self.silent = true; + self + } + + #[must_use] + pub fn with_silent(mut self, silent: bool) -> Self { + self.silent = silent; + self + } } pub trait MapResult { @@ -309,9 +321,11 @@ use rocket::{ impl Responder<'_, 'static> for Error { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { - match self.kind { - ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation - _ => error!(target: "error", "{self:#?}"), + if !self.silent { + match self.kind { + ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation + _ => error!(target: "error", "{self:#?}"), + } } let code = Status::from_code(self.code).unwrap_or(Status::BadRequest);