diff --git a/.gitignore b/.gitignore index f5e67cafd..3e96a077f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Ignore any startup.env file: startup.env +# Ignore the boot link of development builds: +dev-boot-link.txt + # Ignore pdfium library: libpdfium.dylib libpdfium.so diff --git a/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs b/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs index 937d98855..3711600de 100644 --- a/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs +++ b/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs @@ -7,8 +7,9 @@ namespace AIStudio.Tools.Security; /// /// /// The middleware runs before every other one, so it covers pages, static files, the Blazor hub -/// with all of its transports, and every endpoint alike. There are no exceptions: a request without -/// the session cookie gets refused, whatever it asks for. +/// with all of its transports, and every endpoint alike. A release build makes no exception: a +/// request without the session cookie gets refused, whatever it asks for. Only a development build +/// also takes its boot link, with which another browser gets the cookie. /// /// A request which carries an Origin header must, in addition, come from a page of the app. /// Browsers send that header with requests made by a script, the WebSocket of the Blazor hub @@ -45,6 +46,14 @@ public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy po return; } +#if DEBUG + if (HttpMethods.IsGet(context.Request.Method) && context.Request.Path.Equals(DEV_BOOT_PATH, StringComparison.Ordinal)) + { + await this.BootAsync(context); + return; + } +#endif + if (!policy.HasSessionCookie(context.Request)) { await this.RefuseAsync(context, "a client without the session cookie"); @@ -54,6 +63,38 @@ public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy po await next(context); } +#if DEBUG + /// + /// The path of the boot link which development builds write for other browsers, such as the + /// one of an agent. A release build knows neither the link nor this path. + /// + private const string DEV_BOOT_PATH = "/dev/boot"; + + /// + /// Trades the token of a boot link for the session cookie and sends the browser on to the app. + /// The browser opens the link by itself, so it sends the cookie along after the redirect. + /// + private async Task BootAsync(HttpContext context) + { + var token = context.Request.Query["token"].ToString(); + if (!policy.IsUIToken(token)) + { + await this.RefuseAsync(context, "a boot link with a wrong token"); + return; + } + + context.Response.Cookies.Append(AppAccessPolicy.SESSION_COOKIE_NAME, token, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Path = "/", + IsEssential = true + }); + + context.Response.Redirect("/"); + } +#endif + private async Task RefuseAsync(HttpContext context, string reason) { this.LogRefusal(context, reason); diff --git a/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs b/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs index 4546e185b..925d62a28 100644 --- a/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs +++ b/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs @@ -64,11 +64,13 @@ public sealed class AppAccessPolicy(int appPort, string uiToken) /// /// The request. /// True when the request comes from the app window. - public bool HasSessionCookie(HttpRequest request) - { - if (!request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) || string.IsNullOrEmpty(value)) - return false; + public bool HasSessionCookie(HttpRequest request) => request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) && this.IsUIToken(value); - return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes); - } + /// + /// Checks whether the given value is the token of this start, in a time which does not depend + /// on where a wrong value differs. + /// + /// The value to check. + /// True when the value is the token. + public bool IsUIToken(string? value) => !string.IsNullOrEmpty(value) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes); } \ No newline at end of file diff --git a/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs b/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs index 4cfa80337..1f73a3dab 100644 --- a/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs +++ b/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs @@ -116,4 +116,61 @@ public sealed class AppAccessMiddlewareTests { Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, "http://127.0.0.1:61153", "http://rebind.example:61153"), Is.EqualTo(StatusCodes.Status403Forbidden)); } + +#if DEBUG + private static async Task BootAsync(string method, string? token) + { + var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger.Instance); + var context = new DefaultHttpContext { Request = { Method = method, Path = "/dev/boot" } }; + if (token is not null) + context.Request.QueryString = QueryString.Create("token", token); + + await middleware.InvokeAsync(context); + return context.Response; + } + + [Test] + public async Task TheBootLinkOfADevelopmentBuildHandsOutTheSessionCookie() + { + var response = await BootAsync(HttpMethods.Get, UI_TOKEN); + var cookie = response.Headers.SetCookie.ToString().ToLowerInvariant(); + + Assert.Multiple(() => + { + Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status302Found)); + Assert.That(response.Headers.Location.ToString(), Is.EqualTo("/")); + Assert.That(cookie, Does.StartWith($"{AppAccessPolicy.SESSION_COOKIE_NAME}={UI_TOKEN}")); + Assert.That(cookie, Does.Contain("httponly")); + Assert.That(cookie, Does.Contain("samesite=strict")); + Assert.That(cookie, Does.Contain("path=/")); + Assert.That(cookie, Does.Not.Contain("expires")); + }); + } + + [TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee")] + [TestCase("")] + [TestCase(null)] + public async Task TheBootLinkWithAWrongTokenIsRefused(string? token) + { + var response = await BootAsync(HttpMethods.Get, token); + + Assert.Multiple(() => + { + Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden)); + Assert.That(response.Headers.SetCookie.ToString(), Is.Empty); + }); + } + + [Test] + public async Task TheBootLinkAnswersOnlyAGet() + { + var response = await BootAsync(HttpMethods.Post, UI_TOKEN); + + Assert.Multiple(() => + { + Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden)); + Assert.That(response.Headers.SetCookie.ToString(), Is.Empty); + }); + } +#endif } \ No newline at end of file diff --git a/runtime/src/app_access.rs b/runtime/src/app_access.rs index b0bea703d..76a6b718e 100644 --- a/runtime/src/app_access.rs +++ b/runtime/src/app_access.rs @@ -18,6 +18,10 @@ pub const DOTNET_ENV_UI_TOKEN: &str = "AI_STUDIO_UI_TOKEN"; /// 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, @@ -61,6 +65,28 @@ pub fn hand_over_session_cookie(window: &WebviewWindow, app_url: &Url) { } } +/// 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::*; @@ -97,6 +123,19 @@ mod tests { 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()))); diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 0bb71483a..e405a8891 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -301,6 +301,11 @@ pub async fn dotnet_ready() { } let dotnet_server_port = *DOTNET_SERVER_PORT; + + // 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,