mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-02 08:07:21 +00:00
Added a boot link for development builds
This commit is contained in:
6 files changed
+155
-8
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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())));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user