Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<!-- 8.0.0 pulls in Microsoft.Build.Tasks.Git 8.0.0 (CVE-2026-62900), which has no patched 8.x release. -->
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.402" />
<!-- Transitive via Testcontainers 4.1.0 (SSH.NET 2024.1.0). Pinned to the release patched for
GHSA-q939-rpr3-3284 and GHSA-mggc-4xg6-vcxf; Testcontainers 4.16+ already depends on it. -->
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
</ItemGroup>
</Project>
3 changes: 2 additions & 1 deletion dashboard/src/hooks/useSignalR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export function useSignalR() {

const connection = new HubConnectionBuilder()
.withUrl(hubUrl, {
accessTokenFactory: () => settings.apiKey,
// Read at call time so a key entered on the Settings page applies on the next (re)connect.
accessTokenFactory: () => getDashboardSettings().apiKey,
headers: settings.apiKey ? { 'X-Api-Key': settings.apiKey } : undefined,
})
.withAutomaticReconnect([0, 2000, 5000, 10000])
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export function SettingsPage() {
placeholder="X-Api-Key header value"
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm"
/>
<span className="mt-1 block text-xs text-slate-500">
Kept in memory for this tab only and never saved to the browser. Enter it again after
reloading the page.
</span>
</label>

<label className="block text-sm">
Expand Down
63 changes: 53 additions & 10 deletions dashboard/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,69 @@ import type {

const SETTINGS_KEY = 'eventmesh.dashboard.settings';

const defaultSettings: DashboardSettings = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '',
apiKey: import.meta.env.VITE_API_KEY ?? '',
refreshIntervalMs: 5000,
};
/**
* The settings that may be persisted in the browser. The API key is deliberately absent: it is a
* credential, so it is never written to localStorage or sessionStorage.
*/
type PersistedSettings = Pick<DashboardSettings, 'apiBaseUrl' | 'refreshIntervalMs'>;

function getSettings(): DashboardSettings {
// Key entered on the Settings page. It lives in memory for the life of the page only, so it is gone
// after a reload; until one is entered the key baked in at build time (VITE_API_KEY) is used.
let inMemoryApiKey: string | undefined;

function getDefaultSettings(): DashboardSettings {
return {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '',
apiKey: import.meta.env.VITE_API_KEY ?? '',
refreshIntervalMs: 5000,
};
}

function readPersistedSettings(): Partial<PersistedSettings> {
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (!stored) {
return defaultSettings;
return {};
}

const parsed = JSON.parse(stored) as Record<string, unknown>;
const persisted: Partial<PersistedSettings> = {};
if (typeof parsed.apiBaseUrl === 'string') {
persisted.apiBaseUrl = parsed.apiBaseUrl;
}
if (typeof parsed.refreshIntervalMs === 'number') {
persisted.refreshIntervalMs = parsed.refreshIntervalMs;
}
return { ...defaultSettings, ...JSON.parse(stored) };

// Earlier builds persisted the whole settings object, API key included. Rewrite the entry
// without it so a key saved by an old build does not linger in the browser.
if (Object.prototype.hasOwnProperty.call(parsed, 'apiKey')) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(persisted));
}

return persisted;
} catch {
return defaultSettings;
return {};
}
}

function getSettings(): DashboardSettings {
const defaults = getDefaultSettings();
return {
...defaults,
...readPersistedSettings(),
apiKey: inMemoryApiKey ?? defaults.apiKey,
};
}

export function saveSettings(settings: DashboardSettings): void {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
inMemoryApiKey = settings.apiKey;

const persisted: PersistedSettings = {
apiBaseUrl: settings.apiBaseUrl,
refreshIntervalMs: settings.refreshIntervalMs,
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(persisted));
}

export function getDashboardSettings(): DashboardSettings {
Expand Down
4 changes: 4 additions & 0 deletions src/EventMesh.Management.Api/EventMesh.Management.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="EventMesh.Integration.Tests" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\EventMesh.Abstractions\EventMesh.Abstractions.csproj" />
<ProjectReference Include="..\EventMesh.Core\EventMesh.Core.csproj" />
Expand Down
63 changes: 63 additions & 0 deletions src/EventMesh.Management.Api/Infrastructure/LogSanitizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Globalization;
using System.Text;

namespace EventMesh.Management.Api.Infrastructure;

/// <summary>
/// Neutralizes caller-supplied text before it is written to a log entry, so a request value cannot
/// forge extra log lines or smuggle terminal escape sequences into the log stream.
/// </summary>
internal static class LogSanitizer
{
/// <summary>
/// The default cap, in characters, for a sanitized value. It is above the longest name any
/// supported broker accepts, so legitimate topic and queue names are never shortened.
/// </summary>
internal const int DefaultMaxLength = 256;

/// <summary>
/// Removes carriage returns, line feeds, every other control character and the Unicode line and
/// paragraph separators from <paramref name="value"/>, then caps the result at
/// <paramref name="maxLength"/> characters.
/// </summary>
/// <param name="value">The caller-supplied text; <see langword="null"/> yields an empty string.</param>
/// <param name="maxLength">The maximum number of characters to keep.</param>
/// <returns>A single-line string that is safe to pass to a logger.</returns>
internal static string Sanitize(string? value, int maxLength = DefaultMaxLength)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLength);

if (string.IsNullOrEmpty(value))
{
return string.Empty;
}

var builder = new StringBuilder(Math.Min(value.Length, maxLength));
foreach (var c in value)
{
if (builder.Length == maxLength)
{
break;
}

if (IsLineBreakOrControl(c))
{
continue;
}

builder.Append(c);
}

// Do not leave half of a surrogate pair behind when the cap cut one in two.
if (builder.Length > 0 && char.IsHighSurrogate(builder[^1]))
{
builder.Length--;
}

return builder.ToString();
}

