diff --git a/core/dr-sync-nextcloud/src/auth.rs b/core/dr-sync-nextcloud/src/auth.rs index c53e7fd..186bf41 100644 --- a/core/dr-sync-nextcloud/src/auth.rs +++ b/core/dr-sync-nextcloud/src/auth.rs @@ -22,6 +22,11 @@ pub struct LoginFlow { pub login_url: String, #[serde(rename = "poll")] pub poll: PollInfo, + /// The server the flow was started against — the address the user typed, + /// already normalised. Not part of the response: [`begin`] fills it in so + /// [`poll`] can put it in the credentials instead of the server's answer. + #[serde(skip)] + pub server: String, } #[derive(Debug, Clone, Deserialize)] @@ -66,9 +71,57 @@ pub async fn begin( }); } - resp.json::() + let mut flow = resp + .json::() .await - .map_err(|e| RemoteError::Protocol(e.to_string())) + .map_err(|e| RemoteError::Protocol(e.to_string()))?; + + // Both URLs are the server's to choose, and neither may be trusted as + // sent. The login URL is handed to the operating system to open, where a + // `file:` or UNC path is a program launch rather than a web page; the poll + // endpoint is where the app password comes back from. + flow.login_url = upgraded("login URL", &flow.login_url)?; + flow.poll.endpoint = upgraded("poll endpoint", &flow.poll.endpoint)?; + flow.server = server.trim_end_matches('/').to_string(); + Ok(flow) +} + +/// TRACES: NFR-SEC-3 +/// A URL the server sent, upgraded to HTTPS, or refused. +/// +/// `http` is upgraded rather than refused: a Nextcloud behind a TLS-terminating +/// proxy without `overwriteprotocol` builds every absolute URL it returns with +/// `http`, and the same path over `https` is the one that works. Any other +/// scheme is refused, because the only thing it could be for is reaching +/// something that is not this server. +/// +/// The host is not checked. A server reached by its LAN address may answer with +/// its public name, and nothing here is safer for refusing that: the account +/// is stored under the address the user typed (see [`poll`]), not under +/// anything the server said. +pub(crate) fn upgraded(what: &str, url: &str) -> Result { + let mut parsed = url::Url::parse(url).map_err(|e| { + RemoteError::Protocol(format!( + "the server sent a {what} that is not a URL ({e}): {url}" + )) + })?; + match parsed.scheme() { + "https" => {} + "http" => parsed.set_scheme("https").map_err(|()| { + RemoteError::Protocol(format!("{what} cannot be upgraded to https: {url}")) + })?, + other => { + return Err(RemoteError::Protocol(format!( + "the server sent a {what} using {other}:, and only https is accepted: {url}" + ))) + } + } + if parsed.host_str().is_none_or(str::is_empty) { + return Err(RemoteError::Protocol(format!( + "the server sent a {what} with no host: {url}" + ))); + } + Ok(parsed.into()) } /// Poll until the user finishes authenticating in the browser. @@ -98,10 +151,11 @@ pub async fn poll( match resp.status().as_u16() { 200 => { - return resp + let creds = resp .json::() .await - .map_err(|e| RemoteError::Protocol(e.to_string())) + .map_err(|e| RemoteError::Protocol(e.to_string()))?; + return Ok(under_typed_server(creds, &flow.server)); } // Still waiting for the user. 404 => tokio::time::sleep(POLL_INTERVAL).await, @@ -117,6 +171,26 @@ pub async fn poll( Err(RemoteError::AuthFailed) } +/// TRACES: NFR-SEC-3 +/// Credentials filed under the address the user typed, not the one the server +/// reports. +/// +/// That report is the server's idea of its own URL, and behind a proxy +/// without `overwriteprotocol` it says `http://` — which, stored, would send +/// the app password in the clear on every request from then on. The typed +/// address has just carried the whole flow, so it is known to reach the +/// server. +fn under_typed_server(mut creds: AppCredentials, server: &str) -> AppCredentials { + if creds.server.trim_end_matches('/') != server { + log::info!( + "server reports itself as {}; keeping {server}", + creds.server + ); + } + creds.server = server.to_string(); + creds +} + /// Percent-encode a form value. fn urlencode(s: &str) -> String { s.bytes() @@ -167,6 +241,49 @@ mod tests { assert!(flow.login_url.contains("/login/v2/flow/")); } + /// TRACES: NFR-SEC-3 + #[test] + fn server_urls_are_upgraded_to_https_or_refused() { + assert_eq!( + upgraded("login URL", "http://cloud.example/login/v2/flow/xyz").unwrap(), + "https://cloud.example/login/v2/flow/xyz" + ); + // A non-default port stays, and only the scheme changes. + assert_eq!( + upgraded("poll endpoint", "http://cloud.example:8443/login/v2/poll").unwrap(), + "https://cloud.example:8443/login/v2/poll" + ); + assert_eq!( + upgraded("login URL", "https://cloud.example/x").unwrap(), + "https://cloud.example/x" + ); + // What `rundll32 url.dll,FileProtocolHandler` would run, and what + // `xdg-open` would hand to whatever claims it. + for hostile in [ + "file:///C:/Windows/System32/calc.exe", + "\\\\evil\\share\\x.exe", + "C:\\x.exe", + "javascript:alert(1)", + "-v", + "", + ] { + assert!(upgraded("login URL", hostile).is_err(), "{hostile:?}"); + } + } + + /// TRACES: NFR-SEC-3 + #[test] + fn credentials_keep_the_typed_server_not_the_reported_one() { + let reported = AppCredentials { + server: "http://cloud.example".into(), + login_name: "duncan".into(), + app_password: "secret-token".into(), + }; + let c = under_typed_server(reported, "https://cloud.example"); + assert_eq!(c.server, "https://cloud.example"); + assert_eq!(c.app_password, "secret-token"); + } + #[test] fn form_values_are_encoded() { assert_eq!(urlencode("abc123"), "abc123"); diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index ef0b8f1..5cb35af 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -111,10 +111,10 @@ _None._ | FR-MRG-6 | [`core/dr-pipeline/src/sidecar.rs:1033`](../../core/dr-pipeline/src/sidecar.rs#L1033), [`core/dr-pipeline/src/sidecar.rs:113`](../../core/dr-pipeline/src/sidecar.rs#L113), [`core/dr-pipeline/src/sidecar.rs:123`](../../core/dr-pipeline/src/sidecar.rs#L123), [`core/dr-pipeline/src/sidecar.rs:2241`](../../core/dr-pipeline/src/sidecar.rs#L2241), [`core/dr-pipeline/src/sidecar.rs:841`](../../core/dr-pipeline/src/sidecar.rs#L841), [`ui/dr-ui/src/merge.rs:842`](../../ui/dr-ui/src/merge.rs#L842) | | FR-MRG-7 | [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/app.slint:533`](../../ui/dr-ui/ui/app.slint#L533), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | | FR-MRG-8 | [`core/dr-pano/src/xfeat.rs:1`](../../core/dr-pano/src/xfeat.rs#L1), [`core/dr-segment/examples/onnx_probe.rs:1`](../../core/dr-segment/examples/onnx_probe.rs#L1) | -| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:206`](../../core/dr-sync-nextcloud/src/auth.rs#L206), [`core/dr-sync-nextcloud/src/auth.rs:49`](../../core/dr-sync-nextcloud/src/auth.rs#L49), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:375`](../../core/dr-sync/src/account.rs#L375), [`ui/dr-ui/src/launch.rs:316`](../../ui/dr-ui/src/launch.rs#L316), [`ui/dr-ui/src/launch.rs:61`](../../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:450`](../../ui/dr-ui/src/launch_ui.rs#L450) | +| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:206`](../../core/dr-sync-nextcloud/src/auth.rs#L206), [`core/dr-sync-nextcloud/src/auth.rs:49`](../../core/dr-sync-nextcloud/src/auth.rs#L49), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:375`](../../core/dr-sync/src/account.rs#L375), [`ui/dr-ui/src/launch.rs:316`](../../ui/dr-ui/src/launch.rs#L316), [`ui/dr-ui/src/launch.rs:61`](../../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:446`](../../ui/dr-ui/src/launch_ui.rs#L446) | | FR-NC-10 | [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:682`](../../ui/dr-ui/src/lib.rs#L682), [`ui/dr-ui/src/library/paths.rs:69`](../../ui/dr-ui/src/library/paths.rs#L69), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library_ui/controller.rs:657`](../../ui/dr-ui/src/library_ui/controller.rs#L657), [`ui/dr-ui/src/library_ui/offline.rs:623`](../../ui/dr-ui/src/library_ui/offline.rs#L623), [`ui/dr-ui/src/library_ui/sync.rs:100`](../../ui/dr-ui/src/library_ui/sync.rs#L100), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:1097`](../../core/dr-sync-nextcloud/src/lib.rs#L1097), [`core/dr-sync-nextcloud/src/lib.rs:40`](../../core/dr-sync-nextcloud/src/lib.rs#L40), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:87`](../../core/dr-sync/src/account.rs#L87), [`core/dr-sync/src/lib.rs:218`](../../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:120`](../../core/dr-sync/src/provider.rs#L120), [`core/dr-sync/src/provider.rs:1`](../../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../../ui/dr-ui/src/remote.rs#L1) | -| FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:120`](../../core/dr-sync/src/provider.rs#L120), [`ui/dr-ui/src/launch_ui.rs:349`](../../ui/dr-ui/src/launch_ui.rs#L349), [`ui/dr-ui/src/remote.rs:1`](../../ui/dr-ui/src/remote.rs#L1) | +| FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:120`](../../core/dr-sync/src/provider.rs#L120), [`ui/dr-ui/src/launch_ui.rs:345`](../../ui/dr-ui/src/launch_ui.rs#L345), [`ui/dr-ui/src/remote.rs:1`](../../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:318`](../../core/dr-sync/src/account.rs#L318), [`core/dr-sync/src/account.rs:375`](../../core/dr-sync/src/account.rs#L375), [`core/dr-sync/src/account.rs:59`](../../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../../platform/dr-plat/src/secrets.rs#L82) | | FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui/grid.rs:458`](../../ui/dr-ui/src/library_ui/grid.rs#L458), [`ui/dr-ui/src/library_ui/mod.rs:1`](../../ui/dr-ui/src/library_ui/mod.rs#L1), [`ui/dr-ui/src/library_ui/sync.rs:385`](../../ui/dr-ui/src/library_ui/sync.rs#L385), [`ui/dr-ui/ui/library.slint:737`](../../ui/dr-ui/ui/library.slint#L737), [`ui/dr-ui/ui/settings.slint:362`](../../ui/dr-ui/ui/settings.slint#L362), [`ui/dr-ui/ui/settings.slint:74`](../../ui/dr-ui/ui/settings.slint#L74) | | FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../../core/dr-sync-nextcloud/src/propfind.rs#L103), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:218`](../../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:129`](../../core/dr-sync/src/scan.rs#L129), [`ui/dr-ui/src/launch.rs:61`](../../ui/dr-ui/src/launch.rs#L61) | @@ -137,7 +137,7 @@ _None._ | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/dirs.rs:1`](../../platform/dr-plat/src/dirs.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:1386`](../../ui/dr-ui/src/lib.rs#L1386), [`ui/dr-ui/src/preset_store.rs:1`](../../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../../platform/dr-plat/src/display/x11.rs#L1) | | FR-PLAT-WIN-1 | [`platform/dr-plat/src/dirs.rs:1`](../../platform/dr-plat/src/dirs.rs#L1) | -| FR-PLAT-WIN-2 | [`apps/darkroom-desktop/build.rs:1`](../../apps/darkroom-desktop/build.rs#L1), [`apps/darkroom-desktop/src/main.rs:6`](../../apps/darkroom-desktop/src/main.rs#L6), [`ui/dr-ui/src/launch_ui.rs:865`](../../ui/dr-ui/src/launch_ui.rs#L865) | +| FR-PLAT-WIN-2 | [`apps/darkroom-desktop/build.rs:1`](../../apps/darkroom-desktop/build.rs#L1), [`apps/darkroom-desktop/src/main.rs:6`](../../apps/darkroom-desktop/src/main.rs#L6), [`ui/dr-ui/src/launch_ui.rs:861`](../../ui/dr-ui/src/launch_ui.rs#L861) | | FR-PLAT-WIN-3 | [`apps/darkroom-desktop/src/main.rs:19`](../../apps/darkroom-desktop/src/main.rs#L19) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:259`](../../core/dr-decode/src/lib.rs#L259), [`core/dr-types/src/lib.rs:132`](../../core/dr-types/src/lib.rs#L132), [`core/dr-types/src/lib.rs:203`](../../core/dr-types/src/lib.rs#L203) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:155`](../../core/dr-decode/src/lib.rs#L155), [`core/dr-decode/src/lib.rs:546`](../../core/dr-decode/src/lib.rs#L546), [`core/dr-decode/src/locate.rs:1435`](../../core/dr-decode/src/locate.rs#L1435) | @@ -182,7 +182,7 @@ _None._ | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-gpu/src/lib.rs:95`](../../core/dr-gpu/src/lib.rs#L95), [`core/dr-thumbs/src/codec.rs:1`](../../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:393`](../../core/dr-thumbs/src/lib.rs#L393) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../../core/dr-decode/src/error.rs#L1), [`core/dr-decode/src/error.rs:30`](../../core/dr-decode/src/error.rs#L30) | | NFR-SEC-2 | [`core/dr-sync/src/account.rs:318`](../../core/dr-sync/src/account.rs#L318), [`platform/dr-plat/src/crash.rs:1`](../../platform/dr-plat/src/crash.rs#L1), [`platform/dr-plat/src/diagnostics.rs:1`](../../platform/dr-plat/src/diagnostics.rs#L1), [`platform/dr-plat/src/diagnostics/bundle.rs:1`](../../platform/dr-plat/src/diagnostics/bundle.rs#L1), [`platform/dr-plat/src/diagnostics/redact.rs:1`](../../platform/dr-plat/src/diagnostics/redact.rs#L1), [`platform/dr-plat/src/secrets.rs:82`](../../platform/dr-plat/src/secrets.rs#L82) | -| NFR-SEC-3 | [`core/dr-sync-nextcloud/src/auth.rs:174`](../../core/dr-sync-nextcloud/src/auth.rs#L174), [`core/dr-sync-nextcloud/src/auth.rs:244`](../../core/dr-sync-nextcloud/src/auth.rs#L244), [`core/dr-sync-nextcloud/src/auth.rs:274`](../../core/dr-sync-nextcloud/src/auth.rs#L274), [`core/dr-sync-nextcloud/src/auth.rs:89`](../../core/dr-sync-nextcloud/src/auth.rs#L89), [`core/dr-sync-nextcloud/src/lib.rs:1035`](../../core/dr-sync-nextcloud/src/lib.rs#L1035), [`core/dr-sync-nextcloud/src/lib.rs:1`](../../core/dr-sync-nextcloud/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:726`](../../core/dr-sync-nextcloud/src/lib.rs#L726), [`core/dr-sync-nextcloud/src/provider.rs:147`](../../core/dr-sync-nextcloud/src/provider.rs#L147), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync-nextcloud/src/provider.rs:99`](../../core/dr-sync-nextcloud/src/provider.rs#L99), [`core/dr-sync/src/account.rs:513`](../../core/dr-sync/src/account.rs#L513), [`core/dr-sync/src/account.rs:709`](../../core/dr-sync/src/account.rs#L709), [`core/dr-sync/src/account.rs:753`](../../core/dr-sync/src/account.rs#L753), [`core/dr-sync/src/account.rs:774`](../../core/dr-sync/src/account.rs#L774), [`ui/dr-ui/src/launch_ui.rs:1022`](../../ui/dr-ui/src/launch_ui.rs#L1022), [`ui/dr-ui/src/launch_ui.rs:837`](../../ui/dr-ui/src/launch_ui.rs#L837) | +| NFR-SEC-3 | [`core/dr-sync-nextcloud/src/auth.rs:174`](../../core/dr-sync-nextcloud/src/auth.rs#L174), [`core/dr-sync-nextcloud/src/auth.rs:244`](../../core/dr-sync-nextcloud/src/auth.rs#L244), [`core/dr-sync-nextcloud/src/auth.rs:274`](../../core/dr-sync-nextcloud/src/auth.rs#L274), [`core/dr-sync-nextcloud/src/auth.rs:89`](../../core/dr-sync-nextcloud/src/auth.rs#L89), [`core/dr-sync-nextcloud/src/lib.rs:1035`](../../core/dr-sync-nextcloud/src/lib.rs#L1035), [`core/dr-sync-nextcloud/src/lib.rs:1`](../../core/dr-sync-nextcloud/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:726`](../../core/dr-sync-nextcloud/src/lib.rs#L726), [`core/dr-sync-nextcloud/src/provider.rs:147`](../../core/dr-sync-nextcloud/src/provider.rs#L147), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync-nextcloud/src/provider.rs:99`](../../core/dr-sync-nextcloud/src/provider.rs#L99), [`core/dr-sync/src/account.rs:513`](../../core/dr-sync/src/account.rs#L513), [`core/dr-sync/src/account.rs:709`](../../core/dr-sync/src/account.rs#L709), [`core/dr-sync/src/account.rs:753`](../../core/dr-sync/src/account.rs#L753), [`core/dr-sync/src/account.rs:774`](../../core/dr-sync/src/account.rs#L774), [`ui/dr-ui/src/launch_ui.rs:1018`](../../ui/dr-ui/src/launch_ui.rs#L1018), [`ui/dr-ui/src/launch_ui.rs:833`](../../ui/dr-ui/src/launch_ui.rs#L833) | | NFR-SEC-4 | [`platform/dr-plat/src/diagnostics/bundle.rs:1`](../../platform/dr-plat/src/diagnostics/bundle.rs#L1) | | NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`platform/dr-plat/src/diagnostics/bundle.rs:1`](../../platform/dr-plat/src/diagnostics/bundle.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1) | | R3 | [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-pipeline/src/lib.rs:1`](../../core/dr-pipeline/src/lib.rs#L1) | diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index 1e38824..e8d0e22 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -830,6 +830,19 @@ async fn fetch_user_id( /// success here strands the flow on "Approve the sign-in in your browser" with /// no browser and no explanation. fn open_in_browser(url: &str) -> std::io::Result<()> { + // TRACES: NFR-SEC-3 + // The URL is the server's, and both launchers below open anything, not + // just web pages: `rundll32 url.dll,FileProtocolHandler` runs a `file:` + // or UNC path, and `xdg-open` hands it to whatever claims it. `auth::begin` + // already refuses those; this is the last point before a process starts, + // so it refuses them again rather than trust that every caller came that + // way. + if !url.starts_with("https://") { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("refusing to open a sign-in address that is not https: {url}"), + )); + } // Not `target_os = "linux"`: Android is its own target_os, and reached this // arm's `Ok(())` fallback, so the browser silently never opened. #[cfg(all(unix, not(target_os = "android"), not(target_os = "macos")))] @@ -1002,6 +1015,21 @@ mod tests { assert!(store.current().is_none(), "nothing may be persisted"); } + /// TRACES: NFR-SEC-3 + #[test] + fn only_an_https_address_reaches_the_launcher() { + // Refused before any process starts, so running this spawns nothing. + for url in [ + "http://cloud.example/login/v2/flow/x", + "file:///C:/Windows/System32/calc.exe", + "\\\\evil\\share\\x.exe", + "-v", + ] { + let e = open_in_browser(url).expect_err(url); + assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput, "{url}"); + } + } + #[test] fn the_error_says_what_to_fix() { // It goes straight to the screen's error line, so it has to read as