mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 07:27:22 +00:00
Improved the security of the connection between the app window and AI Studio (#1146)
This commit is contained in:
1 parent
64dc621a7d
commit
faae2951f7
26 files changed
+1056
-88
No files matched your search
+3
-1
@@ -37,8 +37,10 @@ Key structure:
|
||||
- **Long menus** - MudBlazor keeps a `MudSelect` inside the window by itself, but never a `MudMenu`: a menu taller than the window runs past its edge, and its lower entries cannot be reached. Give every `MudMenu` whose entries come from data, such as the assistants or the profiles, `MaxHeight="@MenuDefaults.MAX_HEIGHT"`, so that it scrolls.
|
||||
- **Growing popovers** - Since version 9, MudBlazor places an open popover again whenever its content changes size. Content which unfolds later, such as a `MudCollapse` or an `ExpansionPanel` inside a `MudPopover`, therefore stays inside the window and needs no code of its own.
|
||||
- **Encryption** - Initialized before Rust service is marked ready
|
||||
- **Access to the app server** - Only the app window gets into the Blazor server. `AppAccessMiddleware` runs first in `Program.cs`, right after the host filter, and refuses every request without the session cookie of the window or with an origin other than the app's own. Add no exception for any path, not even for static files: `AppAccessPipelineTests` checks the order in `Program.cs`. Since nothing but the window holds the cookie, the app never sends a request to its own server: read files below `wwwroot/` through `WebRootFiles`. The system browser gets no cookie either, so never open a URL of the app server there; offer a save dialog through `RustService` instead, as the ERI Server assistant does for its specification. `runtime/AGENTS.md` describes the runtime side.
|
||||
- **Debug environment** - Reads `startup.env` file with IPC credentials
|
||||
- **Static files and the browser cache** - When the developer starts the app from Rider, the embedded browser of Tauri keeps the static files under `wwwroot/`, such as `app.css` or a script, in its cache. The app then still shows the old file, and a change seems not to work. Whenever you changed such a file, tell the developer to clear that cache through the developer tools of the embedded browser before testing. Users of a release are not affected.
|
||||
- **Boot link** - Once the .NET server is ready, a development build writes `dev-boot-link.txt` into the repository root. Opening that link hands another browser the session cookie. Open it only when the developer asks for it: the development build shows the data of the installed app.
|
||||
- **Static files and the browser cache** - When the developer starts the app from Rider, the embedded browser of Tauri keeps the static files under `wwwroot/`, such as `app.css` or a script, in its cache. The app then still shows the old file, and a change seems not to work. Whenever you changed such a file, tell the developer to clear that cache through the developer tools of the embedded browser before testing, and only the cache: the website data hold the session cookie, and without it the window shows "This window cannot access AI Studio. Please restart AI Studio." until the .NET server starts again. Users of a release are not affected.
|
||||
- **Production environment** - Runtime launches .NET sidecar with environment variables
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -116,8 +116,8 @@ else
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Disabled="@this.IsSpecificationDownloadDisabled" Href="@this.selectedERIVersion.SpecificationURL()" Target="_blank">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Link" Class="mr-2"/> @T("Download specification")
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Disabled="@this.IsSpecificationSavingDisabled" OnClick="@this.SaveSpecificationAsync">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Save" Class="mr-2"/> @T("Save specification")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using AIStudio.Dialogs;
|
||||
using AIStudio.Dialogs.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -13,7 +14,7 @@ namespace AIStudio.Assistants.ERI;
|
||||
public partial class AssistantERI : AssistantBaseCore<SettingsDialogERIServer>
|
||||
{
|
||||
[Inject]
|
||||
private HttpClient HttpClient { get; set; } = null!;
|
||||
private WebRootFiles WebRootFiles { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
@@ -375,9 +376,43 @@ public partial class AssistantERI : AssistantBaseCore<SettingsDialogERIServer>
|
||||
private bool IsERIInputDisabled => this.IsNoneERIServerSelected || this.IsProcessing;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the selected ERI specification cannot be downloaded.
|
||||
/// Gets whether the selected ERI specification cannot be saved.
|
||||
/// </summary>
|
||||
private bool IsSpecificationDownloadDisabled => !this.selectedERIVersion.WasSpecificationSelected() || this.IsERIInputDisabled;
|
||||
private bool IsSpecificationSavingDisabled => !this.selectedERIVersion.WasSpecificationSelected() || this.IsERIInputDisabled;
|
||||
|
||||
/// <summary>
|
||||
/// Lets the user save the selected ERI specification, a copy of the one the app carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The app server answers the app window only, so a link to the file would fail in the browser
|
||||
/// of the system. The copy also matches the installed version of AI Studio.
|
||||
/// </remarks>
|
||||
private async Task SaveSpecificationAsync()
|
||||
{
|
||||
var specificationPath = this.selectedERIVersion.SpecificationURL();
|
||||
var response = await this.RustService.SaveFile(T("Save the ERI specification"), [FileTypes.JSON], Path.GetFileName(specificationPath));
|
||||
if (response.UserCancelled)
|
||||
return;
|
||||
|
||||
await using var feedback = new FileExportFeedback();
|
||||
feedback.ShowRunning(response.SaveFilePath);
|
||||
try
|
||||
{
|
||||
if (await this.WebRootFiles.CopyToAsync(specificationPath, response.SaveFilePath))
|
||||
{
|
||||
await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, T("The ERI specification was saved."), response.SaveFilePath, true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.Logger.LogError("The app carries no ERI specification at '{SpecificationPath}'.", specificationPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.Logger.LogError(exception, "Failed to save the ERI specification {ERIVersion}.", this.selectedERIVersion);
|
||||
}
|
||||
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, T("The ERI specification could not be saved.")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the generated-code target directory selection should be disabled.
|
||||
@@ -843,7 +878,7 @@ public partial class AssistantERI : AssistantBaseCore<SettingsDialogERIServer>
|
||||
var baseDirectorySnapshot = this.baseDirectory;
|
||||
var previouslyGeneratedFilesSnapshot = this.previouslyGeneratedFiles.ToArray();
|
||||
|
||||
this.eriSpecification = await this.selectedERIVersion.ReadSpecification(this.HttpClient);
|
||||
this.eriSpecification = await this.selectedERIVersion.ReadSpecification(this.WebRootFiles);
|
||||
if (string.IsNullOrWhiteSpace(this.eriSpecification))
|
||||
{
|
||||
this.AddInputIssue(T("The ERI specification could not be loaded. Please try again later."));
|
||||
|
||||
@@ -2,26 +2,14 @@ namespace AIStudio.Assistants.ERI;
|
||||
|
||||
public static class ERIVersionExtensions
|
||||
{
|
||||
public static async Task<string> ReadSpecification(this ERIVersion version, HttpClient httpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = version.SpecificationURL();
|
||||
using var response = await httpClient.GetAsync(url);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<string> ReadSpecification(this ERIVersion version, WebRootFiles webRootFiles) => webRootFiles.ReadTextAsync(version.SpecificationURL());
|
||||
|
||||
public static string SpecificationURL(this ERIVersion version)
|
||||
{
|
||||
var nameLower = version.ToString().ToLowerInvariant();
|
||||
var filename = $"{nameLower}.json";
|
||||
return $"specs/eri/{filename}";
|
||||
}
|
||||
|
||||
|
||||
public static bool WasSpecificationSelected(this ERIVersion version) => version != ERIVersion.NONE;
|
||||
}
|
||||
@@ -1537,12 +1537,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Opera
|
||||
-- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected."
|
||||
|
||||
-- The ERI specification was saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "The ERI specification was saved."
|
||||
|
||||
-- Save the ERI specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "Save the ERI specification"
|
||||
|
||||
-- The ERI specification could not be loaded. Please try again later.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "The ERI specification could not be loaded. Please try again later."
|
||||
|
||||
-- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions."
|
||||
|
||||
-- The ERI specification could not be saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "The ERI specification could not be saved."
|
||||
|
||||
-- Are you sure you want to delete the ERI server preset '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Are you sure you want to delete the ERI server preset '{0}'?"
|
||||
|
||||
@@ -1672,9 +1681,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Yes,
|
||||
-- Common ERI server settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Common ERI server settings"
|
||||
|
||||
-- Download specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Download specification"
|
||||
|
||||
-- Are you sure you want to delete the embedding '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Are you sure you want to delete the embedding '{0}'?"
|
||||
|
||||
@@ -1741,6 +1747,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI se
|
||||
-- Are you sure you want to delete the retrieval process '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Are you sure you want to delete the retrieval process '{0}'?"
|
||||
|
||||
-- Save specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Save specification"
|
||||
|
||||
-- Data source: hostname
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Data source: hostname"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace AIStudio.Components;
|
||||
public partial class Changelog : MSGComponentBase
|
||||
{
|
||||
[Inject]
|
||||
private HttpClient HttpClient { get; set; } = null!;
|
||||
private WebRootFiles WebRootFiles { get; set; } = null!;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
@@ -23,7 +23,6 @@ public partial class Changelog : MSGComponentBase
|
||||
|
||||
private async Task ReadLogAsync()
|
||||
{
|
||||
using var response = await this.HttpClient.GetAsync($"changelog/{this.SelectedLog.Filename}");
|
||||
this.LogContent = await response.Content.ReadAsStringAsync();
|
||||
this.LogContent = await this.WebRootFiles.ReadTextAsync($"changelog/{this.SelectedLog.Filename}");
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace AIStudio.Pages;
|
||||
public partial class Home : MSGComponentBase
|
||||
{
|
||||
[Inject]
|
||||
private HttpClient HttpClient { get; init; } = null!;
|
||||
private WebRootFiles WebRootFiles { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager NavigationManager { get; init; } = null!;
|
||||
@@ -222,8 +222,7 @@ public partial class Home : MSGComponentBase
|
||||
private async Task ReadLastChangeAsync()
|
||||
{
|
||||
var latest = Changelog.LOGS.MaxBy(n => n.Build);
|
||||
using var response = await this.HttpClient.GetAsync($"changelog/{latest.Filename}");
|
||||
this.LastChangeContent = await response.Content.ReadAsStringAsync();
|
||||
this.LastChangeContent = await this.WebRootFiles.ReadTextAsync($"changelog/{latest.Filename}");
|
||||
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
+12
-3
@@ -1539,12 +1539,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Betri
|
||||
-- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "Sie können zusätzliche Bibliotheken integrieren. Vielleicht möchten Sie die Prompts im Voraus mit einer Machine-Learning-Methode auswerten oder sie mithilfe eines Text-Mining-Ansatzes analysieren? Oder möchten Sie vielleicht Bilder in den Prompts vorverarbeiten? Für solche erweiterten Szenarien können Sie hier angeben, welche Bibliotheken Sie verwenden möchten. Beschreiben Sie am besten, welche Bibliothek Sie für welchen Zweck einbinden möchten. So kann das LLM, das den ERI-Server für Sie erstellt, versuchen, diese Bibliotheken effektiv zu nutzen. Dadurch sollte weniger Nacharbeit nötig sein. Falls Sie die benötigten Bibliotheken nicht kennen, können Sie stattdessen versuchen, den geplanten Einsatzzweck zu beschreiben. Das LLM kann dann versuchen, passende Bibliotheken auszuwählen. Allerdings können dabei Halluzinationen auftreten und fiktive Bibliotheken gewählt werden."
|
||||
|
||||
-- The ERI specification was saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "Die ERI-Spezifikation wurde gespeichert."
|
||||
|
||||
-- Save the ERI specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "ERI-Spezifikation speichern"
|
||||
|
||||
-- The ERI specification could not be loaded. Please try again later.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "Die ERI-Spezifikation konnte nicht geladen werden. Bitte versuchen Sie es später erneut."
|
||||
|
||||
-- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "Sie können es sich so vorstellen: Hypothetisch gesehen würde die Wikipedia beim Einsatz von ERI alle Seiten mithilfe einer Einbettungsmethode „vektorisieren“. Alle Daten von Wikipedia, einschließlich der Vektordatenbank, würden bei der Wikipedia bleiben (dezentraler Ansatz). Danach könnte jeder Nutzer von MindWork AI Studio Wikipedia als Datenquelle hinzufügen, um Halluzinationen des LLM bei Wissensfragen deutlich zu reduzieren."
|
||||
|
||||
-- The ERI specification could not be saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "Die ERI-Spezifikation konnte nicht gespeichert werden."
|
||||
|
||||
-- Are you sure you want to delete the ERI server preset '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Sind Sie sicher, dass Sie die ERI-Server-Voreinstellung „{0}“ löschen möchten?"
|
||||
|
||||
@@ -1674,9 +1683,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Ja, b
|
||||
-- Common ERI server settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Allgemeine ERI-Server-Einstellungen"
|
||||
|
||||
-- Download specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Spezifikation herunterladen"
|
||||
|
||||
-- Are you sure you want to delete the embedding '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Möchten Sie die Einbettung „{0}“ wirklich löschen?"
|
||||
|
||||
@@ -1743,6 +1749,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI-Se
|
||||
-- Are you sure you want to delete the retrieval process '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Sind Sie sicher, dass Sie den Abrufprozess „{0}“ löschen möchten?"
|
||||
|
||||
-- Save specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Spezifikation speichern"
|
||||
|
||||
-- Data source: hostname
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Datenquelle: Hostname"
|
||||
|
||||
|
||||
+12
-3
@@ -1539,12 +1539,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Opera
|
||||
-- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected."
|
||||
|
||||
-- The ERI specification was saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "The ERI specification was saved."
|
||||
|
||||
-- Save the ERI specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "Save the ERI specification"
|
||||
|
||||
-- The ERI specification could not be loaded. Please try again later.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "The ERI specification could not be loaded. Please try again later."
|
||||
|
||||
-- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions."
|
||||
|
||||
-- The ERI specification could not be saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "The ERI specification could not be saved."
|
||||
|
||||
-- Are you sure you want to delete the ERI server preset '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Are you sure you want to delete the ERI server preset '{0}'?"
|
||||
|
||||
@@ -1674,9 +1683,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Yes,
|
||||
-- Common ERI server settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Common ERI server settings"
|
||||
|
||||
-- Download specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Download specification"
|
||||
|
||||
-- Are you sure you want to delete the embedding '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Are you sure you want to delete the embedding '{0}'?"
|
||||
|
||||
@@ -1743,6 +1749,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI se
|
||||
-- Are you sure you want to delete the retrieval process '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Are you sure you want to delete the retrieval process '{0}'?"
|
||||
|
||||
-- Save specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Save specification"
|
||||
|
||||
-- Data source: hostname
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Data source: hostname"
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ using AIStudio.Tools.Web;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Server.Circuits;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
|
||||
@@ -32,11 +33,6 @@ using MudBlazor.Services;
|
||||
|
||||
using MudExtensions.Services;
|
||||
|
||||
#if !DEBUG
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
#endif
|
||||
|
||||
namespace AIStudio;
|
||||
|
||||
internal sealed class Program
|
||||
@@ -96,7 +92,14 @@ internal sealed class Program
|
||||
}
|
||||
|
||||
API_TOKEN = apiToken;
|
||||
|
||||
|
||||
var uiToken = Environment.GetEnvironmentVariable(AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE);
|
||||
if(string.IsNullOrWhiteSpace(uiToken))
|
||||
{
|
||||
Console.WriteLine($"Error: The {AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE} environment variable is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var rust = new RustService(rustApiPort, certificateFingerprint);
|
||||
var appPort = await rust.GetAppPort();
|
||||
if(appPort == 0)
|
||||
@@ -283,12 +286,18 @@ internal sealed class Program
|
||||
options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri($"http://localhost:{appPort}")
|
||||
});
|
||||
// For absolute URLs only. The app reads its own files through WebRootFiles:
|
||||
builder.Services.AddSingleton(new HttpClient());
|
||||
builder.Services.AddSingleton<WebRootFiles>();
|
||||
|
||||
builder.WebHost.UseUrls($"http://localhost:{appPort}");
|
||||
//
|
||||
// We listen on the IPv4 loopback address only, where the app window goes. With localhost, the
|
||||
// server would listen on the IPv6 loopback address as well, and a client which resolves
|
||||
// localhost could end up with another process holding that port there.
|
||||
//
|
||||
builder.WebHost.UseUrls($"http://127.0.0.1:{appPort}");
|
||||
builder.Services.Configure<HostFilteringOptions>(AppAccessPolicy.ConfigureHostFiltering);
|
||||
builder.Services.AddSingleton(new AppAccessPolicy(appPort, uiToken));
|
||||
|
||||
#if DEBUG
|
||||
builder.WebHost.UseWebRoot("wwwroot");
|
||||
@@ -343,6 +352,13 @@ internal sealed class Program
|
||||
|
||||
DATABASE_CLIENT_PROVIDER = app.Services.GetRequiredService<DatabaseClientProvider>();
|
||||
|
||||
//
|
||||
// These two come first, so that no request reaches anything else without passing them. We add
|
||||
// the host filtering ourselves instead of relying on the defaults of ASP.NET Core to do so:
|
||||
//
|
||||
app.UseHostFiltering();
|
||||
app.UseMiddleware<AppAccessMiddleware>();
|
||||
|
||||
programLogger.LogInformation("Initialize internal file system.");
|
||||
app.Use(Redirect.HandlerContentAsync);
|
||||
app.Use(FileHandler.HandlerAsync);
|
||||
@@ -351,10 +367,9 @@ internal sealed class Program
|
||||
app.UseStaticFiles();
|
||||
app.UseDeveloperExceptionPage();
|
||||
#else
|
||||
var fileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot");
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = fileProvider,
|
||||
FileProvider = app.Services.GetRequiredService<WebRootFiles>().FileProvider,
|
||||
RequestPath = string.Empty,
|
||||
});
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace AIStudio.Tools.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Refuses every request to the app server which does not come from the app window.
|
||||
/// </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. 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
|
||||
/// included, but not when they load a page. A sandboxed iframe sends "null", which gets refused as
|
||||
/// well.
|
||||
/// </remarks>
|
||||
/// <param name="next">The next middleware.</param>
|
||||
/// <param name="policy">Decides which requests may pass.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy policy, ILogger<AppAccessMiddleware> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// The shortest interval between two log entries about refused requests. Any process on this
|
||||
/// machine can send requests, and an entry for each of them would flood the log.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan REFUSAL_LOG_INTERVAL = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// What a refused request gets to read. The app window only shows it when it lost its cookie,
|
||||
/// and a new start gives it a new one.
|
||||
/// </summary>
|
||||
private const string REFUSAL_TEXT = "This window cannot access AI Studio. Please restart AI Studio.";
|
||||
|
||||
private readonly Lock refusalLock = new();
|
||||
private DateTimeOffset lastRefusalLog = DateTimeOffset.MinValue;
|
||||
private int refusalsSinceLastLog;
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var origin = context.Request.Headers.Origin;
|
||||
if (!StringValues.IsNullOrEmpty(origin) && (origin.Count != 1 || !policy.IsAppOrigin(origin.ToString())))
|
||||
{
|
||||
await this.RefuseAsync(context, "another origin");
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
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.Lax,
|
||||
Path = "/",
|
||||
IsEssential = true
|
||||
});
|
||||
|
||||
context.Response.Redirect("/");
|
||||
}
|
||||
#endif
|
||||
|
||||
private async Task RefuseAsync(HttpContext context, string reason)
|
||||
{
|
||||
this.LogRefusal(context, reason);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "text/plain; charset=utf-8";
|
||||
await context.Response.WriteAsync(REFUSAL_TEXT);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs a refused request, at most once per interval, together with the number of requests
|
||||
/// refused since the last entry. The entry names the path but never the query or any header.
|
||||
/// </summary>
|
||||
private void LogRefusal(HttpContext context, string reason)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
int refusals;
|
||||
lock (this.refusalLock)
|
||||
{
|
||||
this.refusalsSinceLastLog++;
|
||||
if (now - this.lastRefusalLog < REFUSAL_LOG_INTERVAL)
|
||||
return;
|
||||
|
||||
refusals = this.refusalsSinceLastLog;
|
||||
this.refusalsSinceLastLog = 0;
|
||||
this.lastRefusalLog = now;
|
||||
}
|
||||
|
||||
logger.LogWarning("Refused {Refusals} request(s) to the app server since the last report. The latest one, '{Method} {Path}', came from {Reason}.", refusals, context.Request.Method, context.Request.Path.Value, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
|
||||
namespace AIStudio.Tools.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Decides which requests may reach the app server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Rust runtime makes a new token for every start. It gives the token to this server through
|
||||
/// an environment variable, and to the app window as the value of the session cookie. Nothing else
|
||||
/// on this machine has the cookie, so a request with it comes from the app window.
|
||||
/// </remarks>
|
||||
/// <param name="appPort">The port the app server listens on.</param>
|
||||
/// <param name="uiToken">The token of this start, which the session cookie carries.</param>
|
||||
public sealed class AppAccessPolicy(int appPort, string uiToken)
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the session cookie. The Rust runtime sets the cookie under the same name.
|
||||
/// </summary>
|
||||
public const string SESSION_COOKIE_NAME = "ai_studio_session";
|
||||
|
||||
/// <summary>
|
||||
/// The environment variable which carries the token from the Rust runtime to this server.
|
||||
/// </summary>
|
||||
public const string UI_TOKEN_ENVIRONMENT_VARIABLE = "AI_STUDIO_UI_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// The host names under which the app server answers. A request for any other name, such as one
|
||||
/// which a website points at the loopback address, gets refused.
|
||||
/// </summary>
|
||||
private static readonly string[] ALLOWED_HOSTS = ["127.0.0.1", "localhost", "[::1]"];
|
||||
|
||||
private readonly HashSet<string> allowedOrigins = ALLOWED_HOSTS.Select(host => $"http://{host}:{appPort}").ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly byte[] uiTokenBytes = Encoding.UTF8.GetBytes(uiToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lets the host filtering of ASP.NET Core accept only the host names of the app server.
|
||||
/// </summary>
|
||||
/// <param name="options">The options of the host filtering.</param>
|
||||
public static void ConfigureHostFiltering(HostFilteringOptions options)
|
||||
{
|
||||
options.AllowedHosts = [..ALLOWED_HOSTS];
|
||||
options.AllowEmptyHosts = false;
|
||||
options.IncludeFailureMessage = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given origin is the app itself.
|
||||
/// </summary>
|
||||
/// <param name="origin">The value of the Origin header of a request.</param>
|
||||
/// <returns>True when a page of the app made the request.</returns>
|
||||
public bool IsAppOrigin(string origin) => this.allowedOrigins.Contains(origin);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given request carries the session cookie of this start.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The comparison takes the same time no matter where a wrong value differs, so the time of a
|
||||
/// refusal tells nothing about how much of a guess was right.
|
||||
/// </remarks>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <returns>True when the request comes from the app window.</returns>
|
||||
public bool HasSessionCookie(HttpRequest request) => request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) && this.IsUIToken(value);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Reflection;
|
||||
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the files below wwwroot, which every build embeds into the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The app reads files such as its changelogs directly instead of requesting them over HTTP from
|
||||
/// its own server, which answers the app window only. The release build serves its static files
|
||||
/// from the same provider.
|
||||
/// </remarks>
|
||||
public sealed class WebRootFiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the files below wwwroot.
|
||||
/// </summary>
|
||||
public IFileProvider FileProvider { get; } = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot");
|
||||
|
||||
/// <summary>
|
||||
/// Reads a text file below wwwroot.
|
||||
/// </summary>
|
||||
/// <param name="path">The path below wwwroot, such as <c>changelog/v26.10.1.md</c>.</param>
|
||||
/// <returns>The text of the file, or an empty string when there is no such file.</returns>
|
||||
public async Task<string> ReadTextAsync(string path)
|
||||
{
|
||||
var file = this.FileProvider.GetFileInfo(path);
|
||||
if (!file.Exists)
|
||||
return string.Empty;
|
||||
|
||||
await using var fileStream = file.CreateReadStream();
|
||||
using var reader = new StreamReader(fileStream);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a file below wwwroot to the given place, such as one the user chose to save it to.
|
||||
/// </summary>
|
||||
/// <param name="path">The path below wwwroot, such as <c>specs/eri/v1.json</c>.</param>
|
||||
/// <param name="destinationPath">Where to write the copy. An existing file there gets replaced.</param>
|
||||
/// <returns>True when the copy was written, false when there is no such file below wwwroot.</returns>
|
||||
public async Task<bool> CopyToAsync(string path, string destinationPath)
|
||||
{
|
||||
var file = this.FileProvider.GetFileInfo(path);
|
||||
if (!file.Exists)
|
||||
return false;
|
||||
|
||||
await using var source = file.CreateReadStream();
|
||||
await using var destination = File.Create(destinationPath);
|
||||
await source.CopyToAsync(destination);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
- Added a setting for the size of the interface, which you find in the app settings. You can choose a size for each of your screens, and AI Studio switches to it when you move its window to another screen. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
|
||||
- Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German.
|
||||
- Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere.
|
||||
- Improved the security of the connection between the app window and AI Studio.
|
||||
- Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka.
|
||||
- Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again.
|
||||
- Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which requests the app server lets through to the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the app window has the session cookie, so every request without it gets refused, whatever
|
||||
/// it asks for: pages, static files, the Blazor hub, the local files, and the preview of a visual
|
||||
/// briefing alike. A request which a script sends also carries the origin of its page. Only the
|
||||
/// pages of the app may send such requests: a page from anywhere else, and a sandboxed iframe with
|
||||
/// its origin "null", get refused even with the cookie.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class AppAccessMiddlewareTests
|
||||
{
|
||||
private const int APP_PORT = 61153;
|
||||
|
||||
private const string UI_TOKEN = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// <summary>
|
||||
/// The status a request gets when it passes the middleware.
|
||||
/// </summary>
|
||||
private const int PASSED = StatusCodes.Status204NoContent;
|
||||
|
||||
private static readonly string[] PATHS =
|
||||
[
|
||||
"/",
|
||||
"/chat",
|
||||
"/_blazor/negotiate",
|
||||
"/_blazor",
|
||||
"/_framework/blazor.web.js",
|
||||
"/local/file",
|
||||
"/visual-briefing/preview/0b6c1c43-6d2e-4f0b-9a35-6f6f3c1f2a10/6a4e7d6e-2a8c-4bb0-8c55-7a2b7b3c9e01",
|
||||
"/system/MudBlazor/MudBlazor.min.css",
|
||||
"/app.css",
|
||||
"/changelog/v26.10.1.md",
|
||||
"/does-not-exist"
|
||||
];
|
||||
|
||||
private static async Task<int> SendAsync(string path, string? sessionCookie, params string[] origins)
|
||||
{
|
||||
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.Instance);
|
||||
var context = new DefaultHttpContext { Request = { Method = HttpMethods.Get, Path = path } };
|
||||
if (sessionCookie is not null)
|
||||
context.Request.Headers.Cookie = $"{AppAccessPolicy.SESSION_COOKIE_NAME}={sessionCookie}";
|
||||
|
||||
if (origins.Length > 0)
|
||||
context.Request.Headers.Origin = origins;
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
return context.Response.StatusCode;
|
||||
}
|
||||
|
||||
private static Task Pass(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = PASSED;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(PATHS))]
|
||||
public async Task ARequestWithTheSessionCookiePasses(string path)
|
||||
{
|
||||
Assert.That(await SendAsync(path, UI_TOKEN), Is.EqualTo(PASSED));
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(PATHS))]
|
||||
public async Task ARequestWithoutTheSessionCookieIsRefused(string path)
|
||||
{
|
||||
Assert.That(await SendAsync(path, null), Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee")]
|
||||
[TestCase("0123456789abcdef")]
|
||||
[TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0")]
|
||||
[TestCase("")]
|
||||
public async Task ARequestWithAWrongSessionCookieIsRefused(string sessionCookie)
|
||||
{
|
||||
Assert.That(await SendAsync("/", sessionCookie), Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheTokenUnderAnotherCookieNameIsRefused()
|
||||
{
|
||||
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.Instance);
|
||||
var context = new DefaultHttpContext { Request = { Method = HttpMethods.Get, Path = "/", Headers = { Cookie = $"session={UI_TOKEN}" } } };
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
Assert.That(context.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[TestCase("http://127.0.0.1:61153")]
|
||||
[TestCase("http://localhost:61153")]
|
||||
[TestCase("http://[::1]:61153")]
|
||||
public async Task ARequestFromTheAppPasses(string origin)
|
||||
{
|
||||
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(PASSED));
|
||||
}
|
||||
|
||||
[TestCase("http://rebind.example:61153")]
|
||||
[TestCase("null")]
|
||||
[TestCase("http://127.0.0.1:61154")]
|
||||
[TestCase("https://127.0.0.1:61153")]
|
||||
[TestCase("http://127.0.0.1")]
|
||||
public async Task ARequestFromAnotherOriginIsRefusedEvenWithTheSessionCookie(string origin)
|
||||
{
|
||||
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARequestWithSeveralOriginsIsRefused()
|
||||
{
|
||||
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=lax"));
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the access checks run before anything else in the request pipeline of the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A middleware or an endpoint which comes first would answer requests the checks never saw. The
|
||||
/// test reads the calls from the source of Program.cs, so that a new one is checked without anybody
|
||||
/// having to add it to a test.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class AppAccessPipelineTests
|
||||
{
|
||||
private static string ReadProgramSource([CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "MindWork AI Studio", "Program.cs"));
|
||||
|
||||
[Test]
|
||||
public void TheAccessChecksComeBeforeEveryOtherMiddlewareAndEndpoint()
|
||||
{
|
||||
var calls = Regex.Matches(ReadProgramSource(), @"\bapp\.(Use|Map)\w*(<\w+>)?\(")
|
||||
.Select(match => match.Value)
|
||||
.ToList();
|
||||
|
||||
Assert.That(calls, Has.Count.GreaterThan(2), "The test found no middleware or endpoint after the access checks.");
|
||||
Assert.That(calls.Take(2), Is.EqualTo(new[] { "app.UseHostFiltering(", "app.UseMiddleware<AppAccessMiddleware>(" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the app server and the Rust runtime agree on the names around the session cookie.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The runtime sets the cookie and hands the token over in an environment variable; the server
|
||||
/// reads both under its own constants. Should the names drift apart, the server would refuse the
|
||||
/// app window, and nothing but a start of the app would tell. The test reads the constants of the
|
||||
/// runtime from its source.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class AppAccessRuntimeAgreementTests
|
||||
{
|
||||
private static string ReadRuntimeSource([CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "..", "runtime", "src", "app_access.rs"));
|
||||
|
||||
private static string ReadRustConstant(string name)
|
||||
{
|
||||
var match = Regex.Match(ReadRuntimeSource(), $"""pub const {name}: &str = "([^"]+)";""");
|
||||
Assert.That(match.Success, Is.True, $"The runtime defines no constant {name}.");
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BothUseTheSameCookieName()
|
||||
{
|
||||
Assert.That(ReadRustConstant("SESSION_COOKIE_NAME"), Is.EqualTo(AppAccessPolicy.SESSION_COOKIE_NAME));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BothUseTheSameEnvironmentVariable()
|
||||
{
|
||||
Assert.That(ReadRustConstant("DOTNET_ENV_UI_TOKEN"), Is.EqualTo(AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which host names the app server answers to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A website may point a name of its own at the loopback address. The browser then sends that name
|
||||
/// as the host, and the app server must refuse the request. The test runs the host filtering of
|
||||
/// ASP.NET Core with the options the app configures.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class HostFilteringTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The status a request gets when it passes the filter.
|
||||
/// </summary>
|
||||
private const int PASSED = StatusCodes.Status204NoContent;
|
||||
|
||||
private static async Task<int> SendAsync(string host)
|
||||
{
|
||||
await using var services = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.Configure<HostFilteringOptions>(AppAccessPolicy.ConfigureHostFiltering)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var middleware = ActivatorUtilities.CreateInstance<HostFilteringMiddleware>(services, (RequestDelegate)Pass);
|
||||
var context = new DefaultHttpContext { Request = { Host = new HostString(host) } };
|
||||
|
||||
await middleware.Invoke(context);
|
||||
return context.Response.StatusCode;
|
||||
}
|
||||
|
||||
private static Task Pass(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = PASSED;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[TestCase("127.0.0.1:61153")]
|
||||
[TestCase("localhost:61153")]
|
||||
[TestCase("[::1]:61153")]
|
||||
public async Task TheLoopbackNamesPass(string host)
|
||||
{
|
||||
Assert.That(await SendAsync(host), Is.EqualTo(PASSED));
|
||||
}
|
||||
|
||||
[TestCase("rebind.example:61153")]
|
||||
[TestCase("127.0.0.1.rebind.example:61153")]
|
||||
[TestCase("localhost.rebind.example")]
|
||||
public async Task AnyOtherNameIsRefused(string host)
|
||||
{
|
||||
Assert.That(await SendAsync(host), Is.EqualTo(StatusCodes.Status400BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARequestWithoutAHostIsRefused()
|
||||
{
|
||||
Assert.That(await SendAsync(string.Empty), Is.EqualTo(StatusCodes.Status400BadRequest));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using AIStudio.Assistants.ERI;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Changelog = AIStudio.Components.Changelog;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the app reads the files below wwwroot which it shows itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The changelogs and the ERI specifications come from the files embedded into the app, not from a
|
||||
/// request to its own server. A file which is missing there would leave the changelog or the ERI
|
||||
/// assistant empty without any error.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class WebRootFilesTests
|
||||
{
|
||||
private static IEnumerable<string> ChangelogFiles() => Changelog.LOGS.Select(log => log.Filename);
|
||||
|
||||
private static IEnumerable<ERIVersion> SpecificationVersions() => Enum.GetValues<ERIVersion>().Where(version => version.WasSpecificationSelected());
|
||||
|
||||
[TestCaseSource(nameof(ChangelogFiles))]
|
||||
public async Task EveryChangelogCanBeRead(string filename)
|
||||
{
|
||||
var text = await new WebRootFiles().ReadTextAsync($"changelog/{filename}");
|
||||
Assert.That(text, Is.Not.Empty);
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(SpecificationVersions))]
|
||||
public async Task EveryERISpecificationCanBeRead(ERIVersion version)
|
||||
{
|
||||
var text = await version.ReadSpecification(new WebRootFiles());
|
||||
Assert.That(text, Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMissingFileReadsAsEmptyText()
|
||||
{
|
||||
var text = await new WebRootFiles().ReadTextAsync("changelog/does-not-exist.md");
|
||||
Assert.That(text, Is.Empty);
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(SpecificationVersions))]
|
||||
public async Task EveryERISpecificationCanBeSaved(ERIVersion version)
|
||||
{
|
||||
var webRootFiles = new WebRootFiles();
|
||||
var destinationPath = Path.Combine(Path.GetTempPath(), $"eri-specification-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
Assert.That(await webRootFiles.CopyToAsync(version.SpecificationURL(), destinationPath), Is.True);
|
||||
Assert.That(await File.ReadAllTextAsync(destinationPath), Is.EqualTo(await version.ReadSpecification(webRootFiles)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(destinationPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMissingFileIsNotCopied()
|
||||
{
|
||||
var destinationPath = Path.Combine(Path.GetTempPath(), $"eri-specification-{Guid.NewGuid():N}.json");
|
||||
|
||||
var copied = await new WebRootFiles().CopyToAsync("specs/eri/does-not-exist.json", destinationPath);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(copied, Is.False);
|
||||
Assert.That(File.Exists(destinationPath), Is.False);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user