Improved the security of the connection between the app window and AI Studio (#1146)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-01 12:29:37 +01:00
1 parent 64dc621a7d
commit faae2951f7
26 files changed
+1056 -88

No files matched your search

+19 -3
View File
@@ -8,6 +8,7 @@ root, which applies here as well.
Key modules:
- `app_window.rs` - Tauri window management, updater integration
- `app_access.rs` - Session cookie which lets the app window into the .NET server
- `dotnet.rs` - Launches and manages the .NET sidecar process
- `runtime_api.rs` - Axum-based HTTPS API for .NET ↔ Rust communication
- `certificate.rs` - Generates self-signed TLS certificates for secure IPC
@@ -25,6 +26,21 @@ a route added afterward would be open to every process on the machine. The test
`every_route_requires_the_api_token` reads the routes from `create_router` and fails for any route
which answers without the token.
**Only the app window gets into the .NET server.** Every start makes a new UI token in `app_access.rs`.
Rust passes it to .NET as `AI_STUDIO_UI_TOKEN` through `ipc_environment` in `dotnet.rs`, and hands it to
the window as an HttpOnly session cookie before the window opens the app (`change_location_to` in
`app_window.rs`); .NET refuses every request without it. Therefore:
- Never put the token into a URL, a log line, or `startup.env`. Only a development build writes a boot
link, to `dev-boot-link.txt` in the repository root, so that another browser can get the cookie.
- Keep the cookie `Lax`. A release shows `tauri://localhost` before it opens the app, and WKWebView on
macOS withholds a `Strict` cookie on that navigation. A development build starts from the dev server
of the Tauri CLI on `127.0.0.1`, the same site as the app, which hides the problem; start it with
`cargo tauri dev --no-watch --no-dev-server` to see what a release sees.
- The .NET server listens on `127.0.0.1` only, and the cookie belongs to that host. Change the host in
`APP_HOST` and in `Program.cs` together, since no test compares them. `AppAccessRuntimeAgreementTests`
only checks that both sides use the same cookie name and environment variable.
**Runtime API handlers never block.** All calls of the .NET app share one HTTP/2 connection, and the
task driving it may wait on exactly the Tokio worker which a blocking handler occupies, so a single
blocking handler can hold up the whole app. Therefore:
@@ -47,8 +63,8 @@ blocking handler can hold up the whole app. Therefore:
## IPC Communication Flow
1. Rust runtime starts and generates TLS certificate
2. Rust starts internal HTTPS API on random port
3. Rust launches .NET sidecar, passing: API port, certificate fingerprint, API token, secret key
3. Rust launches .NET sidecar, passing: API port, certificate fingerprint, API token, secret key, UI token
4. .NET reads environment variables and establishes secure HTTPS connection to Rust
5. .NET requests an app port from Rust, starts Blazor Server on that port
6. Rust opens Tauri webview pointing to localhost:app_port
5. .NET requests an app port from Rust, starts Blazor Server on 127.0.0.1 at that port
6. Rust hands the session cookie to the Tauri webview and points it to 127.0.0.1:app_port
7. Bi-directional communication: .NET ↔ Rust via HTTPS API
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "Default capability for MindWork AI Studio",
"remote": {
"urls": [
"http://localhost:*"
"http://127.0.0.1:*"
]
},
"windows": [
+171
View File
@@ -0,0 +1,171 @@
use log::{error, info};
use once_cell::sync::Lazy;
use tauri::webview::Cookie;
use tauri::{Url, WebviewWindow};
use crate::api_token::{generate_api_token, APIToken};
/// The token which the app window presents to the .NET server as the value of its session cookie.
/// Every start makes a new one.
pub static UI_ACCESS_TOKEN: Lazy<APIToken> = Lazy::new(generate_api_token);
/// The name of the session cookie. The .NET server expects the same name.
pub const SESSION_COOKIE_NAME: &str = "ai_studio_session";
/// The environment variable which carries the token to the .NET server.
pub const DOTNET_ENV_UI_TOKEN: &str = "AI_STUDIO_UI_TOKEN";
/// The host of the app. The .NET server listens on this address only, and the session cookie
/// belongs to it.
pub const APP_HOST: &str = "127.0.0.1";
/// The file in the root of the repository which holds the boot link of a development build.
#[cfg(debug_assertions)]
const DEV_BOOT_LINK_FILE: &str = "dev-boot-link.txt";
/// Builds the session cookie of the app window.
///
/// The cookie is for the app host alone and lives as long as the window: it has no expiry date,
/// and scripts cannot read it. The window never sends it with a request which a page of another
/// site makes, such as a script the page loads or a form it posts.
///
/// The cookie is `Lax`, not `Strict`. Before the window opens the app, it shows a page of Tauri,
/// `tauri://localhost` on macOS, which is another site than the app. WKWebView counts that page
/// as the one which opened the app, and it would withhold a `Strict` cookie. A `Lax` cookie goes
/// along when a whole page of the app opens. Development builds show the page of the Tauri dev
/// server on the app host instead, which hides the difference.
///
/// We parse the cookie from text because the `cookie` crate, whose builder would need its
/// `SameSite` type, is no direct dependency of ours.
pub fn session_cookie() -> Cookie<'static> {
let token = UI_ACCESS_TOKEN.to_hex_text();
Cookie::parse(format!("{SESSION_COOKIE_NAME}={token}; Domain={APP_HOST}; Path=/; HttpOnly; SameSite=Lax"))
.expect("The session cookie consists of fixed attributes and a hexadecimal token, which always parse.")
}
/// Puts the session cookie into the cookie store of the given window, before the window opens
/// the app at the given URL.
///
/// Setting the cookie does not wait for the webview, and WebView2 even adds it in the background.
/// Reading the cookies back waits until the webview has handled everything sent before, so the
/// cookie is in place once this function returns. That reading is also the only way to learn
/// whether setting the cookie worked.
///
/// Blocks until the main thread has answered, so never call it on a worker of the async runtime.
/// The log never shows the value of the cookie.
pub fn hand_over_session_cookie(window: &WebviewWindow, app_url: &Url) {
if let Err(error) = window.set_cookie(session_cookie()) {
error!(Source = "App access"; "Failed to hand the session cookie to the app window: {error}");
return;
}
match app_host_cookies(window, app_url) {
Ok(cookies) if cookies.iter().any(|cookie| cookie.name() == SESSION_COOKIE_NAME) => {
info!(Source = "App access"; "The app window got its session cookie.");
},
Ok(_) => {
error!(Source = "App access"; "The app window lacks its session cookie after it was set.");
},
Err(error) => {
error!(Source = "App access"; "Failed to check the session cookie of the app window: {error}");
},
}
}
/// Reads the cookies of the app host from the given window.
///
/// On macOS, wry picks the cookies for a URL by comparing their domain with the domain of the
/// URL. An IP address such as the app host has no domain in that sense, so wry would never find
/// a cookie of the app there. We read all cookies instead and pick those of the app host
/// ourselves.
#[cfg(target_os = "macos")]
fn app_host_cookies(window: &WebviewWindow, _app_url: &Url) -> tauri::Result<Vec<Cookie<'static>>> {
Ok(window.cookies()?.into_iter().filter(|cookie| cookie.domain() == Some(APP_HOST)).collect())
}
/// Reads the cookies of the app host from the given window.
///
/// WebView2 and WebKitGTK pick the cookies for a URL by themselves. Reading all cookies instead
/// would need WebKitGTK 2.42 or newer.
#[cfg(not(target_os = "macos"))]
fn app_host_cookies(window: &WebviewWindow, app_url: &Url) -> tauri::Result<Vec<Cookie<'static>>> {
window.cookies_for_url(app_url.clone())
}
/// Returns the link with which another browser gets the session cookie of a development build,
/// such as the browser of an agent which checks a change in the running app.
#[cfg(debug_assertions)]
fn dev_boot_link(port: u16) -> String {
format!("http://{APP_HOST}:{port}/dev/boot?token={}", UI_ACCESS_TOKEN.to_hex_text())
}
/// Writes the boot link of a development build into a file of its own in the root of the
/// repository. It never goes into the startup env file: that file holds the secrets of the IPC,
/// which a browser needs none of.
///
/// Only development builds contain this function. A release has no boot link, and its .NET
/// server knows no route which would take one. The log names the file, never the link.
#[cfg(debug_assertions)]
pub fn write_dev_boot_link(port: u16) {
let path = std::path::PathBuf::from("..").join(DEV_BOOT_LINK_FILE);
match std::fs::write(&path, dev_boot_link(port)) {
Ok(_) => info!(Source = "App access"; "Wrote the boot link of this development build to '{}'.", path.display()),
Err(error) => error!(Source = "App access"; "Failed to write the boot link of this development build to '{}': {error}", path.display()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_session_cookie_carries_the_token() {
let cookie = session_cookie();
assert_eq!(cookie.name(), SESSION_COOKIE_NAME);
assert_eq!(cookie.value(), UI_ACCESS_TOKEN.to_hex_text());
}
#[test]
fn the_session_cookie_belongs_to_the_app_host_alone() {
let cookie = session_cookie();
assert_eq!(cookie.domain(), Some(APP_HOST));
assert_eq!(cookie.path(), Some("/"));
}
#[test]
fn the_session_cookie_is_hidden_from_scripts_and_other_sites() {
let cookie = session_cookie();
assert_eq!(cookie.http_only(), Some(true));
assert!(cookie.same_site().is_some_and(|same_site| same_site.is_lax()));
}
#[test]
fn the_session_cookie_lives_only_as_long_as_the_window() {
let cookie = session_cookie();
assert!(cookie.expires().is_none());
assert!(cookie.max_age().is_none());
}
#[test]
#[cfg(debug_assertions)]
fn the_dev_boot_link_leads_to_the_boot_route_of_the_app_host() {
let link = Url::parse(dev_boot_link(61153).as_str()).unwrap();
assert_eq!(link.host_str(), Some(APP_HOST));
assert_eq!(link.port(), Some(61153));
assert_eq!(link.path(), "/dev/boot");
let token = link.query_pairs().find(|(key, _)| key == "token").map(|(_, value)| value.into_owned());
assert_eq!(token.as_deref(), Some(UI_ACCESS_TOKEN.to_hex_text()));
}
#[test]
fn the_ui_token_differs_from_the_api_token() {
assert!(!UI_ACCESS_TOKEN.validate(&APIToken::from_hex_text(crate::runtime_api_token::API_TOKEN.to_hex_text())));
}
}
+27 -11
View File
@@ -21,6 +21,7 @@ use tauri_plugin_updater::{UpdaterExt, Update};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::broadcast;
use tokio::time;
use crate::app_access::hand_over_session_cookie;
use crate::clipboard::shutdown_clipboard;
use crate::data_restore::apply_pending_restore;
use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server};
@@ -312,7 +313,7 @@ fn is_tauri_asset_url(url: &tauri::Url) -> bool {
matches!(url.scheme(), "http" | "https") && is_tauri_asset_host(url.host_str())
}
fn is_local_http_url(url: &tauri::Url) -> bool {
pub(crate) fn is_local_http_url(url: &tauri::Url) -> bool {
matches!(url.scheme(), "http" | "https") && is_local_host(url.host_str())
}
@@ -677,8 +678,9 @@ pub fn send_event(event: Event) {
}
}
/// Changes the location of the main window to the given URL.
pub async fn change_location_to(url: &str) {
/// Changes the location of the main window to the given URL. The window gets its session cookie
/// first, so that the .NET server lets it in.
pub async fn change_location_to(url: tauri::Url) {
// Try to get the main window. If it is not available yet, wait for it:
let mut main_window_ready = false;
let mut main_window_status_reported = false;
@@ -700,16 +702,30 @@ pub async fn change_location_to(url: &str) {
}
}
if let Ok(parsed_url) = tauri::Url::parse(url) && is_local_http_url(&parsed_url) {
*APPROVED_APP_URL.lock().unwrap() = Some(parsed_url);
if is_local_http_url(&url) {
*APPROVED_APP_URL.lock().unwrap() = Some(url.clone());
}
let js_location_change = format!("window.location = '{url}';");
let main_window = main_window_spawn_clone.lock().unwrap();
let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str());
match location_change_result {
Ok(_) => info!("The app location was changed to {url}."),
Err(e) => error!("Failed to change the app location to {url}: {e}."),
//
// The window navigates by itself instead of through a script of the page it shows. That page
// comes from Tauri, which is another site than the app; app_access::session_cookie explains
// why the session cookie goes along all the same. We take the window out of the lock first:
// the cookie and the navigation need the main thread, which may wait for this lock meanwhile.
//
// Handing over the cookie waits for the main thread. This function serves a request of the
// runtime API, so the waiting happens outside the async runtime, see runtime/AGENTS.md.
//
let main_window = main_window_spawn_clone.lock().unwrap().clone().unwrap();
let navigation = tokio::task::spawn_blocking(move || {
hand_over_session_cookie(&main_window, &url);
match main_window.navigate(url.clone()) {
Ok(_) => info!("The app location was changed to {url}."),
Err(e) => error!("Failed to change the app location to {url}: {e}."),
}
});
if let Err(error) = navigation.await {
error!("Failed to open the app in the main window: {error}");
}
}
+76 -23
View File
@@ -9,6 +9,7 @@ use tauri::{Manager, Url};
use tauri_plugin_shell::process::{CommandChild, CommandEvent, TerminatedPayload};
use tauri_plugin_shell::ShellExt;
use crate::runtime_api_token::API_TOKEN;
use crate::app_access::{APP_HOST, DOTNET_ENV_UI_TOKEN, UI_ACCESS_TOKEN};
use crate::app_window::change_location_to;
use crate::runtime_certificate::CERTIFICATE_FINGERPRINT;
use crate::encryption::ENCRYPTION;
@@ -177,46 +178,43 @@ fn dotnet_bundle_extraction_directory<R: tauri::Runtime>(app_handle: &tauri::App
/// but also remove that code from any production build.
#[cfg(debug_assertions)]
pub fn create_startup_env_file() {
// Get the secret password & salt and convert it to a base64 string:
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
let api_port = *API_SERVER_PORT;
warn!(Source = "Bootloader .NET"; "Development environment detected; create the startup env file at '../startup.env'.");
let env_file_path = std::path::PathBuf::from("..").join("startup.env");
let mut env_file = std::fs::File::create(env_file_path).unwrap();
let mut env_file_lines = vec![
format!("AI_STUDIO_SECRET_PASSWORD={secret_password}"),
format!("AI_STUDIO_SECRET_KEY_SALT={secret_key_salt}"),
format!("AI_STUDIO_CERTIFICATE_FINGERPRINT={}", CERTIFICATE_FINGERPRINT.get().unwrap()),
format!("AI_STUDIO_API_PORT={api_port}"),
format!("AI_STUDIO_API_TOKEN={}", API_TOKEN.to_hex_text()),
];
for (key, value) in external_http_custom_root_certificate_policy_environment() {
env_file_lines.push(format!("{key}={value}"));
}
let env_file_lines: Vec<String> = ipc_environment()
.into_iter()
.map(|(key, value)| format!("{key}={value}"))
.collect();
std::io::Write::write_all(&mut env_file, env_file_lines.join("\n").as_bytes()).unwrap();
info!(Source = "Bootloader .NET"; "The startup env file was created successfully.");
}
/// Starts the .NET server in a separate process.
pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
/// Returns the environment which the .NET server needs to talk to this runtime and to let only
/// the app window in. The sidecar gets it, and so does the startup env file in the development
/// environment, so neither of them can miss a variable.
fn ipc_environment() -> Vec<(String, String)> {
// Get the secret password & salt and convert it to a base64 string:
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
let api_port = *API_SERVER_PORT;
let mut dotnet_server_environment: HashMap<String, String> = HashMap::from_iter([
let mut environment = vec![
(String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password),
(String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt),
(String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()),
(String::from("AI_STUDIO_API_PORT"), format!("{api_port}")),
(String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()),
]);
dotnet_server_environment.extend(external_http_custom_root_certificate_policy_environment());
(String::from(DOTNET_ENV_UI_TOKEN), UI_ACCESS_TOKEN.to_hex_text().to_string()),
];
environment.extend(external_http_custom_root_certificate_policy_environment());
environment
}
/// Starts the .NET server in a separate process.
pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
let mut dotnet_server_environment: HashMap<String, String> = HashMap::from_iter(ipc_environment());
if let Some(extraction_directory) = dotnet_bundle_extraction_directory(&app_handle) {
dotnet_server_environment.insert(String::from(DOTNET_ENV_BUNDLE_EXTRACT_BASE_DIR), extraction_directory);
}
@@ -303,7 +301,12 @@ pub async fn dotnet_ready() {
}
let dotnet_server_port = *DOTNET_SERVER_PORT;
let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str())
// A development build lets other browsers in, too, such as the one of an agent:
#[cfg(debug_assertions)]
tokio::task::spawn_blocking(move || crate::app_access::write_dev_boot_link(dotnet_server_port));
let url = match Url::parse(app_address(dotnet_server_port).as_str())
{
Ok(url) => url,
Err(msg) => {
@@ -312,7 +315,14 @@ pub async fn dotnet_ready() {
}
};
change_location_to(url.as_str()).await;
change_location_to(url).await;
}
/// Returns the address of the app on the .NET server. The server listens on the IPv4 loopback
/// address only, so the window goes there directly instead of resolving `localhost`, which may
/// answer with the IPv6 loopback address first.
fn app_address(port: u16) -> String {
format!("http://{APP_HOST}:{port}/")
}
/// Stops the .NET server process.
@@ -336,4 +346,47 @@ pub fn cleanup_dotnet_server() {
if let Err(e) = kill_stale_process(pid_path, SIDECAR_TYPE) {
warn!(Source = ".NET"; "Error during the cleanup of .NET: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_window::is_local_http_url;
#[test]
fn the_app_address_is_the_ipv4_loopback_address() {
let url = Url::parse(app_address(61153).as_str()).unwrap();
assert_eq!(url.host_str(), Some("127.0.0.1"));
assert_eq!(url.port(), Some(61153));
assert_eq!(url.path(), "/");
}
#[test]
fn the_app_address_counts_as_local() {
let url = Url::parse(app_address(61153).as_str()).unwrap();
assert!(is_local_http_url(&url));
}
#[test]
fn the_ipc_environment_carries_every_variable_the_dotnet_server_needs() {
// Only the start of the app generates the certificate this fingerprint belongs to:
let _ = CERTIFICATE_FINGERPRINT.set(String::from("test-fingerprint"));
let environment = ipc_environment();
let keys: Vec<&str> = environment.iter().map(|(key, _)| key.as_str()).collect();
for expected in [
"AI_STUDIO_SECRET_PASSWORD",
"AI_STUDIO_SECRET_KEY_SALT",
"AI_STUDIO_CERTIFICATE_FINGERPRINT",
"AI_STUDIO_API_PORT",
"AI_STUDIO_API_TOKEN",
DOTNET_ENV_UI_TOKEN,
] {
assert!(keys.contains(&expected), "The environment lacks {expected}.");
}
let ui_token = environment.iter().find(|(key, _)| key == DOTNET_ENV_UI_TOKEN).map(|(_, value)| value.as_str());
assert_eq!(ui_token, Some(UI_ACCESS_TOKEN.to_hex_text()));
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod environment;
pub mod dotnet;
pub mod network;
pub mod api_token;
pub mod app_access;
pub mod app_window;
pub mod secret;
pub mod clipboard;