Added a boot link for development builds

This commit is contained in:
Thorsten Sommer committed 2026-10-31 19:20:34 +01:00
1 parent 42ed1db4a4
commit f06d2f553b
6 files changed
+155 -8

No files matched your search

+3
View File
@@ -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
@@ -7,8 +7,9 @@ namespace AIStudio.Tools.Security;
/// </summary>
/// <remarks>
/// 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
/// <summary>
/// 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.
/// </summary>
private const string DEV_BOOT_PATH = "/dev/boot";
/// <summary>
/// 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.
/// </summary>
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);
@@ -64,11 +64,13 @@ public sealed class AppAccessPolicy(int appPort, string uiToken)
/// </remarks>
/// <param name="request">The request.</param>
/// <returns>True when the request comes from the app window.</returns>
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);
}
/// <summary>
/// Checks whether the given value is the token of this start, in a time which does not depend
/// on where a wrong value differs.
/// </summary>
/// <param name="value">The value to check.</param>
/// <returns>True when the value is the token.</returns>
public bool IsUIToken(string? value) => !string.IsNullOrEmpty(value) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes);
}
@@ -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<HttpResponse> BootAsync(string method, string? token)
{
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.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
}
+39
View File
@@ -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())));
+5
View File
@@ -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,