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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user