Keep the typed server after browser sign-in, and open only https

Login Flow v2 saved the account under the `server` field of the poll
response, not under the address the person typed. That field is the
server's idea of its own URL. Behind a TLS-terminating proxy without
`overwriteprotocol` (a common setup) it says http://, and the account then
sent its app password in the clear on every request after that. The
typed address, already upgraded to https by normalise_endpoint, has just
carried the whole flow, so it is the one kept.

The flow's other two URLs come from the server as well, and are now
upgraded from http to https, and refused if they use any other scheme:

- The login URL is handed to the OS to open. On Windows that is
  `rundll32 url.dll,FileProtocolHandler`, which runs a file: or UNC path
  rather than showing a web page, so a hostile server could launch a
  program when the user starts signing in. open_in_browser also refuses
  anything that is not https, as the last check before a process starts.
- The poll endpoint is where the app password comes back from.

The host is not checked. A server reached by its LAN address can answer
with its public name, and refusing that would break a working setup
without protecting anything: the account is stored under the typed
address whatever the server says.

Part of #65.
This commit is contained in:
2026-09-24 20:44:19 -04:00
parent adade27de4
commit ea31791388
3 changed files with 153 additions and 8 deletions
+28
View File
@@ -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