diff --git a/Libraries/Esiur/Core/ExceptionCode.cs b/Libraries/Esiur/Core/ExceptionCode.cs index 90323d4..2f095ed 100644 --- a/Libraries/Esiur/Core/ExceptionCode.cs +++ b/Libraries/Esiur/Core/ExceptionCode.cs @@ -47,5 +47,9 @@ public enum ExceptionCode : ushort NotSupported, NotImplemented, NotAllowed, - RateLimitExceeded + RateLimitExceeded, + ParserLimitExceeded, + AttachmentLimitExceeded, + AlreadyAttached, + ConnectionLimitExceeded } diff --git a/Libraries/Esiur/Data/AutoList.cs b/Libraries/Esiur/Data/AutoList.cs index 04b68ec..a603473 100644 --- a/Libraries/Esiur/Data/AutoList.cs +++ b/Libraries/Esiur/Data/AutoList.cs @@ -89,8 +89,8 @@ public class AutoList : IEnumerable, ICollection, ICollection /// Array public T[] ToArray() { - // list.OrderBy() - return list.ToArray(); + lock (syncRoot) + return list.ToArray(); } /// @@ -242,7 +242,11 @@ public class AutoList : IEnumerable, ICollection, ICollection /// public int Count { - get { return list.Count; } + get + { + lock (syncRoot) + return list.Count; + } } public bool IsSynchronized => (list as ICollection).IsSynchronized; @@ -256,7 +260,8 @@ public class AutoList : IEnumerable, ICollection, ICollection /// Item to check if exists public bool Contains(T value) { - return list.Contains(value); + lock (syncRoot) + return list.Contains(value); } /// diff --git a/Libraries/Esiur/Data/DataDeserializer.cs b/Libraries/Esiur/Data/DataDeserializer.cs index 01ecb73..bf1ae43 100644 --- a/Libraries/Esiur/Data/DataDeserializer.cs +++ b/Libraries/Esiur/Data/DataDeserializer.cs @@ -23,6 +23,18 @@ public static class DataDeserializer { internal static readonly AsyncLocal TypeDefRequestSequence = new(); + private static void EnsureTypedArrayBudget(ParsedTdu tdu, Warehouse warehouse, int elementSize) + => ParserGuard.EnsureAllocation( + warehouse, + ParserGuard.MultiplySaturated(tdu.PayloadLength, (ulong)elementSize), + "typed array"); + + private static T[] GuardDecodedArray(Warehouse warehouse, T[] values, int elementSize) + { + ParserGuard.EnsureCollectionCount(warehouse, values.Length, elementSize); + return values; + } + public static async AsyncReply TypeDefInfoParserAsync( ParsedTdu tdu, EpConnection connection, uint[] requestSequence) { @@ -324,6 +336,10 @@ public static class DataDeserializer public static object ResourceLinkParserAsync(ParsedTdu tdu, EpConnection connection, uint[] requestSequence) { + ParserGuard.EnsureAllocation( + connection?.ParsingWarehouse, + ParserGuard.MultiplySaturated(tdu.PayloadLength, 2), + "resource link"); var link = tdu.Data.GetString(tdu.PayloadOffset, (uint)tdu.PayloadLength); if (connection == null) { @@ -337,6 +353,10 @@ public static class DataDeserializer public static object ResourceLinkParser(ParsedTdu tdu, Warehouse warehouse) { + ParserGuard.EnsureAllocation( + warehouse, + ParserGuard.MultiplySaturated(tdu.PayloadLength, 2), + "resource link"); var link = tdu.Data.GetString(tdu.PayloadOffset, (uint)tdu.PayloadLength); return new ResourceLink(link); } @@ -432,27 +452,41 @@ public static class DataDeserializer public static unsafe object RawDataParserAsync(ParsedTdu tdu, EpConnection connection, uint[] requestSequence) { + ParserGuard.EnsureAllocation(connection?.ParsingWarehouse, tdu.PayloadLength, "raw data"); return tdu.Data.Clip(tdu.PayloadOffset, (uint)tdu.PayloadLength); } public static unsafe object RawDataParser(ParsedTdu tdu, Warehouse warehouse) { + ParserGuard.EnsureAllocation(warehouse, tdu.PayloadLength, "raw data"); return tdu.Data.Clip(tdu.PayloadOffset, (uint)tdu.PayloadLength); } public static unsafe object StringParserAsync(ParsedTdu tdu, EpConnection connection, uint[] requestSequence) { + ParserGuard.EnsureAllocation( + connection?.ParsingWarehouse, + ParserGuard.MultiplySaturated(tdu.PayloadLength, 2), + "string"); return tdu.Data.GetString(tdu.PayloadOffset, (uint)tdu.PayloadLength); } public static unsafe object StringParser(ParsedTdu tdu, Warehouse warehouse) { + ParserGuard.EnsureAllocation( + warehouse, + ParserGuard.MultiplySaturated(tdu.PayloadLength, 2), + "string"); return tdu.Data.GetString(tdu.PayloadOffset, (uint)tdu.PayloadLength); } public static async AsyncReply RecordParserAsync(ParsedTdu tdu, TypeDef recordTypeDef, EpConnection connection, uint[] requestSequence) { + ParserGuard.EnsureCollectionCount( + connection?.ParsingWarehouse, + recordTypeDef.Properties.Length, + IntPtr.Size); //if (tdu.Metadata.TypeDefId == null) // throw new Exception("TypeDefId metadata is required for record parsing."); @@ -618,6 +652,8 @@ public static class DataDeserializer "TypeDef not found for record."); } + ParserGuard.EnsureCollectionCount(warehouse, recordTypeDef.Properties.Length, IntPtr.Size); + var list = new List(); ParsedTdu current; @@ -789,6 +825,7 @@ public static class DataDeserializer public static async AsyncReply RecordListParserAsync(ParsedTdu tdu, EpConnection connection, uint[] requestSequence) { var rt = new AsyncBag(); + var count = 0; var length = tdu.PayloadLength; var offset = tdu.PayloadOffset; @@ -798,6 +835,7 @@ public static class DataDeserializer //var (cs, reply) var pr = await Codec.ParseAsync(tdu.Data, offset, connection, requestSequence); + ParserGuard.EnsureCollectionCount(connection?.ParsingWarehouse, ++count, IntPtr.Size); rt.Add(pr.Value); if (pr.Size > 0) @@ -827,6 +865,7 @@ public static class DataDeserializer { var (cs, reply) = Codec.ParseSync(tdu.Data, offset, warehouse); + ParserGuard.EnsureCollectionCount(warehouse, rt.Count + 1, IntPtr.Size); rt.Add(reply as IRecord); if (cs > 0) @@ -845,6 +884,7 @@ public static class DataDeserializer public static async AsyncReply ResourceListParserAsync(ParsedTdu tdu, EpConnection connection, uint[] requestSequence) { var rt = new AsyncBag(); + var count = 0; var length = tdu.PayloadLength; var offset = tdu.PayloadOffset; @@ -854,6 +894,7 @@ public static class DataDeserializer //var (cs, reply) var pr = await Codec.ParseAsync(tdu.Data, offset, connection, requestSequence); + ParserGuard.EnsureCollectionCount(connection?.ParsingWarehouse, ++count, IntPtr.Size); rt.Add(pr.Value);// reply); if (pr.Size > 0) @@ -884,6 +925,7 @@ public static class DataDeserializer { var (cs, reply) = Codec.ParseSync(tdu.Data, offset, warehouse); + ParserGuard.EnsureCollectionCount(warehouse, rt.Count + 1, IntPtr.Size); rt.Add(reply as IResource); if (cs > 0) @@ -931,6 +973,7 @@ public static class DataDeserializer var rt = new AsyncBag(); + var count = 0; //var list = new List(); @@ -962,6 +1005,7 @@ public static class DataDeserializer //var (cs, reply) var value = Codec.ParseAsync(current, connection, requestSequence); + ParserGuard.EnsureCollectionCount(connection?.ParsingWarehouse, ++count, IntPtr.Size); rt.Add(value); if (current.TotalLength > 0) @@ -1012,6 +1056,7 @@ public static class DataDeserializer var reply = Codec.ParseSync(current, warehouse); + ParserGuard.EnsureCollectionCount(warehouse, list.Count + 1, IntPtr.Size); list.Add(reply); if (current.TotalLength > 0) @@ -1040,6 +1085,7 @@ public static class DataDeserializer { var (cs, reply) = Codec.ParseSync(data, offset, warehouse); + ParserGuard.EnsureCollectionCount(warehouse, rt.Count + 1, IntPtr.Size); rt.Add(reply); if (cs > 0) @@ -1151,26 +1197,37 @@ public static class DataDeserializer public static Array TypedArrayParser(ParsedTdu tdu, Tru elementTru, Warehouse warehouse) { + var primitiveElementSize = elementTru.Identifier switch + { + TruIdentifier.Int16 or TruIdentifier.UInt16 => 2, + TruIdentifier.Int32 or TruIdentifier.UInt32 => 4, + TruIdentifier.Int64 or TruIdentifier.UInt64 => 8, + _ => 0 + }; + + if (primitiveElementSize > 0) + EnsureTypedArrayBudget(tdu, warehouse, primitiveElementSize); + switch (elementTru.Identifier) { case TruIdentifier.Int32: - return GroupInt32Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength)); + return GuardDecodedArray(warehouse, GroupInt32Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 4); case TruIdentifier.Int64: - return GroupInt64Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength)); + return GuardDecodedArray(warehouse, GroupInt64Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 8); case TruIdentifier.Int16: - return GroupInt16Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength)); + return GuardDecodedArray(warehouse, GroupInt16Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 2); case TruIdentifier.UInt32: - return GroupUInt32Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength)); + return GuardDecodedArray(warehouse, GroupUInt32Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 4); case TruIdentifier.UInt64: - return GroupUInt64Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength)); + return GuardDecodedArray(warehouse, GroupUInt64Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 8); case TruIdentifier.UInt16: - return GroupUInt16Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength)); + return GuardDecodedArray(warehouse, GroupUInt16Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 2); //case TruIdentifier.Enum: // var enumType = tru.GetRuntimeType(warehouse); @@ -1224,6 +1281,7 @@ public static class DataDeserializer var value = Codec.ParseSync(current, warehouse); + ParserGuard.EnsureCollectionCount(warehouse, list.Count + 1, IntPtr.Size); list.Add(value); if (current.TotalLength > 0) @@ -1249,26 +1307,38 @@ public static class DataDeserializer // Non-async/await version of TypedArrayParserAsync using continuations public static AsyncReply TypedArrayParserAsync2(ParsedTdu tdu, Tru elementTru, EpConnection connection, uint[] requestSequence) { + var warehouse = connection?.ParsingWarehouse; + var primitiveElementSize = elementTru.Identifier switch + { + TruIdentifier.Int16 or TruIdentifier.UInt16 => 2, + TruIdentifier.Int32 or TruIdentifier.UInt32 => 4, + TruIdentifier.Int64 or TruIdentifier.UInt64 => 8, + _ => 0 + }; + + if (primitiveElementSize > 0) + EnsureTypedArrayBudget(tdu, warehouse, primitiveElementSize); + switch (elementTru.Identifier) { case TruIdentifier.Int32: - return new AsyncReply(GroupInt32Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength))); + return new AsyncReply(GuardDecodedArray(warehouse, GroupInt32Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 4)); case TruIdentifier.Int64: - return new AsyncReply(GroupInt64Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength))); + return new AsyncReply(GuardDecodedArray(warehouse, GroupInt64Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 8)); case TruIdentifier.Int16: - return new AsyncReply(GroupInt16Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength))); + return new AsyncReply(GuardDecodedArray(warehouse, GroupInt16Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 2)); case TruIdentifier.UInt32: - return new AsyncReply(GroupUInt32Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength))); + return new AsyncReply(GuardDecodedArray(warehouse, GroupUInt32Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 4)); case TruIdentifier.UInt64: - return new AsyncReply(GroupUInt64Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength))); + return new AsyncReply(GuardDecodedArray(warehouse, GroupUInt64Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 8)); case TruIdentifier.UInt16: - return new AsyncReply(GroupUInt16Codec.Decode(tdu.Data.AsSpan( - (int)tdu.PayloadOffset, (int)tdu.PayloadLength))); + return new AsyncReply(GuardDecodedArray(warehouse, GroupUInt16Codec.Decode(tdu.Data.AsSpan( + (int)tdu.PayloadOffset, (int)tdu.PayloadLength)), 2)); default: var rt = new AsyncReply(); @@ -1285,6 +1355,7 @@ public static class DataDeserializer // Recursive processor using continuations Action processNext = null; + var itemCount = 0; processNext = (curOffset, curLength, previous) => { @@ -1333,6 +1404,19 @@ public static class DataDeserializer var reply = Codec.ParseAsync(current, connection, requestSequence); + try + { + ParserGuard.EnsureCollectionCount( + warehouse, + ++itemCount, + IntPtr.Size); + } + catch (Exception ex) + { + rt.TriggerError(ex); + return; + } + list.Add(reply); if (current.TotalLength > 0) diff --git a/Libraries/Esiur/Data/KeyList.cs b/Libraries/Esiur/Data/KeyList.cs index c50da5a..96368dc 100644 --- a/Libraries/Esiur/Data/KeyList.cs +++ b/Libraries/Esiur/Data/KeyList.cs @@ -62,14 +62,16 @@ public class KeyList : IEnumerable> public T Take(KT key) { - if (dic.ContainsKey(key)) + lock (syncRoot) { - var v = dic[key]; - Remove(key); - return v; - } - else + if (dic.TryGetValue(key, out var value)) + { + Remove(key); + return value; + } + return default(T); + } } public void Sort(Func, object> keySelector) @@ -79,9 +81,12 @@ public class KeyList : IEnumerable> public T[] ToArray() { - var a = new T[Count]; - dic.Values.CopyTo(a, 0); - return a; + lock (syncRoot) + { + var a = new T[dic.Count]; + dic.Values.CopyTo(a, 0); + return a; + } } public void Add(KT key, T value) @@ -132,10 +137,8 @@ public class KeyList : IEnumerable> { get { - if (dic.ContainsKey(key)) - return dic[key]; - else - return default(T); + lock (syncRoot) + return dic.TryGetValue(key, out var value) ? value : default(T); } set { @@ -157,13 +160,15 @@ public class KeyList : IEnumerable> public void Clear() { - if (removableList) - foreach (IDestructible v in dic.Values) - if (v != null) - v.OnDestroy -= ItemDestroyed; - lock (syncRoot) + { + if (removableList) + foreach (IDestructible v in dic.Values) + if (v != null) + v.OnDestroy -= ItemDestroyed; + dic.Clear(); + } if (OnCleared != null) OnCleared(this); @@ -184,17 +189,18 @@ public class KeyList : IEnumerable> public void Remove(KT key) { - if (!dic.ContainsKey(key)) - return; - - var value = dic[key]; - - if (removableList) - if (value != null) - ((IDestructible)value).OnDestroy -= ItemDestroyed; - + T value; lock (syncRoot) + { + if (!dic.TryGetValue(key, out value)) + return; + + if (removableList) + if (value != null) + ((IDestructible)value).OnDestroy -= ItemDestroyed; + dic.Remove(key); + } if (OnRemoved != null) OnRemoved(key, value, this); @@ -208,19 +214,26 @@ public class KeyList : IEnumerable> public int Count { - get { return dic.Count; } + get + { + lock (syncRoot) + return dic.Count; + } } public bool Contains(KT Key) { - return dic.ContainsKey(Key); + lock (syncRoot) + return dic.ContainsKey(Key); } public bool ContainsKey(KT Key) { - return dic.ContainsKey(Key); + lock (syncRoot) + return dic.ContainsKey(Key); } public bool ContainsValue(T Value) { - return dic.ContainsValue(Value); + lock (syncRoot) + return dic.ContainsValue(Value); } diff --git a/Libraries/Esiur/Data/ParsedTdu.cs b/Libraries/Esiur/Data/ParsedTdu.cs index 26986bb..8cb27e8 100644 --- a/Libraries/Esiur/Data/ParsedTdu.cs +++ b/Libraries/Esiur/Data/ParsedTdu.cs @@ -27,8 +27,6 @@ namespace Esiur.Data public static async AsyncReply ParseAsync(byte[] data, uint offset, uint ends, EpConnection connection) { - // @TODO: add protection against memory allocation attacks by checking the length of the data before parsing it. - var h = data[offset++]; var cls = (TduClass)(h >> 6); @@ -91,6 +89,8 @@ namespace Esiur.Data for (uint i = 0; i < cll; i++) cl = cl << 8 | data[offset++]; + ParserGuard.EnsurePacketSize(ParserGuard.GetWarehouse(connection), cl); + if (ends - offset < cl) return new ParsedTdu() { @@ -102,6 +102,10 @@ namespace Esiur.Data //offset += data[offset] + (uint)1; var metaDataTru = await Tru.ParseAsync(data, offset, connection, null); + + if (metaDataTru.Size > cl) + throw new ParserLimitException("Typed TDU metadata exceeds its declared payload length."); + offset += metaDataTru.Size; return new ParsedTdu() @@ -133,6 +137,8 @@ namespace Esiur.Data for (uint i = 0; i < cll; i++) cl = cl << 8 | data[offset++]; + ParserGuard.EnsurePacketSize(ParserGuard.GetWarehouse(connection), cl); + if (ends - offset < cl) return new ParsedTdu() { @@ -158,8 +164,6 @@ namespace Esiur.Data public static object Parse(byte[] data, uint offset, uint ends, EpConnection connection) { - // @TODO: add protection against memory allocation attacks by checking the length of the data before parsing it. - var h = data[offset++]; var cls = (TduClass)(h >> 6); @@ -222,6 +226,8 @@ namespace Esiur.Data for (uint i = 0; i < cll; i++) cl = cl << 8 | data[offset++]; + ParserGuard.EnsurePacketSize(ParserGuard.GetWarehouse(connection), cl); + if (ends - offset < cl) return new ParsedTdu() { @@ -235,6 +241,13 @@ namespace Esiur.Data Tru.ParseAsync(data, offset, connection, null).Then(metaDataTru => { + if (metaDataTru.Size > cl) + { + rt.TriggerError(new ParserLimitException( + "Typed TDU metadata exceeds its declared payload length.")); + return; + } + offset += metaDataTru.Size; rt.Trigger(new ParsedTdu() @@ -269,6 +282,8 @@ namespace Esiur.Data for (uint i = 0; i < cll; i++) cl = cl << 8 | data[offset++]; + ParserGuard.EnsurePacketSize(ParserGuard.GetWarehouse(connection), cl); + if (ends - offset < cl) return new ParsedTdu() { @@ -347,8 +362,6 @@ namespace Esiur.Data public static ParsedTdu ParseSync(byte[] data, uint offset, uint ends, Warehouse warehouse) { - // @TODO: add protection against memory allocation attacks by checking the length of the data before parsing it. - var h = data[offset++]; var cls = (TduClass)(h >> 6); @@ -411,6 +424,8 @@ namespace Esiur.Data for (uint i = 0; i < cll; i++) cl = cl << 8 | data[offset++]; + ParserGuard.EnsurePacketSize(warehouse, cl); + if (ends - offset < cl) return new ParsedTdu() { @@ -422,6 +437,10 @@ namespace Esiur.Data //offset += data[offset] + (uint)1; var metaDataTru = Tru.Parse(data, offset, warehouse); + + if (metaDataTru.Size > cl) + throw new ParserLimitException("Typed TDU metadata exceeds its declared payload length."); + offset += metaDataTru.Size; return new ParsedTdu() @@ -453,6 +472,8 @@ namespace Esiur.Data for (uint i = 0; i < cll; i++) cl = cl << 8 | data[offset++]; + ParserGuard.EnsurePacketSize(warehouse, cl); + if (ends - offset < cl) return new ParsedTdu() { diff --git a/Libraries/Esiur/Data/ParserGuard.cs b/Libraries/Esiur/Data/ParserGuard.cs new file mode 100644 index 0000000..8641e45 --- /dev/null +++ b/Libraries/Esiur/Data/ParserGuard.cs @@ -0,0 +1,61 @@ +using Esiur.Protocol; +using Esiur.Resource; +using System; +using System.IO; + +namespace Esiur.Data; + +/// +/// Raised when untrusted input exceeds a configured parser budget. +/// +public sealed class ParserLimitException : Exception +{ + public ParserLimitException(string message) : base(message) + { + } +} + +internal static class ParserGuard +{ + internal static Warehouse? GetWarehouse(EpConnection? connection) + => connection?.ParsingWarehouse; + + internal static void EnsurePacketSize(Warehouse? warehouse, ulong size) + { + var limit = warehouse?.Configuration.Parser.MaximumPacketSize ?? 0; + if (limit > 0 && size > limit) + throw new ParserLimitException( + $"Declared packet payload of {size} bytes exceeds the {limit}-byte limit."); + } + + internal static void EnsureAllocation(Warehouse? warehouse, ulong size, string kind) + { + var limit = warehouse?.Configuration.Parser.MaximumAllocationSize ?? 0; + if (limit > 0 && size > limit) + throw new ParserLimitException( + $"Decoded {kind} allocation of {size} bytes exceeds the {limit}-byte limit."); + } + + internal static void EnsureCollectionCount( + Warehouse? warehouse, + int count, + int estimatedBytesPerItem = 0) + { + var configuration = warehouse?.Configuration.Parser; + if (configuration == null) + return; + + if (configuration.MaximumCollectionItems > 0 && count > configuration.MaximumCollectionItems) + throw new ParserLimitException( + $"Decoded collection count of {count} exceeds the {configuration.MaximumCollectionItems}-item limit."); + + if (estimatedBytesPerItem > 0) + EnsureAllocation( + warehouse, + (ulong)count * (ulong)estimatedBytesPerItem, + "collection"); + } + + internal static ulong MultiplySaturated(ulong value, ulong multiplier) + => value > ulong.MaxValue / multiplier ? ulong.MaxValue : value * multiplier; +} diff --git a/Libraries/Esiur/Data/PlainTdu.cs b/Libraries/Esiur/Data/PlainTdu.cs index 3667e21..671a536 100644 --- a/Libraries/Esiur/Data/PlainTdu.cs +++ b/Libraries/Esiur/Data/PlainTdu.cs @@ -21,7 +21,11 @@ namespace Esiur.Data - public static PlainTdu Parse(byte[] data, uint offset, uint ends) + public static PlainTdu Parse( + byte[] data, + uint offset, + uint ends, + ulong maximumPayloadLength = ulong.MaxValue) { var oOffset = offset; @@ -91,6 +95,10 @@ namespace Esiur.Data for (uint i = 0; i < cll; i++) cl = cl << 8 | data[offset++]; + if (cl > maximumPayloadLength) + throw new ParserLimitException( + $"Declared packet payload of {cl} bytes exceeds the {maximumPayloadLength}-byte limit."); + if (ends - offset < cl) return new PlainTdu() { @@ -128,6 +136,10 @@ namespace Esiur.Data for (uint i = 0; i < cll; i++) cl = cl << 8 | data[offset++]; + if (cl > maximumPayloadLength) + throw new ParserLimitException( + $"Declared packet payload of {cl} bytes exceeds the {maximumPayloadLength}-byte limit."); + if (ends - offset < cl) return new PlainTdu() { diff --git a/Libraries/Esiur/Net/NetworkServer.cs b/Libraries/Esiur/Net/NetworkServer.cs index 09123a0..01f5380 100644 --- a/Libraries/Esiur/Net/NetworkServer.cs +++ b/Libraries/Esiur/Net/NetworkServer.cs @@ -112,6 +112,11 @@ public abstract class NetworkServer : IDestructible where TConnecti c.Assign(s); Add(c); + // A derived server can reject admission (for example, due to a per-peer + // connection quota) by not adding the connection and closing its socket. + if (!Connections.Contains(c)) + continue; + try { ClientConnected(c); diff --git a/Libraries/Esiur/Net/Packets/EpAuthPacket.cs b/Libraries/Esiur/Net/Packets/EpAuthPacket.cs index cec726f..4bc7e11 100644 --- a/Libraries/Esiur/Net/Packets/EpAuthPacket.cs +++ b/Libraries/Esiur/Net/Packets/EpAuthPacket.cs @@ -185,7 +185,12 @@ public class EpAuthPacket : Packet if (NotEnough(offset, ends, 1)) return -dataLengthNeeded; - Tdu = PlainTdu.Parse(data, offset, ends);//, _warehouse); + var maximumPacketSize = _warehouse.Configuration.Parser.MaximumPacketSize; + Tdu = PlainTdu.Parse( + data, + offset, + ends, + maximumPacketSize == 0 ? ulong.MaxValue : maximumPacketSize); if (Tdu.Value.Class == TduClass.Invalid) return -(int)Tdu.Value.TotalLength; diff --git a/Libraries/Esiur/Net/Packets/EpPacket.cs b/Libraries/Esiur/Net/Packets/EpPacket.cs index 64a211d..9c7296a 100644 --- a/Libraries/Esiur/Net/Packets/EpPacket.cs +++ b/Libraries/Esiur/Net/Packets/EpPacket.cs @@ -135,7 +135,12 @@ class EpPacket : Packet if (NotEnough(offset, ends, 1)) return -dataLengthNeeded; - Tdu = PlainTdu.Parse(data, offset, ends); + var maximumPacketSize = _warehouse.Configuration.Parser.MaximumPacketSize; + Tdu = PlainTdu.Parse( + data, + offset, + ends, + maximumPacketSize == 0 ? ulong.MaxValue : maximumPacketSize); if (Tdu.Value.Class == TduClass.Invalid) return -(int)Tdu.Value.TotalLength; diff --git a/Libraries/Esiur/Protocol/EpConnection.cs b/Libraries/Esiur/Protocol/EpConnection.cs index 25961aa..c34211e 100644 --- a/Libraries/Esiur/Protocol/EpConnection.cs +++ b/Libraries/Esiur/Protocol/EpConnection.cs @@ -121,6 +121,7 @@ public partial class EpConnection : NetworkConnection, IStore Warehouse _serverWarehouse; public EpServer Server => _server; + internal Warehouse ParsingWarehouse => Instance?.Warehouse ?? _serverWarehouse ?? Warehouse.Default; //public EpServer Server //{ // get => _server; @@ -1857,6 +1858,11 @@ public partial class EpConnection : NetworkConnection, IStore offset = processPacket(msg, offset, ends, data, chunkId); } } + catch (ParserLimitException ex) + { + Global.Log("EpConnection:ParserLimit", LogType.Warning, ex.Message); + Close(); + } catch (Exception ex) { Global.Log(ex); diff --git a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs index 025e552..97ab4f6 100644 --- a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs +++ b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs @@ -214,6 +214,8 @@ partial class EpConnection // Global.Counters suffer from). Used by the deadlock experiments. /// Number of resources fully attached on this connection (a monotonic progress signal). public long AttachedResourceCount { get; private set; } + /// Number of resource attach or reattach requests sent by this connection. + public long ResourceAttachRequestCount { get; private set; } /// Number of wait-for-cycle breaks (placeholders returned to break a cycle) on this connection. public long CycleBreakCount { get; private set; } /// Number of placeholders returned where no genuine cycle existed (legacy resolver only). @@ -235,6 +237,7 @@ partial class EpConnection volatile int _callbackCounter = 0; Dictionary> _subscriptions = new Dictionary>(); + readonly HashSet _peerAttachmentRequests = new HashSet(); // resources might get attached by the client internal KeyList _cache = new(); @@ -928,38 +931,55 @@ partial class EpConnection var resourceId = Convert.ToUInt32(value); + if (!TryBeginPeerAttachment(resourceId, out var exceptionCode, out var exceptionMessage)) + { + SendError(ErrorType.Management, callback, (ushort)exceptionCode, exceptionMessage); + return; + } + Instance.Warehouse.GetById(resourceId).Then((res) => { - if (res != null) + try { - if (res.Instance.Applicable(_session, ActionType.Attach, null) == Ruling.Denied) + if (res != null) { - SendError(ErrorType.Management, callback, 6); - return; + if (res.Instance.Applicable(_session, ActionType.Attach, null) == Ruling.Denied) + { + SendError(ErrorType.Management, callback, 6); + return; + } + + var r = res as IResource; + + // unsubscribe + Unsubscribe(r); + + // reply ok + SendReply(EpPacketReply.Completed, callback, + r.Instance.Definition.Id, + r.Instance.Age, + r.Instance.Link, + r.Instance.Hops, + r.Instance.Serialize()); + + // subscribe + Subscribe(r); + } + else + { + // reply failed + Global.Log("EpConnection", LogType.Debug, "Not found " + resourceId); + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound); } - - var r = res as IResource; - - // unsubscribe - Unsubscribe(r); - - // reply ok - SendReply(EpPacketReply.Completed, callback, - r.Instance.Definition.Id, - r.Instance.Age, - r.Instance.Link, - r.Instance.Hops, - r.Instance.Serialize()); - - // subscribe - Subscribe(r); } - else + finally { - // reply failed - Global.Log("EpConnection", LogType.Debug, "Not found " + resourceId); - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound); + EndPeerAttachment(resourceId); } + }).Error(ex => + { + EndPeerAttachment(resourceId); + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.GeneralFailure, ex.Message); }); } @@ -974,40 +994,57 @@ partial class EpConnection var age = Convert.ToUInt64(args[1]); + if (!TryBeginPeerAttachment(resourceId, out var exceptionCode, out var exceptionMessage)) + { + SendError(ErrorType.Management, callback, (ushort)exceptionCode, exceptionMessage); + return; + } + Instance.Warehouse.GetById(resourceId).Then((res) => { - if (res != null) + try { - if (res.Instance.Applicable(_session, ActionType.Attach, null) == Ruling.Denied) + if (res != null) { - SendError(ErrorType.Management, callback, 6); - return; + if (res.Instance.Applicable(_session, ActionType.Attach, null) == Ruling.Denied) + { + SendError(ErrorType.Management, callback, 6); + return; + } + + var r = res as IResource; + + // unsubscribe + Unsubscribe(r); + + + // reply ok + SendReply(EpPacketReply.Completed, callback, + r.Instance.Definition.Id, + r.Instance.Age, + r.Instance.Link, + r.Instance.Hops, + r.Instance.SerializeAfter(age)); + + + // subscribe + Subscribe(r); + } + else + { + // reply failed + Global.Log("EpConnection", LogType.Debug, "Not found " + resourceId); + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound); } - - var r = res as IResource; - - // unsubscribe - Unsubscribe(r); - - - // reply ok - SendReply(EpPacketReply.Completed, callback, - r.Instance.Definition.Id, - r.Instance.Age, - r.Instance.Link, - r.Instance.Hops, - r.Instance.SerializeAfter(age)); - - - // subscribe - Subscribe(r); } - else + finally { - // reply failed - Global.Log("EpConnection", LogType.Debug, "Not found " + resourceId); - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound); + EndPeerAttachment(resourceId); } + }).Error(ex => + { + EndPeerAttachment(resourceId); + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.GeneralFailure, ex.Message); }); } @@ -2660,6 +2697,88 @@ partial class EpConnection } } + private AsyncReply AttachmentLimitError(string message) + { + var reply = new AsyncReply(); + // AsyncReply throws when an error is triggered before any observer is attached. + // Install a sink so this already-failed reply can be returned and observed by Codec. + reply.Error(_ => { }); + reply.TriggerError(new AsyncException( + ErrorType.Management, + (ushort)ExceptionCode.AttachmentLimitExceeded, + message)); + return reply; + } + + private void FailResourceAttachment( + uint id, + AsyncReply reply, + Exception exception) + { + _resourceRequests.Remove(id); + _neededResources.Remove(id); + ClearResourceFetchNode(id); + reply.TriggerError(exception); + } + + private bool TryReserveResourceAttachment( + uint id, + AsyncReply reply, + uint[] requestSequence, + out AsyncReply alternative) + { + lock (_resourceRequests.SyncRoot) + { + var existing = _resourceRequests[id]; + if (existing != null) + { + alternative = existing.Reply; + return false; + } + + var configuration = ParsingWarehouse.Configuration.ResourceAttachments; + + if (configuration.MaximumPendingAttachmentsPerConnection > 0 + && _resourceRequests.Count >= configuration.MaximumPendingAttachmentsPerConnection) + { + alternative = AttachmentLimitError( + "The pending resource attachment limit for this connection was reached."); + return false; + } + + var attachedCount = 0; + lock (_attachedResources.SyncRoot) + { + var stale = new List(); + foreach (var pair in _attachedResources) + { + if (pair.Value != null && pair.Value.TryGetTarget(out _)) + attachedCount++; + else + stale.Add(pair.Key); + } + + foreach (var staleId in stale) + _attachedResources.Remove(staleId); + } + + if (configuration.MaximumAttachedResourcesPerConnection > 0 + && attachedCount + _resourceRequests.Count + >= configuration.MaximumAttachedResourcesPerConnection) + { + alternative = AttachmentLimitError( + "The resource attachment limit for this connection was reached."); + return false; + } + + _resourceRequests.Add( + id, + new FetchRequestInfo(reply, requestSequence)); + alternative = null; + return true; + } + } + public AsyncReply FetchResource(uint id, uint[] requestSequence) { //lock (fetchLock) @@ -2746,19 +2865,26 @@ partial class EpConnection var newSequence = requestSequence != null ? requestSequence.Concat(new uint[] { id }).ToArray() : new uint[] { id }; var reply = new AsyncReply(); - _resourceRequests.Add(id, new FetchRequestInfo(reply, newSequence)); + if (!TryReserveResourceAttachment(id, reply, newSequence, out var alternative)) + return alternative; // This fetch's parent now waits on `id` until it attaches. if (parent != null) AddResourceFetchBlock(parent.Value, id); + ResourceAttachRequestCount++; SendRequest(EpPacketRequest.AttachResource, id) .Then((result) => { if (result == null) { - reply.TriggerError(new AsyncException(ErrorType.Management, - (ushort)ExceptionCode.ResourceNotFound, "Null response")); + FailResourceAttachment( + id, + reply, + new AsyncException( + ErrorType.Management, + (ushort)ExceptionCode.ResourceNotFound, + "Null response")); return; } @@ -2801,11 +2927,7 @@ partial class EpConnection ClearResourceFetchNode(id); TryPublishDeliveredRoots(); reply.Trigger(dr); - }).Error(ex => { - _resourceRequests.Remove(id); - ClearResourceFetchNode(id); - reply.TriggerError(ex); - }); + }).Error(ex => FailResourceAttachment(id, reply, ex)); }; if (typeDef == null) @@ -2827,17 +2949,14 @@ partial class EpConnection _neededResources[id] = resource; Instance.Warehouse.Put(Instance.Link + "/" + id.ToString(), resource) .Then(initResource) - .Error(ex => reply.TriggerError(ex)); + .Error(ex => FailResourceAttachment(id, reply, ex)); } else { initResource(resource); } - }).Error((ex) => - { - reply.TriggerError(ex); - }); + }).Error(ex => FailResourceAttachment(id, reply, ex)); } else { @@ -2854,7 +2973,8 @@ partial class EpConnection // references in the graph can resolve back to this instance. _neededResources[id] = resource; Instance.Warehouse.Put(this.Instance.Link + "/" + id.ToString(), resource) - .Then(initResource).Error((ex) => reply.TriggerError(ex)); + .Then(initResource) + .Error(ex => FailResourceAttachment(id, reply, ex)); } else { @@ -2862,13 +2982,11 @@ partial class EpConnection } } - }).Error((ex) => + }).Error(ex => { // Failed to attach: drop the in-flight request and wait-for edges so a // later retry is not blocked by a stale entry. - _resourceRequests.Remove(id); - ClearResourceFetchNode(id); - reply.TriggerError(ex); + FailResourceAttachment(id, reply, ex); }); @@ -2903,8 +3021,10 @@ partial class EpConnection var reply = new AsyncReply(); var sequence = new uint[] { id }; - _resourceRequests.Add(id, new FetchRequestInfo(reply, sequence)); + if (!TryReserveResourceAttachment(id, reply, sequence, out var alternative)) + return alternative; + ResourceAttachRequestCount++; SendRequest(EpPacketRequest.ReattachResource, id, age).Then(result => { if (result == null) @@ -3106,10 +3226,62 @@ partial class EpConnection return reply; } + private bool TryBeginPeerAttachment( + uint resourceId, + out ExceptionCode exceptionCode, + out string exceptionMessage) + { + lock (_subscriptionsLock) + { + var configuration = ParsingWarehouse.Configuration.ResourceAttachments; + var alreadyAttached = _subscriptions.Keys.Any( + resource => resource.Instance?.Id == resourceId); + + if (configuration.RejectDuplicateAttachments + && (alreadyAttached || _peerAttachmentRequests.Contains(resourceId))) + { + exceptionCode = ExceptionCode.AlreadyAttached; + exceptionMessage = $"Resource {resourceId} is already attached or being attached by this connection."; + return false; + } + + if (configuration.MaximumPendingAttachmentsPerConnection > 0 + && _peerAttachmentRequests.Count >= configuration.MaximumPendingAttachmentsPerConnection) + { + exceptionCode = ExceptionCode.AttachmentLimitExceeded; + exceptionMessage = "The pending resource attachment limit for this connection was reached."; + return false; + } + + if (configuration.MaximumAttachedResourcesPerConnection > 0 + && _subscriptions.Count + _peerAttachmentRequests.Count + >= configuration.MaximumAttachedResourcesPerConnection) + { + exceptionCode = ExceptionCode.AttachmentLimitExceeded; + exceptionMessage = "The resource attachment limit for this connection was reached."; + return false; + } + + _peerAttachmentRequests.Add(resourceId); + exceptionCode = default; + exceptionMessage = null; + return true; + } + } + + private void EndPeerAttachment(uint resourceId) + { + lock (_subscriptionsLock) + _peerAttachmentRequests.Remove(resourceId); + } + private void Subscribe(IResource resource) { lock (_subscriptionsLock) { + if (_subscriptions.ContainsKey(resource)) + return; + resource.Instance.EventOccurred += Instance_EventOccurred; resource.Instance.CustomEventOccurred += Instance_CustomEventOccurred; resource.Instance.PropertyModified += Instance_PropertyModified; @@ -3147,6 +3319,7 @@ partial class EpConnection } _subscriptions.Clear(); + _peerAttachmentRequests.Clear(); } } diff --git a/Libraries/Esiur/Protocol/EpServer.cs b/Libraries/Esiur/Protocol/EpServer.cs index 24a0a08..f1cd87c 100644 --- a/Libraries/Esiur/Protocol/EpServer.cs +++ b/Libraries/Esiur/Protocol/EpServer.cs @@ -44,6 +44,9 @@ namespace Esiur.Protocol; public class EpServer : NetworkServer, IResource { + readonly object _peerConnectionsLock = new object(); + readonly Dictionary _peerConnectionCounts = new Dictionary(); + readonly Dictionary _admittedConnections = new Dictionary(); //[Attribute] @@ -167,19 +170,99 @@ public class EpServer : NetworkServer, IResource public override void Add(EpConnection connection) { - connection.Handle(ResourceOperation.Configure, - new EpServerConnectionContext() { - Server = this, - Warehouse = Instance.Warehouse } - ); + if (!TryAdmitConnection(connection)) + { + Global.Log( + "EpServer:ConnectionLimit", + LogType.Warning, + $"Rejected connection from {connection.RemoteEndPoint?.Address}: per-IP limit reached."); + connection.Close(); + return; + } - connection.ExceptionLevel = ExceptionLevel; - base.Add(connection); + try + { + connection.Handle(ResourceOperation.Configure, + new EpServerConnectionContext() + { + Server = this, + Warehouse = Instance.Warehouse + }); + + connection.ExceptionLevel = ExceptionLevel; + base.Add(connection); + } + catch + { + ReleaseConnection(connection); + connection.Close(); + throw; + } } public override void Remove(EpConnection connection) { - base.Remove(connection); + try + { + base.Remove(connection); + } + finally + { + ReleaseConnection(connection); + } + } + + private bool TryAdmitConnection(EpConnection connection) + { + var address = NormalizeAddress(connection.RemoteEndPoint?.Address); + if (address == null) + return true; + + lock (_peerConnectionsLock) + { + var count = _peerConnectionCounts.TryGetValue(address, out var current) + ? current + : 0; + var limit = Instance.Warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress; + + if (limit > 0 && count >= limit) + return false; + + _peerConnectionCounts[address] = count + 1; + _admittedConnections[connection] = address; + return true; + } + } + + private void ReleaseConnection(EpConnection connection) + { + lock (_peerConnectionsLock) + { + if (!_admittedConnections.TryGetValue(connection, out var address)) + return; + + _admittedConnections.Remove(connection); + + if (!_peerConnectionCounts.TryGetValue(address, out var count)) + return; + + if (count <= 1) + _peerConnectionCounts.Remove(address); + else + _peerConnectionCounts[address] = count - 1; + } + } + + private static IPAddress NormalizeAddress(IPAddress address) + => address?.IsIPv4MappedToIPv6 == true ? address.MapToIPv4() : address; + + internal int GetConnectionCount(IPAddress address) + { + address = NormalizeAddress(address); + lock (_peerConnectionsLock) + return address != null && _peerConnectionCounts.TryGetValue(address, out var count) + ? count + : 0; } protected override void ClientDisconnected(EpConnection connection) diff --git a/Libraries/Esiur/Resource/WarehouseConfiguration.cs b/Libraries/Esiur/Resource/WarehouseConfiguration.cs index 933754c..3c35313 100644 --- a/Libraries/Esiur/Resource/WarehouseConfiguration.cs +++ b/Libraries/Esiur/Resource/WarehouseConfiguration.cs @@ -8,6 +8,45 @@ namespace Esiur.Resource; public sealed class WarehouseConfiguration { public RateControlConfiguration RateControl { get; set; } = new RateControlConfiguration(); + public ParserConfiguration Parser { get; set; } = new ParserConfiguration(); + public ResourceAttachmentConfiguration ResourceAttachments { get; set; } = new ResourceAttachmentConfiguration(); + public ConnectionConfiguration Connections { get; set; } = new ConnectionConfiguration(); +} + +/// +/// Limits memory and object amplification while parsing untrusted packets. +/// A value of zero disables the corresponding limit. +/// +public sealed class ParserConfiguration +{ + /// Maximum declared TDU payload retained for one packet. + public uint MaximumPacketSize { get; set; } = 8 * 1024 * 1024; + + /// Maximum allocation produced by one decoded value. + public uint MaximumAllocationSize { get; set; } = 4 * 1024 * 1024; + + /// Maximum number of values decoded into one collection. + public int MaximumCollectionItems { get; set; } = 65_536; +} + +/// +/// Limits resources imported from, or attached by, one remote connection. +/// A value of zero disables the corresponding limit. +/// +public sealed class ResourceAttachmentConfiguration +{ + public int MaximumAttachedResourcesPerConnection { get; set; } = 4_096; + public int MaximumPendingAttachmentsPerConnection { get; set; } = 128; + public bool RejectDuplicateAttachments { get; set; } = true; +} + +/// +/// Configures network connection admission limits. +/// A value of zero disables the corresponding limit. +/// +public sealed class ConnectionConfiguration +{ + public int MaximumConnectionsPerIpAddress { get; set; } = 64; } /// diff --git a/Tests/Features/Functional/Program.cs b/Tests/Features/Functional/Program.cs index 3cf1a43..266421b 100644 --- a/Tests/Features/Functional/Program.cs +++ b/Tests/Features/Functional/Program.cs @@ -78,6 +78,12 @@ internal static class Program static async Task StartServer(Warehouse warehouse, ushort port) { warehouse.RegisterAuthenticationProvider(new ServerAuthenticationProvider()); + warehouse.Configuration.Parser.MaximumPacketSize = 8 * 1024 * 1024; + warehouse.Configuration.Parser.MaximumAllocationSize = 4 * 1024 * 1024; + warehouse.Configuration.Parser.MaximumCollectionItems = 65_536; + warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096; + warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128; + warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 64; warehouse.Configuration.RateControl.DenialsBeforeConnectionBlock = 10; warehouse.AddRatePolicy(new BurstRatePolicy("standard-call") { @@ -139,6 +145,8 @@ internal static class Program static async Task ConnectClient(Warehouse warehouse, ushort port) { warehouse.RegisterAuthenticationProvider(new ClientAuthenticationProvider()); + warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096; + warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128; return await warehouse.Get($"ep://localhost:{port}", new EpConnectionContext { diff --git a/Tests/Features/Functional/README.md b/Tests/Features/Functional/README.md index a3e48cf..99740f5 100644 --- a/Tests/Features/Functional/README.md +++ b/Tests/Features/Functional/README.md @@ -13,6 +13,7 @@ Coverage includes: - pull streams backed by `IAsyncEnumerable`; - `TerminateExecution` through async-enumerator disposal; - `HaltExecution` and `ResumeExecution` for a cooperative push stream. +- parser allocation and collection budgets, attachment quotas, and per-IP connection limits. Run it from the repository root: @@ -41,3 +42,17 @@ public void Call() { } ``` + +Security limits are configured per Warehouse. A value of zero disables an individual limit: + +```csharp +warehouse.Configuration.Parser.MaximumPacketSize = 8 * 1024 * 1024; +warehouse.Configuration.Parser.MaximumAllocationSize = 4 * 1024 * 1024; +warehouse.Configuration.Parser.MaximumCollectionItems = 65_536; + +warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096; +warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128; +warehouse.Configuration.ResourceAttachments.RejectDuplicateAttachments = true; + +warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 64; +``` diff --git a/Tests/Unit/Integration/AttachmentSecurityTests.cs b/Tests/Unit/Integration/AttachmentSecurityTests.cs new file mode 100644 index 0000000..47c7970 --- /dev/null +++ b/Tests/Unit/Integration/AttachmentSecurityTests.cs @@ -0,0 +1,45 @@ +using Esiur.Core; +using Esiur.Protocol; + +namespace Esiur.Tests.Unit.Integration; + +[Collection("Integration")] +public class AttachmentSecurityTests +{ + [Fact] + public async Task ConcurrentFetches_AreCoalescedIntoOneAttachRequest() + { + await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10)); + + var fetches = Enumerable.Range(0, 16) + .Select(_ => Task.Run(async () => await cluster.Connection.Get("sys/first"))) + .ToArray(); + var resources = await Task.WhenAll(fetches).WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.All(resources, resource => Assert.Same(resources[0], resource)); + Assert.Equal(1, cluster.Connection.ResourceAttachRequestCount); + } + + [Fact] + public async Task Connection_RefusesResourcesBeyondConfiguredAttachmentLimit() + { + await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10)); + cluster.ClientWarehouse.Configuration.ResourceAttachments + .MaximumAttachedResourcesPerConnection = 1; + + Assert.NotNull(await cluster.Connection.Get("sys/first")); + + var exception = await Assert.ThrowsAsync(async () => + await cluster.Connection.Get("sys/second")); + + Assert.Equal(ExceptionCode.AttachmentLimitExceeded, exception.Code); + Assert.Equal(1, cluster.Connection.ResourceAttachRequestCount); + } + + static Task StartCluster() + => IntegrationCluster.StartAsync(async warehouse => + { + await warehouse.Put("sys/first", new RateLimitedResource()); + await warehouse.Put("sys/second", new RateLimitedResource()); + }); +} diff --git a/Tests/Unit/ParserSecurityTests.cs b/Tests/Unit/ParserSecurityTests.cs new file mode 100644 index 0000000..71559d0 --- /dev/null +++ b/Tests/Unit/ParserSecurityTests.cs @@ -0,0 +1,55 @@ +using Esiur.Data; +using Esiur.Net.Packets; +using Esiur.Resource; + +namespace Esiur.Tests.Unit; + +public class ParserSecurityTests +{ + [Fact] + public void PacketParser_RejectsOversizedDeclarationBeforePayloadArrives() + { + var warehouse = new Warehouse(); + warehouse.Configuration.Parser.MaximumPacketSize = 1_024; + var packet = new EpPacket(warehouse); + + // EP notification with a RawData TDU whose four-byte length declares 1 MiB, + // without supplying that payload. The declaration itself must be rejected. + var data = new byte[] { 0x20, 0x60, 0x00, 0x10, 0x00, 0x00 }; + + Assert.Throws(() => + { + packet.Parse(data, 0, (uint)data.Length); + }); + } + + [Fact] + public void RawDataParser_EnforcesAllocationBudget() + { + var warehouse = new Warehouse(); + warehouse.Configuration.Parser.MaximumAllocationSize = 10; + var data = Codec.Compose(new byte[20], warehouse, null); + + Assert.Throws(() => Codec.ParseSync(data, 0, warehouse)); + } + + [Fact] + public void StringParser_AccountsForDecodedUtf16Allocation() + { + var warehouse = new Warehouse(); + warehouse.Configuration.Parser.MaximumAllocationSize = 10; + var data = Codec.Compose("123456", warehouse, null); + + Assert.Throws(() => Codec.ParseSync(data, 0, warehouse)); + } + + [Fact] + public void ListParser_EnforcesCollectionItemBudget() + { + var warehouse = new Warehouse(); + var data = Codec.Compose(new object[] { true, false, true }, warehouse, null); + warehouse.Configuration.Parser.MaximumCollectionItems = 2; + + Assert.Throws(() => Codec.ParseSync(data, 0, warehouse)); + } +} diff --git a/Tests/Unit/PeerConnectionLimitTests.cs b/Tests/Unit/PeerConnectionLimitTests.cs new file mode 100644 index 0000000..3058588 --- /dev/null +++ b/Tests/Unit/PeerConnectionLimitTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using Esiur.Core; +using Esiur.Data; +using Esiur.Net; +using Esiur.Net.Sockets; +using Esiur.Protocol; +using Esiur.Resource; + +namespace Esiur.Tests.Unit; + +public class PeerConnectionLimitTests +{ + [Fact] + public void Server_RejectsConnectionsAbovePerIpLimit_AndReleasesClosedSlot() + { + var warehouse = new Warehouse(); + warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 1; + + var server = new EpServer(); + server.Instance = new Instance(warehouse, 1, "server", server, null); + server.Connections = new AutoList>(server); + + var address = IPAddress.Parse("192.0.2.10"); + var firstSocket = new TestSocket(address, 10001); + var first = new EpConnection(); + first.Assign(firstSocket); + server.Add(first); + + var rejectedSocket = new TestSocket(address, 10002); + var rejected = new EpConnection(); + rejected.Assign(rejectedSocket); + server.Add(rejected); + + Assert.Single(server.Connections); + Assert.Equal(SocketState.Closed, rejectedSocket.State); + Assert.Equal(1, server.GetConnectionCount(address)); + + firstSocket.Close(); + Assert.Equal(0, server.GetConnectionCount(address)); + + var replacementSocket = new TestSocket(address, 10003); + var replacement = new EpConnection(); + replacement.Assign(replacementSocket); + server.Add(replacement); + + Assert.Equal(SocketState.Established, replacementSocket.State); + Assert.Equal(1, server.GetConnectionCount(address)); + + replacementSocket.Close(); + } + + sealed class TestSocket : ISocket + { + public event DestroyedEvent? OnDestroy; + + public SocketState State { get; private set; } = SocketState.Established; + public INetworkReceiver Receiver { get; set; } = null!; + public IPEndPoint RemoteEndPoint { get; } + public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 10518); + + public TestSocket(IPAddress address, int port) + => RemoteEndPoint = new IPEndPoint(address, port); + + public AsyncReply SendAsync(byte[] message, int offset, int length) + => new(true); + + public void Send(byte[] message) { } + public void Send(byte[] message, int offset, int length) { } + + public void Close() + { + if (State == SocketState.Closed) + return; + + State = SocketState.Closed; + Receiver?.NetworkClose(this); + } + + public AsyncReply Connect(string hostname, ushort port) => new(true); + public bool Begin() => true; + public AsyncReply BeginAsync() => new(true); + public AsyncReply AcceptAsync() => new((ISocket)null!); + public ISocket Accept() => null!; + public void Hold() { } + public void Unhold() { } + + public void Destroy() + { + Close(); + OnDestroy?.Invoke(this); + OnDestroy = null; + } + } +}