fix(mqtt): bound browse observation against a chatty or malicious broker
Three review findings, all "unbounded work/memory from untrusted broker input" rather than correctness bugs - but this points at a live plant broker. 1. InferPayload decoded the WHOLE payload on MQTTnet's dispatcher thread before trimming to 64 chars. The node cap never engages here: a multi-MB blob on an already-known topic creates no node, it just updates one, so the cost repeats per message forever. Now at most PayloadInspectMaxBytes (512) are examined. A blind slice can cut a multi-byte sequence, which the strict decoder would report as "binary" - TrimToUtf8Boundary backs the window off to the last whole sequence so good UTF-8 is never mislabelled. A clipped payload is String by construction rather than inferred from a partial view. 2. The node cap bounded COUNT, not bytes. MQTT topics run to ~65KB, so 50k nodes near that ceiling is a multi-GB tree. Topics now bounded at 1024 chars and segments at 256, rejected whole (a truncated topic is a DIFFERENT topic the picker would commit as a tag address) and surfaced via an __oversized__ marker kept distinct from __truncated__ - the operator's remedy differs. 3. Control characters in a snippet would break the picker's single-line row; Trim() only strips the ends. Now scrubbed to spaces. Each guard was falsified independently by neutering it and confirming exactly one test reddens. That found a real gap: the oversized-topic test was passing via the SEGMENT bound, leaving the whole-topic bound untested - the test now uses many short segments so only the topic bound can reject it. Also records the .Driver project reference as a known, deliberate exception to the .Browser -> .Contracts pattern, with its cost (AdminUI gains Core + Polly + Serilog) and the clean fix (a leaf transport project; NOT a move into .Contracts, which is deliberately transport-free). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -248,6 +248,122 @@ public sealed class MqttBrowseSessionTests
|
||||
root.ShouldContain(n => n.NodeId == MqttBrowseSession.TruncatedNodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedTopic_IsRejectedWhole_AndSurfacesAMarker()
|
||||
{
|
||||
// A truncated topic is a DIFFERENT topic — the picker would commit it as a tag address.
|
||||
// Every segment here is 4 chars, well under MaxSegmentChars, so ONLY the whole-topic bound
|
||||
// can reject it — the segment guard must not be what makes this test pass.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("good/topic", "1");
|
||||
|
||||
var manyShortSegments = "huge/" + string.Join('/', Enumerable.Repeat("abc", MqttBrowseSession.MaxTopicChars / 4 + 8));
|
||||
manyShortSegments.Length.ShouldBeGreaterThan(MqttBrowseSession.MaxTopicChars);
|
||||
manyShortSegments.Split('/').ShouldAllBe(seg => seg.Length <= MqttBrowseSession.MaxSegmentChars);
|
||||
s.ObserveTopicForTest(manyShortSegments, "1");
|
||||
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
root.ShouldContain(n => n.NodeId == MqttBrowseSession.OversizedNodeId);
|
||||
root.ShouldContain(n => n.DisplayName == "good");
|
||||
root.ShouldNotContain(n => n.DisplayName == "huge"); // no half-built path left behind
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedSegment_IsRejectedWhole_EvenWhenTheTopicFits()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("a/" + new string('y', MqttBrowseSession.MaxSegmentChars + 1) + "/b", "1");
|
||||
|
||||
s.OversizedRejected.ShouldBeTrue();
|
||||
(await s.ExpandAsync("a", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
root.ShouldNotContain(n => n.DisplayName == "a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TopicAtExactlyTheCap_IsAccepted()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest(new string('z', MqttBrowseSession.MaxSegmentChars), "1");
|
||||
|
||||
s.OversizedRejected.ShouldBeFalse();
|
||||
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- payload bounds
|
||||
|
||||
[Fact]
|
||||
public async Task PayloadBeyondTheInspectionWindow_IsNeverExamined()
|
||||
{
|
||||
// The discriminating test for the byte cap: content past PayloadInspectMaxBytes cannot
|
||||
// influence the result at all. With an unbounded decode the trailing scalar would be found
|
||||
// and typed Int64, and would show up in the snippet.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest(
|
||||
"t/tail",
|
||||
new string(' ', MqttBrowseSession.PayloadInspectMaxBytes + 64) + "42");
|
||||
|
||||
var a = (await s.AttributesAsync("t/tail", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String));
|
||||
a.SecurityClass.ShouldNotContain("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HugePayload_YieldsABoundedSnippetMarkedAsClipped()
|
||||
{
|
||||
// A '#' subscription receives multi-MB retained blobs; the node cap never engages for a
|
||||
// large payload on an already-known topic, so the decode itself must be bounded.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("t/blob", new string('a', 5_000_000));
|
||||
|
||||
var a = (await s.AttributesAsync("t/blob", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1);
|
||||
a.SecurityClass.ShouldEndWith("…");
|
||||
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClippedMultiByteUtf8_IsNotMislabelledAsBinary()
|
||||
{
|
||||
// The inspection window can land mid-sequence; backing off to a whole sequence keeps a
|
||||
// perfectly good UTF-8 payload from being reported as binary.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
|
||||
// 3 bytes per char, so 512 / 3 leaves a partial sequence at the window edge.
|
||||
s.ObserveTopicForTest("t/utf8", new string('☃', 4000));
|
||||
|
||||
var a = (await s.AttributesAsync("t/utf8", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.ShouldStartWith("☃");
|
||||
a.SecurityClass.ShouldNotContain("binary");
|
||||
a.SecurityClass.ShouldNotContain("�"); // no replacement char from a split sequence
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BinaryPayload_IsReportedAsOpaque_WithItsTrueLength()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
var binary = new byte[2048];
|
||||
Array.Fill(binary, (byte)0xC0); // never legal UTF-8
|
||||
s.Observe("t/bin", binary);
|
||||
|
||||
var a = (await s.AttributesAsync("t/bin", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.ShouldBe("(2048 bytes, binary)"); // full length, not the clipped window
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmbeddedControlCharacters_AreScrubbedToSpaces()
|
||||
{
|
||||
// Trim() only strips the ends; an embedded newline would break the picker's single-line row.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("t/multiline", "line one\nline two\ttabbed[31m");
|
||||
|
||||
var a = (await s.AttributesAsync("t/multiline", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.SecurityClass.ShouldNotContain("\n");
|
||||
a.SecurityClass.ShouldNotContain("\t");
|
||||
a.SecurityClass.ShouldNotContain("");
|
||||
a.SecurityClass.ShouldStartWith("line one line two tabbed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the browser
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user