Skip to main content

bw/tools/
receive.rs

1//! `bw receive` / `bw send receive` — access a Bitwarden Send from a url.
2//!
3//! This is the only Send flow that runs **without a logged-in user**: the send's content is
4//! decrypted with a key derived purely from the URL fragment
5//! ([`bitwarden_send::SendAccessKey`]), never from the account key store. Because the link can
6//! point at any deployment, the command builds its own [`PasswordManagerClient`] per invocation
7//! from URLs derived off the link itself (see [`resolve_urls`]) rather than reusing the session
8//! client — a self-hosted receive link must not have its password hash sent to Bitwarden cloud
9//! identity, and vice versa.
10//!
11//! Both `bw receive <url>` and `bw send receive <url>` are the same command; their arg structs
12//! are field-identical and both funnel into [`run_receive`].
13//!
14//! **PM-40120:** the legacy CLI's version of this bug mints against its configured environment
15//! but can leak the resulting real token to an attacker-controlled host on a follow-up fetch.
16//! The decided fix (2026-08-17) is that a Send access token must never be requested from
17//! anywhere but the server named in the Send link itself. [`resolve_urls`] already does this
18//! unconditionally for both the API and identity origin, so the mint and fetch here always
19//! target the same resolved host and a token can never cross the kind of domain boundary
20//! PM-40120 tracked. A link naming an attacker-controlled host still sends that host a password
21//! hash if the user proceeds — but per the decided fix, that's the token's only valid target,
22//! not a leak to a second, different host.
23
24use bitwarden_auth::send_access::{
25    SendAccessCredentials, SendAccessTokenError, SendAccessTokenRequest, SendAccessTokenResponse,
26    SendEmailCredentials, SendEmailOtpCredentials, SendPasswordCredentials,
27    api::{
28        SendAccessTokenApiErrorResponse, SendAccessTokenInvalidGrantError,
29        SendAccessTokenInvalidRequestError,
30    },
31};
32use bitwarden_core::get_host_platform_info;
33use bitwarden_pm::PasswordManagerClient;
34use bitwarden_send::{SendAccessKey, SendAccessView, SendType};
35use color_eyre::eyre::{Context as _, Result, bail, eyre};
36use inquire::{Password, Text, validator::Validation};
37use url::Url;
38
39use super::send_access_token_cache;
40use crate::{
41    platform::{ConfigFile, read_config_json},
42    render::{CommandOutput, CommandResult},
43    tools::file_output::{default_send_file_name, reject_path_traversal, save_file},
44};
45
46/// The Bitwarden cloud deployments whose Send links do not carry their API host.
47///
48/// `bw` has no region-metadata service (the same gap documented in the `web_vault_url` TODO in
49/// [`super::send`]), so the mapping is a small table here. Hosts are matched **exactly**, never
50/// by suffix: a suffix match would let `send.bitwarden.com.evil.tld` route a Send password hash
51/// to real Bitwarden cloud identity.
52const CLOUD_HOSTS: &[CloudRegion] = &[
53    CloudRegion {
54        hosts: &["send.bitwarden.com", "vault.bitwarden.com"],
55        api_url: "https://api.bitwarden.com",
56        identity_url: "https://identity.bitwarden.com",
57    },
58    CloudRegion {
59        hosts: &["send.bitwarden.eu", "vault.bitwarden.eu"],
60        api_url: "https://api.bitwarden.eu",
61        identity_url: "https://identity.bitwarden.eu",
62    },
63];
64
65struct CloudRegion {
66    hosts: &'static [&'static str],
67    api_url: &'static str,
68    identity_url: &'static str,
69}
70
71/// The flags `bw receive` and `bw send receive` share, normalized into one struct so the two
72/// entry points cannot drift apart.
73pub(crate) struct ReceiveInputs {
74    /// The Send url, including the `#`-fragment that carries the send id and key.
75    pub url: String,
76    /// `--password`
77    pub password: Option<String>,
78    /// `--passwordenv`
79    pub passwordenv: Option<String>,
80    /// `--passwordfile`
81    pub passwordfile: Option<String>,
82    /// `--output <path>`: where to save a file-type Send. Matches the flag name (and internal
83    /// field name, to avoid colliding with the top-level `Cli::output` render-format field)
84    /// already established by `bw send get --output` — legacy spells this the same way.
85    /// `--fullObject` (not legacy's boolean `--obj`) is the JSON-dump flag, matching the
86    /// existing convention on `bw send`/`bw send create`.
87    pub output_path: Option<String>,
88    /// `--fullObject`: dump the decrypted Send as JSON instead of emitting its content.
89    pub full_object: bool,
90}
91
92/// Entry point for [`super::ReceiveArgs`]'s `BwCommand` impl, reached both from the top-level
93/// `bw receive` command and from `SendCommands::Receive` (`bw send receive`), which reuses the
94/// same arg struct rather than a hand-synced copy.
95pub(crate) async fn run_receive(inputs: ReceiveInputs) -> CommandResult {
96    let url = Url::parse(&inputs.url).wrap_err("Failed to parse the provided Send url")?;
97    let (send_id, key_b64) = parse_send_url(&url)?;
98
99    // A structurally valid URL whose fragment key isn't a 16-byte url-safe-base64 blob is not a
100    // Send url; report it the same way as a missing fragment segment rather than leaking the
101    // crypto-layer error text (which would be about key lengths, not about the url).
102    let access_key = SendAccessKey::from_url_b64(&key_b64)
103        .map_err(|_| eyre!("Failed to parse url, the url provided is not a valid Send url"))?;
104
105    let (api_url, identity_url, trusted) =
106        resolve_urls(&url, read_config_json().ok().flatten().as_ref());
107    // The cache key is the resolved host, never a separately configured environment — see
108    // `send_access_token_cache`'s module doc for why that distinction matters. Only engaged for
109    // a trusted host at all; see `resolve_urls`'s doc for why untrusted hosts are excluded
110    // entirely rather than relying on the key alone.
111    let cache_host = format!("{api_url}|{identity_url}");
112    let client = PasswordManagerClient::new(Some(
113        get_host_platform_info().to_client_settings(api_url, identity_url),
114    ));
115
116    if trusted && let Some(token) = send_access_token_cache::get(&cache_host, &send_id) {
117        return match render_access(&client, &access_key, token, &inputs).await {
118            Ok(output) => Ok(output),
119            Err(err) => {
120                // The cache believed this token was still valid but the server disagreed
121                // (clock skew, or revocation before natural expiry) — don't leave a bad entry
122                // behind for the next invocation to hit the same way.
123                send_access_token_cache::evict(&cache_host, &send_id);
124                Err(err)
125            }
126        };
127    }
128
129    let token_response = attempt_access(&client, &send_id, &access_key, &inputs).await?;
130    if trusted {
131        send_access_token_cache::set(
132            &cache_host,
133            &send_id,
134            &token_response.token,
135            token_response.expires_at,
136        );
137    }
138    render_access(&client, &access_key, token_response.token, &inputs).await
139}
140
141/// Extract `(send_id, url_b64_key)` from the last two `#`-fragment segments.
142///
143/// Mirrors the legacy CLI's `getIdAndKey` (`url.hash.slice(1).split("/").slice(-2)`), which is
144/// why one parser handles both link shapes: the web-vault route
145/// (`https://vault.example.com/#/send/<id>/<key>`) and the cloud Send vanity host
146/// (`https://send.bitwarden.com/#<id>/<key>`) — only the trailing two segments matter.
147fn parse_send_url(url: &Url) -> Result<(String, String)> {
148    let fragment = url.fragment().unwrap_or_default();
149    let mut trailing = fragment.rsplit('/');
150    let key = trailing.next().unwrap_or_default();
151    let id = trailing.next().unwrap_or_default();
152
153    if id.trim().is_empty() || key.trim().is_empty() {
154        return Err(eyre!(
155            "Failed to parse url, the url provided is not a valid Send url"
156        ));
157    }
158
159    Ok((id.to_string(), key.to_string()))
160}
161
162/// Resolve the API and identity base URLs to talk to for a given Send link, plus whether that
163/// host is trusted.
164///
165/// Both URLs are needed: the send-access token is minted at `{identity}/connect/token` and the
166/// send itself is read from `{api}`. Precedence:
167///
168/// 1. A known Bitwarden cloud host (exact match — see [`CLOUD_HOSTS`]). Trusted.
169/// 2. The locally configured deployment (`bw config server`) when the link's origin matches it —
170///    explicit `api`/`identity` overrides win, otherwise the base is suffixed. Trusted.
171/// 3. Otherwise `<origin>/api` + `<origin>/identity`, the single-domain self-host convention and
172///    the legacy CLI's final fallback. Not trusted.
173///
174/// The trust flag gates the send-access-token cache ([`send_access_token_cache`]): only a
175/// trusted host's token gets persisted to disk at all, matching the corresponding TS client fix
176/// (which caches only for configured and trusted domains). This is a stricter rule than
177/// correctness alone requires — [`send_access_token_cache`]'s `(resolved_host, send_id)` key
178/// already prevents a token from being looked up under a different host than the one it was
179/// minted against — but it keeps this CLI from ever writing a persistent, on-disk credential for
180/// an arbitrary, untrusted Send-link host, not just from misusing one it already wrote.
181///
182/// Pure so the precedence can be unit-tested without a client or a config file on disk.
183///
184/// See the module-level PM-40120 note: this unconditional host resolution — always the link's
185/// own host, never a separately configured environment.
186fn resolve_urls(url: &Url, config: Option<&ConfigFile>) -> (String, String, bool) {
187    if let Some(host) = url.host_str()
188        && let Some(region) = CLOUD_HOSTS
189            .iter()
190            .find(|region| region.hosts.contains(&host))
191    {
192        return (
193            region.api_url.to_string(),
194            region.identity_url.to_string(),
195            true,
196        );
197    }
198
199    let origin = url.origin().ascii_serialization();
200
201    if let Some(config) = config
202        && let Some(base) = [config.web_vault.as_deref(), config.server.as_deref()]
203            .into_iter()
204            .flatten()
205            .find(|configured| same_origin(configured, &origin))
206    {
207        let base = base.trim_end_matches('/');
208        let api = trimmed(config.api.as_deref()).unwrap_or_else(|| format!("{base}/api"));
209        let identity =
210            trimmed(config.identity.as_deref()).unwrap_or_else(|| format!("{base}/identity"));
211        return (api, identity, true);
212    }
213
214    (format!("{origin}/api"), format!("{origin}/identity"), false)
215}
216
217/// `true` when `configured` denotes the same origin as `origin` (already an ASCII origin
218/// serialization). Falls back to a string compare for values that aren't parseable URLs, so a
219/// hand-edited `config.json` still matches.
220fn same_origin(configured: &str, origin: &str) -> bool {
221    match Url::parse(configured) {
222        Ok(parsed) => parsed.origin().ascii_serialization() == origin,
223        Err(_) => configured.trim_end_matches('/') == origin,
224    }
225}
226
227fn trimmed(value: Option<&str>) -> Option<String> {
228    value
229        .map(|v| v.trim_end_matches('/'))
230        .filter(|v| !v.is_empty())
231        .map(str::to_string)
232}
233
234/// Negotiate a send-access token, prompting for whatever credential the server says the Send
235/// needs. Mirrors the legacy `attemptAccess`: ask with no credentials first and branch on the
236/// typed `send_access_error_type` the server returns.
237///
238/// Legacy wraps every token request in a 3-attempt retry loop (`getTokenWithRetry`) for a
239/// `{kind: "expired"}` case tied to its persistent send-access-token cache, itself keyed on
240/// `sendId` alone with no host component — part of what PM-40120 tracks. This port's own cache
241/// ([`send_access_token_cache`]) is keyed on `(resolved_host, send_id)` instead, and evicts
242/// rather than retries on a rejected cached token (see [`run_receive`]), so there's no
243/// multi-attempt loop to replicate here.
244async fn attempt_access(
245    client: &PasswordManagerClient,
246    send_id: &str,
247    access_key: &SendAccessKey,
248    inputs: &ReceiveInputs,
249) -> Result<SendAccessTokenResponse> {
250    match request_token(client, send_id, None).await {
251        Ok(token) => Ok(token),
252        Err(err) => match invalid_request_type(&err) {
253            Some(SendAccessTokenInvalidRequestError::PasswordHashB64Required) => {
254                access_with_password(client, send_id, access_key, inputs).await
255            }
256            Some(SendAccessTokenInvalidRequestError::EmailRequired) => {
257                access_with_email_otp(client, send_id).await
258            }
259            _ if invalid_grant_type(&err)
260                == Some(&SendAccessTokenInvalidGrantError::SendIdInvalid) =>
261            {
262                Err(eyre!("Not found."))
263            }
264            _ => Err(token_error(err)),
265        },
266    }
267}
268
269/// Password-protected Sends: resolve the password from flags/env/file/prompt, run it through the
270/// same PBKDF2 recipe `bw send create --password` used, and exchange it for a token.
271async fn access_with_password(
272    client: &PasswordManagerClient,
273    send_id: &str,
274    access_key: &SendAccessKey,
275    inputs: &ReceiveInputs,
276) -> Result<SendAccessTokenResponse> {
277    let password = resolve_password(inputs)?;
278    let credentials = SendAccessCredentials::Password(SendPasswordCredentials {
279        password_hash_b64: access_key.hash_password_b64(&password),
280    });
281
282    match request_token(client, send_id, Some(credentials)).await {
283        Ok(token) => Ok(token),
284        Err(err)
285            if invalid_grant_type(&err)
286                == Some(&SendAccessTokenInvalidGrantError::PasswordHashB64Invalid) =>
287        {
288            Err(eyre!("Invalid password"))
289        }
290        Err(err) => Err(token_error(err)),
291    }
292}
293
294/// Email-OTP-protected Sends: the email request is what makes the server send the code, so the
295/// expected outcome of the first call is an `email_and_otp_required` error, not a token.
296async fn access_with_email_otp(
297    client: &PasswordManagerClient,
298    send_id: &str,
299) -> Result<SendAccessTokenResponse> {
300    if !can_interact() {
301        return Err(eyre!(
302            "Email verification required. Run in interactive mode."
303        ));
304    }
305
306    let email = prompt_email()?;
307    let credentials = SendAccessCredentials::Email(SendEmailCredentials {
308        email: email.clone(),
309    });
310
311    let err = match request_token(client, send_id, Some(credentials)).await {
312        // A token here means the server stopped requiring the OTP it just mailed; treat it as a
313        // contract break rather than silently proceeding, matching legacy.
314        Ok(_) => return Err(eyre!("Unexpected server response")),
315        Err(err) => err,
316    };
317
318    if invalid_request_type(&err) != Some(&SendAccessTokenInvalidRequestError::EmailAndOtpRequired)
319    {
320        return Err(token_error(err));
321    }
322
323    let otp = prompt_otp()?;
324    let credentials = SendAccessCredentials::EmailOtp(SendEmailOtpCredentials { email, otp });
325
326    match request_token(client, send_id, Some(credentials)).await {
327        Ok(token) => Ok(token),
328        // The server deliberately doesn't say which of the two was wrong, so neither do we.
329        Err(err)
330            if invalid_request_type(&err)
331                == Some(&SendAccessTokenInvalidRequestError::EmailAndOtpRequired) =>
332        {
333            Err(eyre!("Invalid email or verification code"))
334        }
335        Err(err) => Err(token_error(err)),
336    }
337}
338
339async fn request_token(
340    client: &PasswordManagerClient,
341    send_id: &str,
342    credentials: Option<SendAccessCredentials>,
343) -> Result<SendAccessTokenResponse, SendAccessTokenError> {
344    client
345        .auth()
346        .send_access()
347        .request_send_access_token(SendAccessTokenRequest {
348            send_id: send_id.to_string(),
349            send_access_credentials: credentials,
350        })
351        .await
352}
353
354/// Extracts the typed `send_access_error_type` from an `invalid_request` response, or `None`
355/// if `err` isn't that shape. `attempt_access` and `access_with_email_otp` both need to branch
356/// on this one sub-field of a deeply nested error enum; centralizing the match here keeps
357/// those call sites down to a single `Some(...) => ...` comparison instead of repeating the
358/// full pattern.
359fn invalid_request_type(err: &SendAccessTokenError) -> Option<&SendAccessTokenInvalidRequestError> {
360    match err {
361        SendAccessTokenError::Expected(SendAccessTokenApiErrorResponse::InvalidRequest {
362            send_access_error_type,
363            ..
364        }) => send_access_error_type.as_ref(),
365        _ => None,
366    }
367}
368
369/// Same idea as [`invalid_request_type`], but for the `invalid_grant` response shape (used to
370/// detect an unknown Send id or an invalid password hash).
371fn invalid_grant_type(err: &SendAccessTokenError) -> Option<&SendAccessTokenInvalidGrantError> {
372    match err {
373        SendAccessTokenError::Expected(SendAccessTokenApiErrorResponse::InvalidGrant {
374            send_access_error_type,
375            ..
376        }) => send_access_error_type.as_ref(),
377        _ => None,
378    }
379}
380
381/// Surface a token-negotiation failure we have no specific message for. The error's `Debug`
382/// carries the server's `error_description`, which is diagnostic and never contains send content
383/// or credentials.
384fn token_error(err: SendAccessTokenError) -> color_eyre::eyre::Error {
385    match err {
386        SendAccessTokenError::Unexpected(inner) => eyre!("Server error: {inner:?}"),
387        SendAccessTokenError::Expected(inner) => eyre!("Error: {inner:?}"),
388    }
389}
390
391/// Fetch the Send with the negotiated token, decrypt it with the URL key, and render it.
392async fn render_access(
393    client: &PasswordManagerClient,
394    access_key: &SendAccessKey,
395    token: String,
396    inputs: &ReceiveInputs,
397) -> CommandResult {
398    let response = client.sends().access_send(token.clone()).await?;
399    let view = access_key.decrypt_response(response)?;
400
401    if inputs.full_object {
402        return Ok(CommandOutput::Object(Box::new(view)));
403    }
404
405    match view.type_ {
406        // `render_result` uses `println!`, so this gains a trailing newline over legacy's raw
407        // `stdout.write`. Same pre-existing divergence as `bw send get --text`; kept consistent
408        // with the rest of this CLI rather than special-cased here.
409        Some(SendType::Text) => Ok(CommandOutput::Plain(
410            view.text.and_then(|text| text.text).unwrap_or_default(),
411        )),
412        Some(SendType::File) => {
413            save_file_send(
414                client,
415                access_key,
416                &token,
417                view,
418                inputs.output_path.as_deref(),
419            )
420            .await
421        }
422        Some(SendType::Item) => {
423            bail!("Accessing item Sends is not supported by the CLI.");
424        }
425        // Unknown or absent type: hand back everything we decrypted, as legacy does, rather
426        // than guessing at which content field to print.
427        None => Ok(CommandOutput::Object(Box::new(view))),
428    }
429}
430
431/// Download, decrypt, and save a file-type Send's blob.
432async fn save_file_send(
433    client: &PasswordManagerClient,
434    access_key: &SendAccessKey,
435    token: &str,
436    view: SendAccessView,
437    output: Option<&str>,
438) -> CommandResult {
439    let file = view
440        .file
441        .ok_or_else(|| eyre!("The Send is a file Send but carries no file metadata."))?;
442    let file_id = file
443        .id
444        .ok_or_else(|| eyre!("The Send's file is missing an id; cannot download it."))?;
445
446    let download = client
447        .sends()
448        .get_file_download_data(token.to_string(), file_id)
449        .await?;
450    let download_url = download
451        .url
452        .ok_or_else(|| eyre!("The server did not return a download url for the Send's file."))?;
453
454    let encrypted = download_bytes(client, &download_url).await?;
455    let decrypted = access_key.decrypt_file_buffer(&encrypted)?;
456
457    let file_name = default_send_file_name(file.file_name.as_deref());
458    let path = save_file(output, &file_name, &decrypted)?;
459
460    Ok(CommandOutput::Plain(format!("Saved {}", path.display())))
461}
462
463/// GET a pre-signed blob URL with the client's shared HTTP stack (so proxy and TLS settings
464/// apply), mirroring legacy's `apiService.nativeFetch`.
465///
466/// The fetch lives here rather than in [`super::file_output`] because a shared helper would have
467/// to name `reqwest::Client` in its signature, which would mean adding `reqwest` as a direct
468/// dependency of `bw`. Worth doing once a second command needs it; not for one caller.
469async fn download_bytes(client: &PasswordManagerClient, url: &str) -> Result<Vec<u8>> {
470    let response = client
471        .0
472        .internal
473        .get_http_client()
474        .get(url)
475        // Spelled as a plain `&str` so this file never has to name `reqwest`'s header types.
476        .header("cache-control", "no-cache")
477        .send()
478        .await?;
479
480    if !response.status().is_success() {
481        return Err(eyre!(
482            "A {} error occurred while downloading the attachment.",
483            response.status().as_u16()
484        ));
485    }
486
487    Ok(response.bytes().await?.to_vec())
488}
489
490// ===== Password resolution =====
491
492/// Resolve the Send's password, prompting when it wasn't supplied and the session is
493/// interactive. Mirrors legacy's `handlePasswordAuth` precedence exactly.
494fn resolve_password(inputs: &ReceiveInputs) -> Result<String> {
495    let supplied = password_from_args(
496        inputs.password.as_deref(),
497        inputs.passwordfile.as_deref(),
498        inputs.passwordenv.as_deref(),
499    )?;
500
501    let password = match supplied {
502        Some(password) => password,
503        None if can_interact() => prompt_password()?,
504        None => return Err(eyre!("Password required")),
505    };
506
507    if password.is_empty() {
508        return Err(eyre!("Password required"));
509    }
510
511    Ok(password)
512}
513
514/// Non-interactive half of [`resolve_password`], split out so the precedence is unit-testable.
515///
516/// Order (legacy `handlePasswordAuth`): `--password`, then `--passwordfile`, then
517/// `--passwordenv`. An empty `--password ""` falls through, and `--passwordfile` short-circuits
518/// `--passwordenv` even when the file's first line is empty — both match legacy's `else if`
519/// chain.
520fn password_from_args(
521    password: Option<&str>,
522    passwordfile: Option<&str>,
523    passwordenv: Option<&str>,
524) -> Result<Option<String>> {
525    if let Some(password) = password.filter(|p| !p.is_empty()) {
526        return Ok(Some(password.to_string()));
527    }
528
529    if let Some(path) = passwordfile {
530        reject_path_traversal("--passwordfile", path)?;
531        let contents = std::fs::read_to_string(path)
532            .wrap_err_with(|| format!("Could not read --passwordfile {path}"))?;
533        // First line only, terminator stripped — legacy's `NodeUtils.readFirstLine`. Legacy
534        // hangs forever on an empty file (its readline never emits a `line` event); we treat
535        // that as "no password supplied" and let the caller prompt or error.
536        return Ok(contents.lines().next().map(str::to_string));
537    }
538
539    if let Some(name) = passwordenv {
540        return match std::env::var(name) {
541            Ok(value) => Ok(Some(value)),
542            // Legacy silently ignores an unset variable and falls through to the prompt, which
543            // reads as "the flag did nothing". Say so, on stderr, without echoing any value.
544            Err(_) => {
545                tracing::warn!("--passwordenv variable `{name}` is not set; ignoring it.");
546                Ok(None)
547            }
548        };
549    }
550
551    Ok(None)
552}
553
554// ===== Interactivity =====
555
556/// Whether we may prompt the user.
557///
558/// Reads `BW_NOINTERACTION` strictly (`== "true"`), matching the legacy CLI. The `--nointeraction`
559/// flag on [`crate::command::Cli`] is *not* honored here: it is never threaded into
560/// `ClientContext`, so no `BwCommand` can see it today. Plumbing it through touches every
561/// command's dispatch signature — out of scope for this command; needs its own ticket.
562fn can_interact() -> bool {
563    std::env::var("BW_NOINTERACTION").as_deref() != Ok("true")
564}
565
566/// Prompt for the Send's password. `inquire` renders to stderr, so
567/// `bw receive <url> > out.txt` still captures only the Send's content — the same reason legacy
568/// passes `output: process.stderr` to inquirer.
569fn prompt_password() -> Result<String> {
570    Ok(Password::new("Send password")
571        .without_confirmation()
572        .prompt()?)
573}
574
575fn prompt_email() -> Result<String> {
576    Ok(Text::new("Enter your email address:")
577        .with_validator(|input: &str| {
578            if input.contains('@') {
579                Ok(Validation::Valid)
580            } else {
581                Ok(Validation::Invalid(
582                    "Please enter a valid email address".into(),
583                ))
584            }
585        })
586        .prompt()?)
587}
588
589fn prompt_otp() -> Result<String> {
590    Ok(Text::new("Enter the verification code sent to your email:").prompt()?)
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    fn parse(url: &str) -> Url {
598        Url::parse(url).expect("test url parses")
599    }
600
601    // ---- parse_send_url ----
602
603    #[test]
604    fn parses_the_web_vault_send_route() {
605        let (id, key) = parse_send_url(&parse(
606            "https://vault.bitwarden.com/#/send/access-id/Pgui0FK85cNhBGWHAlBHBw",
607        ))
608        .unwrap();
609        assert_eq!(id, "access-id");
610        assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
611    }
612
613    #[test]
614    fn parses_the_send_vanity_host_route() {
615        // `https://send.bitwarden.com/#<id>/<key>` — no `/send/` path segment in the fragment.
616        let (id, key) = parse_send_url(&parse(
617            "https://send.bitwarden.com/#access-id/Pgui0FK85cNhBGWHAlBHBw",
618        ))
619        .unwrap();
620        assert_eq!(id, "access-id");
621        assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
622    }
623
624    #[test]
625    fn parses_self_hosted_links_with_a_path_prefix() {
626        let (id, key) = parse_send_url(&parse(
627            "https://example.com/bitwarden/#/send/access-id/Pgui0FK85cNhBGWHAlBHBw",
628        ))
629        .unwrap();
630        assert_eq!(id, "access-id");
631        assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
632    }
633
634    #[test]
635    fn rejects_a_url_without_a_fragment() {
636        let err = parse_send_url(&parse("https://vault.bitwarden.com/")).unwrap_err();
637        assert!(
638            err.to_string().contains("not a valid Send url"),
639            "got: {err}"
640        );
641    }
642
643    #[test]
644    fn rejects_a_fragment_with_only_one_segment() {
645        let err = parse_send_url(&parse("https://send.bitwarden.com/#access-id")).unwrap_err();
646        assert!(
647            err.to_string().contains("not a valid Send url"),
648            "got: {err}"
649        );
650    }
651
652    #[test]
653    fn rejects_a_fragment_with_a_blank_segment() {
654        // `#/send//key` and `#/send/id/` both lose a required value.
655        assert!(
656            parse_send_url(&parse("https://vault.bitwarden.com/#/send//key")).is_err(),
657            "blank id must be rejected"
658        );
659        assert!(
660            parse_send_url(&parse("https://vault.bitwarden.com/#/send/id/")).is_err(),
661            "blank key must be rejected"
662        );
663    }
664
665    // ---- resolve_urls ----
666
667    #[test]
668    fn cloud_send_and_vault_hosts_map_to_their_region() {
669        for host in ["send.bitwarden.com", "vault.bitwarden.com"] {
670            let (api, identity, trusted) =
671                resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
672            assert_eq!(api, "https://api.bitwarden.com");
673            assert_eq!(identity, "https://identity.bitwarden.com");
674            assert!(trusted, "a known cloud host must be trusted");
675        }
676        for host in ["send.bitwarden.eu", "vault.bitwarden.eu"] {
677            let (api, identity, trusted) =
678                resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
679            assert_eq!(api, "https://api.bitwarden.eu");
680            assert_eq!(identity, "https://identity.bitwarden.eu");
681            assert!(trusted, "a known cloud host must be trusted");
682        }
683    }
684
685    /// The cloud table must match on the **exact** host. A suffix match would send a Send
686    /// password hash — derived from the recipient's password — to real Bitwarden cloud identity
687    /// on behalf of an attacker-controlled link.
688    #[test]
689    fn a_lookalike_host_is_never_treated_as_cloud() {
690        for host in [
691            "send.bitwarden.com.evil.tld",
692            "evil-send.bitwarden.com.attacker.example",
693            "notsend.bitwarden.com.evil.tld",
694        ] {
695            let (api, identity, trusted) =
696                resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
697            assert_eq!(api, format!("https://{host}/api"));
698            assert_eq!(identity, format!("https://{host}/identity"));
699            assert!(!trusted, "a lookalike host must not be trusted");
700        }
701    }
702
703    #[test]
704    fn unknown_host_falls_back_to_the_single_domain_convention() {
705        let (api, identity, trusted) =
706            resolve_urls(&parse("https://vault.example.com/#/send/id/key"), None);
707        assert_eq!(api, "https://vault.example.com/api");
708        assert_eq!(identity, "https://vault.example.com/identity");
709        assert!(
710            !trusted,
711            "an unknown, unconfigured host must not be trusted"
712        );
713    }
714
715    #[test]
716    fn a_non_default_port_is_preserved_in_the_fallback() {
717        let (api, identity, trusted) =
718            resolve_urls(&parse("https://localhost:8080/#/send/id/key"), None);
719        assert_eq!(api, "https://localhost:8080/api");
720        assert_eq!(identity, "https://localhost:8080/identity");
721        assert!(!trusted);
722    }
723
724    #[test]
725    fn a_matching_configured_server_supplies_the_base() {
726        let config = ConfigFile {
727            server: Some("https://bw.example.com".to_string()),
728            ..Default::default()
729        };
730        let (api, identity, trusted) = resolve_urls(
731            &parse("https://bw.example.com/#/send/id/key"),
732            Some(&config),
733        );
734        assert_eq!(api, "https://bw.example.com/api");
735        assert_eq!(identity, "https://bw.example.com/identity");
736        assert!(trusted, "the locally configured deployment must be trusted");
737    }
738
739    #[test]
740    fn a_matching_configured_web_vault_supplies_the_base() {
741        let config = ConfigFile {
742            web_vault: Some("https://vault.example.com/".to_string()),
743            ..Default::default()
744        };
745        let (api, identity, trusted) = resolve_urls(
746            &parse("https://vault.example.com/#/send/id/key"),
747            Some(&config),
748        );
749        assert_eq!(api, "https://vault.example.com/api");
750        assert_eq!(identity, "https://vault.example.com/identity");
751        assert!(trusted);
752    }
753
754    /// A split-domain self-host configured via `bw config server --api/--identity` must use
755    /// those hosts, not `<web-vault>/api`.
756    #[test]
757    fn explicit_configured_api_and_identity_win_over_the_base() {
758        let config = ConfigFile {
759            web_vault: Some("https://vault.example.com".to_string()),
760            api: Some("https://api.example.com".to_string()),
761            identity: Some("https://identity.example.com/".to_string()),
762            ..Default::default()
763        };
764        let (api, identity, trusted) = resolve_urls(
765            &parse("https://vault.example.com/#/send/id/key"),
766            Some(&config),
767        );
768        assert_eq!(api, "https://api.example.com");
769        assert_eq!(identity, "https://identity.example.com");
770        assert!(trusted);
771    }
772
773    /// A configured deployment must not hijack a link that points somewhere else — receiving a
774    /// cloud Send while `bw config server` points at a self-host has to still hit cloud.
775    #[test]
776    fn a_config_for_a_different_origin_is_ignored() {
777        let config = ConfigFile {
778            server: Some("https://bw.example.com".to_string()),
779            api: Some("https://api.example.com".to_string()),
780            ..Default::default()
781        };
782        let (api, identity, trusted) =
783            resolve_urls(&parse("https://send.bitwarden.com/#id/key"), Some(&config));
784        assert_eq!(api, "https://api.bitwarden.com");
785        assert_eq!(identity, "https://identity.bitwarden.com");
786        assert!(trusted);
787
788        let (api, identity, trusted) = resolve_urls(
789            &parse("https://other.example.com/#/send/id/key"),
790            Some(&config),
791        );
792        assert_eq!(api, "https://other.example.com/api");
793        assert_eq!(identity, "https://other.example.com/identity");
794        assert!(
795            !trusted,
796            "an origin that matches neither the cloud table nor the configured deployment \
797             must not be trusted"
798        );
799    }
800
801    // ---- password_from_args ----
802
803    #[test]
804    fn password_flag_wins() {
805        let password = password_from_args(Some("hunter2"), None, None).unwrap();
806        assert_eq!(password.as_deref(), Some("hunter2"));
807    }
808
809    #[test]
810    fn empty_password_flag_falls_through() {
811        // Legacy treats `--password ""` as "not supplied".
812        assert_eq!(password_from_args(Some(""), None, None).unwrap(), None);
813    }
814
815    #[test]
816    fn password_file_supplies_its_first_line_only() {
817        let path = std::env::temp_dir().join(format!(
818            "bw-receive-passwordfile-{}",
819            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
820        ));
821        std::fs::write(&path, "first-line\nsecond-line\n").unwrap();
822
823        let password = password_from_args(None, Some(&path.to_string_lossy()), None).unwrap();
824
825        assert_eq!(password.as_deref(), Some("first-line"));
826        std::fs::remove_file(&path).ok();
827    }
828
829    #[test]
830    fn password_file_strips_a_windows_line_terminator() {
831        let path = std::env::temp_dir().join(format!(
832            "bw-receive-passwordfile-crlf-{}",
833            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
834        ));
835        std::fs::write(&path, "first-line\r\nsecond\r\n").unwrap();
836
837        let password = password_from_args(None, Some(&path.to_string_lossy()), None).unwrap();
838
839        assert_eq!(password.as_deref(), Some("first-line"));
840        std::fs::remove_file(&path).ok();
841    }
842
843    #[test]
844    fn password_file_rejects_traversal() {
845        let err = password_from_args(None, Some("../secrets/pw.txt"), None).unwrap_err();
846        assert!(
847            err.to_string().contains("--passwordfile"),
848            "expected a --passwordfile traversal error, got: {err}"
849        );
850    }
851
852    #[test]
853    fn missing_password_file_is_an_error_not_a_fallthrough() {
854        // A caller who pointed us at a file expects to hear that it isn't there, rather than
855        // being silently prompted.
856        assert!(password_from_args(None, Some("/nonexistent/bw-receive-pw.txt"), None).is_err());
857    }
858
859    /// `--passwordfile` short-circuits `--passwordenv` even when the file yields nothing —
860    /// legacy's `if/else if` chain never reaches the env branch once the file flag is set.
861    #[test]
862    fn password_file_short_circuits_password_env() {
863        let path = std::env::temp_dir().join(format!(
864            "bw-receive-passwordfile-empty-{}",
865            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
866        ));
867        std::fs::write(&path, "").unwrap();
868
869        let password = password_from_args(
870            None,
871            Some(&path.to_string_lossy()),
872            // Deliberately a variable name that is never set: if the env branch were reached
873            // this test would still pass, so the assertion below is about the empty file
874            // producing `None` rather than about the env var itself.
875            Some("BW_RECEIVE_TEST_UNSET_VAR"),
876        )
877        .unwrap();
878
879        assert_eq!(password, None);
880        std::fs::remove_file(&path).ok();
881    }
882
883    #[test]
884    fn unset_password_env_yields_no_password() {
885        assert_eq!(
886            password_from_args(None, None, Some("BW_RECEIVE_TEST_DEFINITELY_UNSET")).unwrap(),
887            None
888        );
889    }
890
891    #[test]
892    fn no_inputs_yields_no_password() {
893        assert_eq!(password_from_args(None, None, None).unwrap(), None);
894    }
895}