private static bool IsLineBreakOrControl(char c) =>
char.IsControl(c)
|| char.GetUnicodeCategory(c) is UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator;
}
11 changes: 9 additions & 2 deletions src/EventMesh.Management.Api/Services/MeshObservationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using EventMesh.Core.Observability;
using EventMesh.Management.Api.Configuration;
using EventMesh.Management.Api.Hubs;
using EventMesh.Management.Api.Infrastructure;
using EventMesh.Management.Api.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -322,7 +323,10 @@ public TopicInfo CreateTopic(CreateTopicRequest request)
_topics[request.Name] = topic;
}

_logger.LogInformation("Created topic {TopicName} on transport {Transport}", request.Name, transport);
_logger.LogInformation(
"Created topic {TopicName} on transport {Transport}",
LogSanitizer.Sanitize(request.Name),
LogSanitizer.Sanitize(transport));
return topic;
}

Expand Down Expand Up @@ -381,7 +385,10 @@ public QueueInfo CreateQueue(CreateQueueRequest request)
_queues[request.Name] = queue;
}

_logger.LogInformation("Created queue {QueueName} on transport {Transport}", request.Name, transport);
_logger.LogInformation(
"Created queue {QueueName} on transport {Transport}",
LogSanitizer.Sanitize(request.Name),
LogSanitizer.Sanitize(transport));
return queue;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

<ItemGroup>
<ProjectReference Include="..\..\src\EventMesh.Core\EventMesh.Core.csproj" />
<ProjectReference Include="..\..\src\EventMesh.Management.Api\EventMesh.Management.Api.csproj" />
<ProjectReference Include="..\..\src\EventMesh.SchemaRegistry\EventMesh.SchemaRegistry.csproj" />
<ProjectReference Include="..\..\src\EventMesh.Security\EventMesh.Security.csproj" />
<ProjectReference Include="..\..\src\EventMesh.Transport.InMemory\EventMesh.Transport.InMemory.csproj" />
Expand Down
92 changes: 92 additions & 0 deletions tests/EventMesh.Integration.Tests/Management/LogSanitizerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using EventMesh.Management.Api.Infrastructure;
using FluentAssertions;

namespace EventMesh.Integration.Tests.Management;

public sealed class LogSanitizerTests
{
[Theory]
[InlineData("orders\r\n2026-01-01 INFO forged entry", "orders2026-01-01 INFO forged entry")]
[InlineData("first\nsecond", "firstsecond")]
[InlineData("first\rsecond", "firstsecond")]
public void Sanitize_removes_carriage_returns_and_line_feeds(string input, string expected)
{
LogSanitizer.Sanitize(input).Should().Be(expected);
}

[Fact]
public void Sanitize_removes_other_control_characters()
{
var input = "a" + (char)0 + "b" + (char)9 + "c" + (char)27 + "d" + (char)127 + "e" + (char)0x85 + "f";

LogSanitizer.Sanitize(input).Should().Be("abcdef");
}

[Fact]
public void Sanitize_removes_unicode_line_and_paragraph_separators()
{
var input = "a" + (char)0x2028 + "b" + (char)0x2029 + "c";

LogSanitizer.Sanitize(input).Should().Be("abc");
}

[Theory]
[InlineData("orders.created-v1")]
[InlineData("tenant_a/billing events")]
public void Sanitize_leaves_ordinary_names_unchanged(string input)
{
LogSanitizer.Sanitize(input).Should().Be(input);
}

[Fact]
public void Sanitize_leaves_non_ascii_letters_unchanged()
{
var input = "caf" + (char)0xE9 + "-topic";

LogSanitizer.Sanitize(input).Should().Be(input);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void Sanitize_returns_an_empty_string_for_missing_values(string? input)
{
LogSanitizer.Sanitize(input).Should().BeEmpty();
}

[Fact]
public void Sanitize_caps_the_length_at_the_default_limit()
{
var result = LogSanitizer.Sanitize(new string('a', LogSanitizer.DefaultMaxLength * 4));

result.Should().HaveLength(LogSanitizer.DefaultMaxLength);
}

[Fact]
public void Sanitize_caps_the_length_after_stripping_control_characters()
{
var result = LogSanitizer.Sanitize("a\r\nb\r\nc\r\nd", maxLength: 3);

result.Should().Be("abc");
}

[Fact]
public void Sanitize_does_not_leave_half_a_surrogate_pair_when_capping()
{
var input = new string('a', 9) + char.ConvertFromUtf32(0x1F600);

var result = LogSanitizer.Sanitize(input, maxLength: 10);

result.Should().Be(new string('a', 9));
}

[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Sanitize_rejects_a_non_positive_limit(int maxLength)
{
var act = () => LogSanitizer.Sanitize("value", maxLength);

act.Should().Throw<ArgumentOutOfRangeException>();
}
}
Loading