feat: harden gateway reply remap and leaf loop transparency

This commit is contained in:
Joseph Doherty
2026-02-23 14:40:07 -05:00
parent d83b37fec1
commit 958c4aa8ed
5 changed files with 70 additions and 10 deletions

View File

@@ -4,6 +4,10 @@ public static class ReplyMapper
{
private const string GatewayReplyPrefix = "_GR_.";
public static bool HasGatewayReplyPrefix(string? subject)
=> !string.IsNullOrWhiteSpace(subject)
&& subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal);
public static string? ToGatewayReply(string? replyTo, string localClusterId)
{
if (string.IsNullOrWhiteSpace(replyTo))
@@ -16,14 +20,20 @@ public static class ReplyMapper
{
restoredReply = string.Empty;
if (string.IsNullOrWhiteSpace(gatewayReply) || !gatewayReply.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal))
if (!HasGatewayReplyPrefix(gatewayReply))
return false;
var clusterSeparator = gatewayReply.IndexOf('.', GatewayReplyPrefix.Length);
if (clusterSeparator < 0 || clusterSeparator == gatewayReply.Length - 1)
return false;
var current = gatewayReply!;
while (HasGatewayReplyPrefix(current))
{
var clusterSeparator = current.IndexOf('.', GatewayReplyPrefix.Length);
if (clusterSeparator < 0 || clusterSeparator == current.Length - 1)
return false;
restoredReply = gatewayReply[(clusterSeparator + 1)..];
current = current[(clusterSeparator + 1)..];
}
restoredReply = current;
return true;
}
}

View File

@@ -4,6 +4,9 @@ public static class LeafLoopDetector
{
private const string LeafLoopPrefix = "$LDS.";
public static bool HasLoopMarker(string subject)
=> subject.StartsWith(LeafLoopPrefix, StringComparison.Ordinal);
public static string Mark(string subject, string serverId)
=> $"{LeafLoopPrefix}{serverId}.{subject}";
@@ -13,14 +16,20 @@ public static class LeafLoopDetector
public static bool TryUnmark(string subject, out string unmarked)
{
unmarked = subject;
if (!subject.StartsWith(LeafLoopPrefix, StringComparison.Ordinal))
if (!HasLoopMarker(subject))
return false;
var serverSeparator = subject.IndexOf('.', LeafLoopPrefix.Length);
if (serverSeparator < 0 || serverSeparator == subject.Length - 1)
return false;
var current = subject;
while (HasLoopMarker(current))
{
var serverSeparator = current.IndexOf('.', LeafLoopPrefix.Length);
if (serverSeparator < 0 || serverSeparator == current.Length - 1)
return false;
unmarked = subject[(serverSeparator + 1)..];
current = current[(serverSeparator + 1)..];
}
unmarked = current;
return true;
}
}

View File

@@ -879,6 +879,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
var replyTo = message.ReplyTo;
if (ReplyMapper.TryRestoreGatewayReply(replyTo, out var restoredReply))
replyTo = restoredReply;
else if (ReplyMapper.HasGatewayReplyPrefix(replyTo))
replyTo = null;
DeliverRemoteMessage(message.Account, message.Subject, replyTo, message.Payload);
}
@@ -891,6 +893,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
var subject = message.Subject;
if (LeafLoopDetector.TryUnmark(subject, out var unmarked))
subject = unmarked;
else if (LeafLoopDetector.HasLoopMarker(subject))
return;
DeliverRemoteMessage(message.Account, subject, message.ReplyTo, message.Payload);
}

View File

@@ -0,0 +1,19 @@
using NATS.Server.Gateways;
namespace NATS.Server.Tests;
public class GatewayAdvancedRemapRuntimeTests
{
[Fact]
public void Transport_internal_reply_and_loop_markers_never_leak_to_client_visible_subjects()
{
const string clientReply = "_INBOX.123";
var nested = ReplyMapper.ToGatewayReply(
ReplyMapper.ToGatewayReply(clientReply, "CLUSTER-A"),
"CLUSTER-B");
ReplyMapper.TryRestoreGatewayReply(nested, out var restored).ShouldBeTrue();
restored.ShouldBe(clientReply);
restored.ShouldNotStartWith("_GR_.");
}
}

View File

@@ -0,0 +1,18 @@
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
public class LeafLoopTransparencyRuntimeTests
{
[Fact]
public void Transport_internal_reply_and_loop_markers_never_leak_to_client_visible_subjects()
{
var nested = LeafLoopDetector.Mark(
LeafLoopDetector.Mark("orders.created", "S1"),
"S2");
LeafLoopDetector.TryUnmark(nested, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("orders.created");
unmarked.ShouldNotStartWith("$LDS.");
}
}