Enhanced Markdown security by using SecurePipeline

This commit is contained in:
Thorsten Sommer 2026-02-25 21:14:21 +01:00
parent 685f95245b
commit 57f0f39f4d
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
13 changed files with 135 additions and 25 deletions

View File

@ -96,10 +96,10 @@
} }
else else
{ {
<MudMarkdown Value="@textContent.Text.RemoveThinkTags().Trim()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" /> <MudMarkdown Value="@this.NormalizeMarkdownForRendering(textContent.Text)" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SecurePipeline" />
@if (textContent.Sources.Count > 0) @if (textContent.Sources.Count > 0)
{ {
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" /> <MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SecurePipeline" />
} }
} }
} }

View File

@ -10,6 +10,18 @@ namespace AIStudio.Chat;
/// </summary> /// </summary>
public partial class ContentBlockComponent : MSGComponentBase public partial class ContentBlockComponent : MSGComponentBase
{ {
private static readonly string[] HTML_TAG_MARKERS =
[
"<!doctype",
"<html",
"<head",
"<body",
"<style",
"<script",
"<iframe",
"<svg",
];
/// <summary> /// <summary>
/// The role of the chat content block. /// The role of the chat content block.
/// </summary> /// </summary>
@ -68,18 +80,36 @@ public partial class ContentBlockComponent : MSGComponentBase
private RustService RustService { get; init; } = null!; private RustService RustService { get; init; } = null!;
private bool HideContent { get; set; } private bool HideContent { get; set; }
private bool hasRenderHash;
private int lastRenderHash;
#region Overrides of ComponentBase #region Overrides of ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// Register the streaming events: this.RegisterStreamingEvents();
this.Content.StreamingDone = this.AfterStreaming;
this.Content.StreamingEvent = () => this.InvokeAsync(this.StateHasChanged);
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
protected override Task OnParametersSetAsync()
{
this.RegisterStreamingEvents();
return base.OnParametersSetAsync();
}
protected override bool ShouldRender()
{
var currentRenderHash = this.CreateRenderHash();
if (!this.hasRenderHash || currentRenderHash != this.lastRenderHash)
{
this.lastRenderHash = currentRenderHash;
this.hasRenderHash = true;
return true;
}
return false;
}
/// <summary> /// <summary>
/// Gets called when the content stream ended. /// Gets called when the content stream ended.
/// </summary> /// </summary>
@ -111,6 +141,47 @@ public partial class ContentBlockComponent : MSGComponentBase
}); });
} }
private void RegisterStreamingEvents()
{
this.Content.StreamingDone = this.AfterStreaming;
this.Content.StreamingEvent = () => this.InvokeAsync(this.StateHasChanged);
}
private int CreateRenderHash()
{
var hash = new HashCode();
hash.Add(this.Role);
hash.Add(this.Type);
hash.Add(this.Time);
hash.Add(this.Class);
hash.Add(this.IsLastContentBlock);
hash.Add(this.IsSecondToLastBlock);
hash.Add(this.HideContent);
hash.Add(this.SettingsManager.IsDarkMode);
hash.Add(this.RegenerateEnabled());
hash.Add(this.Content.InitialRemoteWait);
hash.Add(this.Content.IsStreaming);
hash.Add(this.Content.FileAttachments.Count);
hash.Add(this.Content.Sources.Count);
switch (this.Content)
{
case ContentText text:
var textValue = text.Text ?? string.Empty;
hash.Add(textValue.Length);
hash.Add(textValue.GetHashCode(StringComparison.Ordinal));
hash.Add(text.Sources.Count);
break;
case ContentImage image:
hash.Add(image.SourceType);
hash.Add(image.Source);
break;
}
return hash.ToHashCode();
}
#endregion #endregion
private string CardClasses => $"my-2 rounded-lg {this.Class}"; private string CardClasses => $"my-2 rounded-lg {this.Class}";
@ -122,6 +193,34 @@ public partial class ContentBlockComponent : MSGComponentBase
CodeBlock = { Theme = this.CodeColorPalette }, CodeBlock = { Theme = this.CodeColorPalette },
}; };
private string NormalizeMarkdownForRendering(string text)
{
var cleaned = text.RemoveThinkTags().Trim();
if (string.IsNullOrWhiteSpace(cleaned))
return string.Empty;
if (cleaned.Contains("```", StringComparison.Ordinal))
return cleaned;
if (LooksLikeRawHtml(cleaned))
return $"```html{Environment.NewLine}{cleaned}{Environment.NewLine}```";
return cleaned;
}
private static bool LooksLikeRawHtml(string text)
{
var content = text.TrimStart();
if (!content.StartsWith("<", StringComparison.Ordinal))
return false;
foreach (var marker in HTML_TAG_MARKERS)
if (content.Contains(marker, StringComparison.OrdinalIgnoreCase))
return true;
return content.Contains("</", StringComparison.Ordinal) || content.Contains("/>", StringComparison.Ordinal);
}
private async Task RemoveBlock() private async Task RemoveBlock()
{ {
if (this.RemoveBlockFunc is null) if (this.RemoveBlockFunc is null)

View File

@ -6,4 +6,4 @@
} }
</MudSelect> </MudSelect>
<MudMarkdown Value="@this.LogContent" Props="Markdown.DefaultConfig"/> <MudMarkdown Value="@this.LogContent" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.SecurePipeline"/>

