mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(gui): run the CAPTCHA page in its own process (B195)
On secureblue, WebKit cannot start the sandbox for the verification page: bubblewrap is kept from the journal socket, xdg-dbus-proxy fails, and WebKit ends its process with g_error. The webview lived in pdfs-app, so the whole app crashed after the sign-in (#29). - gui: `pdfs-app --human-verification` shows the page in its own window, reads the URL on stdin and prints the token on stdout; its logs go to stderr - gui: a child that dies or fails without a token ends the sign-in with a message that the page could not be opened, instead of "cancelled" - i18n: translate the new message in every catalog - docs: B195 in the bug ledger, the cause and a one-time workaround in TROUBLESHOOTING.md, the child process in ARCHITECTURE.md, changelog
This commit is contained in:
1 parent
c7fc85e943
commit
a888e0bb28
16 files changed
+1355
-997
No files matched your search
@@ -202,12 +202,20 @@ impl Ui {
|
||||
}
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
|
||||
let logs = tracing_subscriber::fmt().with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
);
|
||||
// The verification window's stdout carries its token back to the app.
|
||||
if verify {
|
||||
logs.with_writer(std::io::stderr).init();
|
||||
} else {
|
||||
logs.init();
|
||||
}
|
||||
i18n::init();
|
||||
if verify {
|
||||
return run_verification_window();
|
||||
}
|
||||
|
||||
// The tray launches the app with arguments ("--page locations",
|
||||
// "--confirm-stop"), which a running instance must receive too.
|
||||
|
||||
@@ -150,15 +150,27 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
});
|
||||
|
||||
// Same shape for human verification: only fires when the API gates the
|
||||
// sign-in, and the worker is blocked on the token until it does.
|
||||
// sign-in, and the worker is blocked on the token until it does. A
|
||||
// window that never came up is told apart from one the user closed, so
|
||||
// the result below can say why the sign-in stopped.
|
||||
let ui_hv = ui.clone();
|
||||
let verification_failed = Rc::new(Cell::new(false));
|
||||
let hv_failed = verification_failed.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||
ui_hv
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&gettext("Complete the verification to continue…"));
|
||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||
// Anything but a token drops the sender, which the worker reads
|
||||
// as a cancelled sign-in.
|
||||
match verify_human(url).await {
|
||||
Verification::Solved(token) => {
|
||||
let _ = token_tx.send(token);
|
||||
}
|
||||
Verification::Cancelled => {}
|
||||
Verification::Failed => hv_failed.set(true),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -194,6 +206,9 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(_) if verification_failed.get() => ui.login.login_status.set_text(&gettext(
|
||||
"The verification page couldn't be opened, so the sign-in was cancelled. Your system may keep the web view's sandbox from starting; the troubleshooting guide explains what to do.",
|
||||
)),
|
||||
Err(e) => ui.login.login_status.set_text(&e),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The human-verification (CAPTCHA) dialog.
|
||||
//! The human-verification (CAPTCHA) window.
|
||||
//!
|
||||
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||
@@ -8,10 +8,26 @@
|
||||
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||
//! browser because it reports completion by posting a message to its host, not
|
||||
//! by redirecting: a browser has nowhere to post it back to.
|
||||
//!
|
||||
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
|
||||
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
|
||||
//! on hardened systems that keep bubblewrap from the journal socket
|
||||
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
|
||||
//! The URL goes to the child on stdin, so it never shows in a process list, and
|
||||
//! the token comes back on stdout.
|
||||
|
||||
use crate::*;
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
use webkit6::prelude::*;
|
||||
|
||||
/// The argument that makes `pdfs-app` the verification window instead of the app.
|
||||
pub(crate) const VERIFY_ARG: &str = "--human-verification";
|
||||
|
||||
/// Marks the token among whatever else lands on the child's stdout: GLib and
|
||||
/// WebKit write debug output there.
|
||||
const TOKEN_PREFIX: &str = "hv-token ";
|
||||
|
||||
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||
///
|
||||
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
|
||||
});
|
||||
"#;
|
||||
|
||||
/// Present the challenge and hand the solved token back through `token_tx`.
|
||||
///
|
||||
/// Closing the dialog without solving drops the sender, which the waiting login
|
||||
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
||||
pub(crate) fn prompt_human_verification(
|
||||
ui: &Rc<Ui>,
|
||||
url: &str,
|
||||
token_tx: std::sync::mpsc::Sender<String>,
|
||||
) {
|
||||
/// How the verification window ended.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Verification {
|
||||
Solved(String),
|
||||
/// Closed without solving.
|
||||
Cancelled,
|
||||
/// Never came up, or died: most likely WebKit could not start its sandbox.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Show the challenge in the verification window and wait until it closes.
|
||||
pub(crate) async fn verify_human(url: String) -> Verification {
|
||||
match gio::spawn_blocking(move || run_verification_child(&url)).await {
|
||||
Ok(Ok(verification)) => verification,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("cannot run the verification window: {e}");
|
||||
Verification::Failed
|
||||
}
|
||||
Err(_) => Verification::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
|
||||
let mut child = Command::new(own_exe())
|
||||
.arg(VERIFY_ARG)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
// A failed write means the child is already gone; its exit status says
|
||||
// how. Dropping `stdin` closes it.
|
||||
let _ = writeln!(stdin, "{url}");
|
||||
}
|
||||
// Stops reading at the token rather than at EOF: WebKit's helper processes
|
||||
// inherit the pipe and may hold it open a moment after the child quits.
|
||||
let token = child
|
||||
.stdout
|
||||
.take()
|
||||
.and_then(|out| find_token(BufReader::new(out)));
|
||||
let status = child.wait()?;
|
||||
Ok(outcome(token, status))
|
||||
}
|
||||
|
||||
/// This very binary, so a development build opens its own window. After an
|
||||
/// upgrade replaced the file, `current_exe` names a deleted path, and the
|
||||
/// installed `pdfs-app` is the next best.
|
||||
fn own_exe() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.filter(|path| path.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
|
||||
}
|
||||
|
||||
fn find_token(out: impl BufRead) -> Option<String> {
|
||||
out.lines()
|
||||
.map_while(Result::ok)
|
||||
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
|
||||
}
|
||||
|
||||
/// A token wins over the exit status: the user solved the page, even if
|
||||
/// WebKit's teardown crashed after it.
|
||||
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
|
||||
match token {
|
||||
Some(token) => Verification::Solved(token),
|
||||
None if status.success() => Verification::Cancelled,
|
||||
None => {
|
||||
tracing::error!(%status, "the verification window failed");
|
||||
Verification::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run as the verification window: read the page's URL from stdin, show it, and
|
||||
/// print the token once the user has solved it. Closing the window exits
|
||||
/// without a token, which the app reads as a cancelled sign-in.
|
||||
pub(crate) fn run_verification_window() -> glib::ExitCode {
|
||||
let mut url = String::new();
|
||||
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
|
||||
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
let url = url.trim().to_owned();
|
||||
|
||||
// Not unique: the app that started it already holds APP_ID.
|
||||
let app = adw::Application::builder()
|
||||
.flags(gio::ApplicationFlags::NON_UNIQUE)
|
||||
.build();
|
||||
app.connect_activate(move |app| build_verification_window(app, &url));
|
||||
// GApplication would refuse VERIFY_ARG as an unknown option.
|
||||
let argv0 = std::env::args().next().unwrap_or_default();
|
||||
app.run_with_args(&[argv0])
|
||||
}
|
||||
|
||||
fn build_verification_window(app: &adw::Application, url: &str) {
|
||||
let content = webkit6::UserContentManager::new();
|
||||
content.add_script(&webkit6::UserScript::new(
|
||||
BRIDGE,
|
||||
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&adw::HeaderBar::new());
|
||||
body.append(&webview);
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title(gettext("Verification"))
|
||||
.content_width(420)
|
||||
.content_height(560)
|
||||
.icon_name(APP_ID)
|
||||
.default_width(420)
|
||||
.default_height(560)
|
||||
.content(&body)
|
||||
.build();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&header);
|
||||
body.append(&webview);
|
||||
dialog.set_child(Some(&body));
|
||||
|
||||
// Taken on the first success *or* on close, so the token is sent at most
|
||||
// once and a cancelled dialog always drops the sender.
|
||||
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
||||
|
||||
let tx = token_tx.clone();
|
||||
let dlg_weak = dialog.downgrade();
|
||||
let app = app.downgrade();
|
||||
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||
let Some(token) = extract_token(&value.to_str()) else {
|
||||
// Every `postMessage` on the page reaches here, most of them the
|
||||
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
|
||||
// is simply not ours.
|
||||
return;
|
||||
};
|
||||
if let Some(tx) = tx.borrow_mut().take() {
|
||||
let _ = tx.send(token);
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
|
||||
tracing::error!("cannot hand the verification token back: {e}");
|
||||
}
|
||||
if let Some(dlg) = dlg_weak.upgrade() {
|
||||
dlg.close();
|
||||
if let Some(app) = app.upgrade() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// Covers the close button, Escape, and the user giving up.
|
||||
dialog.connect_closed(move |_| {
|
||||
token_tx.borrow_mut().take();
|
||||
});
|
||||
|
||||
webview.load_uri(url);
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
// Returns immediately; the login worker stays blocked on the channel until
|
||||
// the handler above sends, or the dialog closes and drops the sender.
|
||||
dialog.present(parent.as_ref());
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Pull the verification token out of a message posted by the verification page.
|
||||
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_token;
|
||||
use super::{Verification, extract_token, find_token, outcome};
|
||||
use std::os::unix::process::ExitStatusExt as _;
|
||||
use std::process::ExitStatus;
|
||||
|
||||
/// The wait status of a process killed by SIGABRT.
|
||||
const ABORTED: i32 = 6;
|
||||
|
||||
#[test]
|
||||
fn a_completion_message_yields_its_token() {
|
||||
@@ -163,4 +256,39 @@ mod tests {
|
||||
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_found_among_other_output() {
|
||||
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
|
||||
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
|
||||
assert_eq!(find_token("only chatter\n".as_bytes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_closed_without_a_token_cancels() {
|
||||
assert_eq!(
|
||||
outcome(None, ExitStatus::from_raw(0)),
|
||||
Verification::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
|
||||
/// failure the app explains, not as the user giving up.
|
||||
#[test]
|
||||
fn a_window_that_died_fails() {
|
||||
assert_eq!(
|
||||
outcome(None, ExitStatus::from_raw(ABORTED)),
|
||||
Verification::Failed
|
||||
);
|
||||
let exited_1 = ExitStatus::from_raw(1 << 8);
|
||||
assert_eq!(outcome(None, exited_1), Verification::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_counts_even_if_the_window_died_after_it() {
|
||||
assert_eq!(
|
||||
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
|
||||
Verification::Solved("tok-1".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user