View File

@ -16,6 +16,7 @@
@if (!block.HideFromUser) @if (!block.HideFromUser)
{ {
<ContentBlockComponent <ContentBlockComponent
@key="@block"
Role="@block.Role" Role="@block.Role"
Type="@block.ContentType" Type="@block.ContentType"
Time="@block.Time" Time="@block.Time"

View File

@ -28,7 +28,7 @@
<MudText Typo="Typo.h6"> <MudText Typo="Typo.h6">
@T("Description") @T("Description")
</MudText> </MudText>
<MudMarkdown Value="@this.currentConfidence.Description"/> <MudMarkdown Value="@this.currentConfidence.Description" MarkdownPipeline="Markdown.SecurePipeline"/>
@if (this.currentConfidence.Sources.Count > 0) @if (this.currentConfidence.Sources.Count > 0)
{ {

View File

@ -104,7 +104,7 @@
@context.ToName() @context.ToName()
</MudTd> </MudTd>
<MudTd> <MudTd>
<MudMarkdown Value="@context.GetConfidence(this.SettingsManager).Description"/> <MudMarkdown Value="@context.GetConfidence(this.SettingsManager).Description" MarkdownPipeline="Markdown.SecurePipeline"/>
</MudTd> </MudTd>
<MudTd Style="vertical-align: top;"> <MudTd Style="vertical-align: top;">
<MudMenu StartIcon="@Icons.Material.Filled.Security" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@this.GetCurrentConfidenceLevelName(context)" Variant="Variant.Filled" Style="@this.SetCurrentConfidenceLevelColorStyle(context)"> <MudMenu StartIcon="@Icons.Material.Filled.Security" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@this.GetCurrentConfidenceLevelName(context)" Variant="Variant.Filled" Style="@this.SetCurrentConfidenceLevelColorStyle(context)">

View File

@ -54,7 +54,7 @@
Class="ma-2 pe-4" Class="ma-2 pe-4"
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")"> HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")">
<div style="max-height: 40vh; overflow-y: auto;"> <div style="max-height: 40vh; overflow-y: auto;">
<MudMarkdown Value="@this.FileContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling"/> <MudMarkdown Value="@this.FileContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SecurePipeline"/>
</div> </div>
</MudField> </MudField>
</MudTabPanel> </MudTabPanel>

View File

@ -30,7 +30,7 @@
} }
else if (!string.IsNullOrWhiteSpace(this.licenseText)) else if (!string.IsNullOrWhiteSpace(this.licenseText))
{ {
<MudMarkdown Value="@this.licenseText" Props="Markdown.DefaultConfig"/> <MudMarkdown Value="@this.licenseText" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.SecurePipeline"/>
} }
</ExpansionPanel> </ExpansionPanel>

View File

@ -5,7 +5,7 @@
<MudIcon Icon="@Icons.Material.Filled.Update" Size="Size.Large" Class="mr-3"/> <MudIcon Icon="@Icons.Material.Filled.Update" Size="Size.Large" Class="mr-3"/>
@this.HeaderText @this.HeaderText
</MudText> </MudText>
<MudMarkdown Value="@this.UpdateResponse.Changelog" Props="Markdown.DefaultConfig"/> <MudMarkdown Value="@this.UpdateResponse.Changelog" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.SecurePipeline"/>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled"> <MudButton OnClick="@this.Cancel" Variant="Variant.Filled">

View File

@ -27,7 +27,7 @@
</ExpansionPanel> </ExpansionPanel>
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.EventNote" HeaderText="@T("Last Changelog")"> <ExpansionPanel HeaderIcon="@Icons.Material.Filled.EventNote" HeaderText="@T("Last Changelog")">
<MudMarkdown Value="@this.LastChangeContent" Props="Markdown.DefaultConfig"/> <MudMarkdown Value="@this.LastChangeContent" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.SecurePipeline"/>
</ExpansionPanel> </ExpansionPanel>
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Lightbulb" HeaderText="@T("Vision")"> <ExpansionPanel HeaderIcon="@Icons.Material.Filled.Lightbulb" HeaderText="@T("Vision")">
@ -35,7 +35,7 @@
</ExpansionPanel> </ExpansionPanel>
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.RocketLaunch" HeaderText="@T("Quick Start Guide")"> <ExpansionPanel HeaderIcon="@Icons.Material.Filled.RocketLaunch" HeaderText="@T("Quick Start Guide")">
<MudMarkdown Props="Markdown.DefaultConfig" Value="@QUICK_START_GUIDE"/> <MudMarkdown Props="Markdown.DefaultConfig" Value="@QUICK_START_GUIDE" MarkdownPipeline="Markdown.SecurePipeline"/>
</ExpansionPanel> </ExpansionPanel>
</MudExpansionPanels> </MudExpansionPanels>

View File

@ -297,7 +297,7 @@
</MudGrid> </MudGrid>
</ExpansionPanel> </ExpansionPanel>
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Verified" HeaderText="License: FSL-1.1-MIT"> <ExpansionPanel HeaderIcon="@Icons.Material.Filled.Verified" HeaderText="License: FSL-1.1-MIT">
<MudMarkdown Value="@LICENSE" Props="Markdown.DefaultConfig"/> <MudMarkdown Value="@LICENSE" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.SecurePipeline"/>
</ExpansionPanel> </ExpansionPanel>
</MudExpansionPanels> </MudExpansionPanels>
</InnerScrolling> </InnerScrolling>

View File

@ -1,7 +1,16 @@
using Markdig;
namespace AIStudio.Tools; namespace AIStudio.Tools;
public static class Markdown public static class Markdown
{ {
private static readonly MarkdownPipeline SAFE_MARKDOWN_PIPELINE = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.DisableHtml()
.Build();
public static MarkdownPipeline SecurePipeline => SAFE_MARKDOWN_PIPELINE;
public static MudMarkdownProps DefaultConfig => new() public static MudMarkdownProps DefaultConfig => new()
{ {
Heading = Heading =

View File

@ -3,3 +3,4 @@
- Improved the user-language logging by limiting language detection logs to a single entry per app start. - Improved the user-language logging by limiting language detection logs to a single entry per app start.
- Improved the logbook readability by removing non-readable special characters from log entries. - Improved the logbook readability by removing non-readable special characters from log entries.
- Improved the logbook reliability by significantly reducing duplicate log entries. - Improved the logbook reliability by significantly reducing duplicate log entries.
- Fixed an issue where the app could turn white or appear invisible in certain chats after HTML-like content was shown.