diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9018c93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test-and-audit: + runs-on: windows-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore solution and fail on vulnerable packages + run: dotnet restore Esiur.sln -p:NuGetAudit=true -p:NuGetAuditMode=all -p:NuGetAuditLevel=low '-warnaserror:NU1901;NU1902;NU1903;NU1904' + + - name: Build and run unit tests + run: dotnet test Tests/Unit/Esiur.Tests.Unit.csproj --configuration Release --no-restore --verbosity minimal + + - name: Build standalone web example + run: dotnet build Examples/StandaloneWebServer/Esiur.Examples.StandaloneWebServer.csproj --configuration Release --no-restore --verbosity minimal + + - name: Audit direct and transitive packages + run: dotnet list Esiur.sln package --vulnerable --include-transitive diff --git a/Examples/StandaloneWebServer/Program.cs b/Examples/StandaloneWebServer/Program.cs index 5f75230..899d6b3 100644 --- a/Examples/StandaloneWebServer/Program.cs +++ b/Examples/StandaloneWebServer/Program.cs @@ -24,11 +24,43 @@ internal class Program var service = await wh.Put("sys/demo", new Demo()); var http = await wh.Put("sys/http", new HttpServer() { Port = 8888 }); + var webRoot = Path.GetFullPath("Web"); + var webRootPrefix = webRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var pathComparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; http.MapGet("{url}", (string url, HttpConnection sender) => { - var fn = "Web/" + (sender.Request.Filename == "/" ? "/index.html" : sender.Request.Filename); + var requestedPath = sender.Request.Filename == "/" + ? "index.html" + : sender.Request.Filename.TrimStart('/', '\\'); + string fn; + + try + { + fn = Path.GetFullPath(Path.Combine(webRoot, requestedPath)); + } + catch (Exception exception) when ( + exception is ArgumentException || + exception is NotSupportedException || + exception is PathTooLongException) + { + sender.Response.Number = HttpResponseCode.BadRequest; + sender.Send("Invalid path"); + sender.Close(); + return; + } + + if (!fn.StartsWith(webRootPrefix, pathComparison)) + { + sender.Response.Number = HttpResponseCode.Forbidden; + sender.Send("Forbidden"); + sender.Close(); + return; + } if (File.Exists(fn)) { @@ -44,7 +76,7 @@ internal class Program else { sender.Response.Number = HttpResponseCode.NotFound; - sender.Send("`" + fn + "` Not Found"); + sender.Send("Not Found"); sender.Close(); } @@ -57,4 +89,4 @@ internal class Program Console.WriteLine("Running on http://localhost:8888"); } -} \ No newline at end of file +} diff --git a/Integrations/Esiur.AspNetCore/Esiur.AspNetCore.csproj b/Integrations/Esiur.AspNetCore/Esiur.AspNetCore.csproj index 7ecba93..ed5cf93 100644 --- a/Integrations/Esiur.AspNetCore/Esiur.AspNetCore.csproj +++ b/Integrations/Esiur.AspNetCore/Esiur.AspNetCore.csproj @@ -25,7 +25,6 @@ - diff --git a/Libraries/Esiur/Core/AsyncAwaiter.cs b/Libraries/Esiur/Core/AsyncAwaiter.cs index 618f399..9f2481a 100644 --- a/Libraries/Esiur/Core/AsyncAwaiter.cs +++ b/Libraries/Esiur/Core/AsyncAwaiter.cs @@ -1,52 +1,67 @@ -using System; -using System.Collections.Generic; +using System; using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; +using System.Threading; namespace Esiur.Core; public class AsyncAwaiter : INotifyCompletion { - Action callback = null; - - AsyncException exception = null; - - object result; + private static readonly Action CompletedSentinel = () => { }; + private Action continuation; + private AsyncException exception; + private object result; + private readonly AsyncReply reply; public AsyncAwaiter(AsyncReply reply) { - reply.Then(x => - { - this.IsCompleted = true; - this.result = x; - this.callback?.Invoke(); - }).Error(x => - { - exception = x; - this.IsCompleted = true; - this.callback?.Invoke(); - }); + this.reply = reply; + reply.Then(Complete).Error(Fail); } public object GetResult() { if (exception != null) - throw exception; + throw reply.GetExceptionForAwait(); + return result; } - public bool IsCompleted { get; private set; } + public bool IsCompleted + => ReferenceEquals(Volatile.Read(ref continuation), CompletedSentinel); public void OnCompleted(Action continuation) { - if (IsCompleted) - continuation?.Invoke(); - else - // Continue.... - callback = continuation; + if (continuation == null) + throw new ArgumentNullException(nameof(continuation)); + + var previous = Interlocked.CompareExchange(ref this.continuation, continuation, null); + if (ReferenceEquals(previous, CompletedSentinel)) + { + continuation(); + } + else if (previous != null) + { + throw new InvalidOperationException("The awaiter already has a continuation."); + } } + private void Complete(object value) + { + result = value; + InvokeContinuation(); + } + private void Fail(AsyncException value) + { + exception = value; + InvokeContinuation(); + } + + private void InvokeContinuation() + { + var registeredContinuation = Interlocked.Exchange(ref continuation, CompletedSentinel); + if (registeredContinuation != null && !ReferenceEquals(registeredContinuation, CompletedSentinel)) + registeredContinuation(); + } } diff --git a/Libraries/Esiur/Core/AsyncAwaiterGeneric.cs b/Libraries/Esiur/Core/AsyncAwaiterGeneric.cs index ca77ec0..9829603 100644 --- a/Libraries/Esiur/Core/AsyncAwaiterGeneric.cs +++ b/Libraries/Esiur/Core/AsyncAwaiterGeneric.cs @@ -1,50 +1,67 @@ -using System; -using System.Collections.Generic; +using System; using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; +using System.Threading; namespace Esiur.Core; public class AsyncAwaiter : INotifyCompletion { - Action callback = null; + private static readonly Action CompletedSentinel = () => { }; - AsyncException exception = null; - - T result; + private Action continuation; + private AsyncException exception; + private T result; + private readonly AsyncReply reply; public AsyncAwaiter(AsyncReply reply) { - reply.Then(x => - { - this.IsCompleted = true; - this.result = (T)x; - this.callback?.Invoke(); - }).Error(x => - { - exception = x; - this.IsCompleted = true; - this.callback?.Invoke(); - }); + this.reply = reply; + reply.Then(Complete).Error(Fail); } public T GetResult() { if (exception != null) - throw exception; + throw reply.GetExceptionForAwait(); + return result; } - public bool IsCompleted { get; private set; } + public bool IsCompleted + => ReferenceEquals(Volatile.Read(ref continuation), CompletedSentinel); public void OnCompleted(Action continuation) { - if (IsCompleted) - continuation?.Invoke(); - else - // Continue.... - callback = continuation; + if (continuation == null) + throw new ArgumentNullException(nameof(continuation)); + + var previous = Interlocked.CompareExchange(ref this.continuation, continuation, null); + if (ReferenceEquals(previous, CompletedSentinel)) + { + continuation(); + } + else if (previous != null) + { + throw new InvalidOperationException("The awaiter already has a continuation."); + } } + private void Complete(T value) + { + result = value; + InvokeContinuation(); + } + + private void Fail(AsyncException value) + { + exception = value; + InvokeContinuation(); + } + + private void InvokeContinuation() + { + var registeredContinuation = Interlocked.Exchange(ref continuation, CompletedSentinel); + if (registeredContinuation != null && !ReferenceEquals(registeredContinuation, CompletedSentinel)) + registeredContinuation(); + } } diff --git a/Libraries/Esiur/Core/AsyncBag.cs b/Libraries/Esiur/Core/AsyncBag.cs index df139b3..2e33f71 100644 --- a/Libraries/Esiur/Core/AsyncBag.cs +++ b/Libraries/Esiur/Core/AsyncBag.cs @@ -27,6 +27,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Esiur.Core; @@ -44,6 +45,7 @@ public class AsyncBag : AsyncReply, IAsyncBag int count = 0; bool sealedBag = false; + readonly object bagLock = new object(); public virtual Type ArrayType { get; set; } = typeof(T); @@ -79,24 +81,28 @@ public class AsyncBag : AsyncReply, IAsyncBag public void Seal() { - if (sealedBag) - return; + object[] pending; + lock (bagLock) + { + if (sealedBag) + return; - sealedBag = true; + sealedBag = true; + pending = replies.ToArray(); + } - var results = ArrayType == null ? new T[replies.Count] - : Array.CreateInstance(ArrayType, replies.Count); + var results = ArrayType == null ? new T[pending.Length] + : Array.CreateInstance(ArrayType, pending.Length); - if (replies.Count == 0) + if (pending.Length == 0) { Trigger(results); return; } - for (var i = 0; i < replies.Count; i++) + for (var i = 0; i < pending.Length; i++) { - - var k = replies[i]; + var k = pending[i]; var index = i; if (k is AsyncReply reply) @@ -104,19 +110,17 @@ public class AsyncBag : AsyncReply, IAsyncBag reply.Then((r) => { results.SetValue(r, index); - count++; - if (count == replies.Count) + if (Interlocked.Increment(ref count) == pending.Length) Trigger(results); }).Error(e => TriggerError(e)); } else { if (ArrayType != null) - replies[i] = RuntimeCaster.Cast(replies[i], ArrayType); - - results.SetValue(replies[i], index); - count++; - if (count == replies.Count) + k = RuntimeCaster.Cast(k, ArrayType); + + results.SetValue(k, index); + if (Interlocked.Increment(ref count) == pending.Length) Trigger(results); } } @@ -125,20 +129,24 @@ public class AsyncBag : AsyncReply, IAsyncBag public void Add(object valueOrReply) { - if (!sealedBag) + lock (bagLock) { - //if (valueOrReply is AsyncReply) - //{ - // results.Add(default(T)); - replies.Add(valueOrReply); - //} + if (!sealedBag) + replies.Add(valueOrReply); } } public void AddBag(AsyncBag bag) { - foreach (var r in bag.replies) + if (bag == null) + throw new ArgumentNullException(nameof(bag)); + + object[] source; + lock (bag.bagLock) + source = bag.replies.ToArray(); + + foreach (var r in source) Add(r); } diff --git a/Libraries/Esiur/Core/AsyncBagAwaiter.cs b/Libraries/Esiur/Core/AsyncBagAwaiter.cs index c60d947..7aa41aa 100644 --- a/Libraries/Esiur/Core/AsyncBagAwaiter.cs +++ b/Libraries/Esiur/Core/AsyncBagAwaiter.cs @@ -1,51 +1,65 @@ -using System; -using System.Collections.Generic; +using System; using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; +using System.Threading; namespace Esiur.Core; public class AsyncBagAwaiter : INotifyCompletion { - Action callback = null; + private static readonly Action CompletedSentinel = () => { }; - AsyncException exception = null; - - T[] result; + private Action continuation; + private AsyncException exception; + private T[] result; public AsyncBagAwaiter(AsyncBag reply) { - reply.Then(x => - { - this.IsCompleted = true; - this.result = x; - this.callback?.Invoke(); - }).Error(x => - { - exception = x; - this.IsCompleted = true; - this.callback?.Invoke(); - }); + reply.Then(Complete).Error(Fail); } public T[] GetResult() { if (exception != null) throw exception; + return result; } - public bool IsCompleted { get; private set; } + public bool IsCompleted + => ReferenceEquals(Volatile.Read(ref continuation), CompletedSentinel); public void OnCompleted(Action continuation) { - if (IsCompleted) - continuation?.Invoke(); - else - // Continue.... - callback = continuation; + if (continuation == null) + throw new ArgumentNullException(nameof(continuation)); + + var previous = Interlocked.CompareExchange(ref this.continuation, continuation, null); + if (ReferenceEquals(previous, CompletedSentinel)) + { + continuation(); + } + else if (previous != null) + { + throw new InvalidOperationException("The awaiter already has a continuation."); + } } + private void Complete(T[] value) + { + result = value; + InvokeContinuation(); + } + private void Fail(AsyncException value) + { + exception = value; + InvokeContinuation(); + } + + private void InvokeContinuation() + { + var registeredContinuation = Interlocked.Exchange(ref continuation, CompletedSentinel); + if (registeredContinuation != null && !ReferenceEquals(registeredContinuation, CompletedSentinel)) + registeredContinuation(); + } } diff --git a/Libraries/Esiur/Core/AsyncQueue.cs b/Libraries/Esiur/Core/AsyncQueue.cs index 8929485..f4fdfc3 100644 --- a/Libraries/Esiur/Core/AsyncQueue.cs +++ b/Libraries/Esiur/Core/AsyncQueue.cs @@ -108,21 +108,22 @@ public class AsyncQueue : AsyncReply Arrival = DateTime.Now, HasResource = hasResource }); + resultReady = false; } - resultReady = false; if (reply.Ready) processQueue(default(T)); else - reply.Then(processQueue); + reply.Then(processQueue).Error(TriggerError); } public void Remove(AsyncReply reply) { lock (queueLock) { - var item = list.FirstOrDefault(i => i.Reply == reply); - list.Remove(item); + var index = list.FindIndex(item => ReferenceEquals(item.Reply, reply)); + if (index >= 0) + list.RemoveAt(index); } processQueue(default(T)); @@ -145,34 +146,34 @@ public class AsyncQueue : AsyncReply } } - var flushId = currentFlushId++; + if (batchSize > 0) + { + var flushId = currentFlushId++; + var readyItems = list.GetRange(0, batchSize); - for (var i = 0; i < list.Count; i++) - if (list[i].Reply.Ready) + // Shift the remaining list once. The previous RemoveAt(0) loop shifted + // it once per delivered item and became quadratic for large batches. + list.RemoveRange(0, batchSize); + + foreach (var item in readyItems) { - Trigger(list[i].Reply.Result); + Trigger(item.Reply.Result); resultReady = false; if (captureProcessedItems) { - var p = list[i]; - p.Delivered = DateTime.Now; - p.Ready = p.Reply.ReadyTime; - p.BatchSize = batchSize; - p.FlushId = flushId; - //p.HasResource = p.Reply. (p.Ready - p.Arrival).TotalMilliseconds > 5; - processed.Add(p); + var processedItem = item; + processedItem.Delivered = DateTime.Now; + processedItem.Ready = processedItem.Reply.ReadyTime; + processedItem.BatchSize = batchSize; + processedItem.FlushId = flushId; + processed.Add(processedItem); } - - list.RemoveAt(i); - - i--; } - else - break; - } + } - resultReady = (list.Count == 0); + resultReady = list.Count == 0; + } } public AsyncQueue() diff --git a/Libraries/Esiur/Core/AsyncReply.cs b/Libraries/Esiur/Core/AsyncReply.cs index 872c2e1..41d51d5 100644 --- a/Libraries/Esiur/Core/AsyncReply.cs +++ b/Libraries/Esiur/Core/AsyncReply.cs @@ -1,4 +1,4 @@ -/* +/* Copyright (c) 2017 Ahmed Kh. Zamil @@ -22,408 +22,368 @@ SOFTWARE. */ +using Esiur.Resource; using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Esiur.Resource; -using System.Reflection; -using System.Threading; using System.Runtime.CompilerServices; -using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; namespace Esiur.Core; [AsyncMethodBuilder(typeof(AsyncReplyBuilder))] public class AsyncReply { - public DateTime ReadyTime; - protected List> callbacks = new List>(); - protected object result; + // These lists are intentionally lazy. Completed replies are common and should not + // allocate callback storage or a kernel-backed wait primitive unless it is needed. + protected List> callbacks; + protected List> errorCallbacks; + protected List> progressCallbacks; + protected List> chunkCallbacks; + protected List> propagationCallbacks; + protected List> warningCallbacks; - protected List> errorCallbacks = null; + protected volatile object result; + protected volatile bool resultReady; - protected List> progressCallbacks = null; - - protected List> chunkCallbacks = null; - - protected List> propagationCallbacks = null; - protected List> warningCallbacks = null; - - - object asyncLock = new object(); - - //public Timer timeout;// = new Timer() - protected bool resultReady = false; - AsyncException exception; - // StackTrace trace; - AutoResetEvent mutex = new AutoResetEvent(false); + private readonly object asyncLock = new object(); + private volatile AsyncException exception; + private Exception observedException; + private ManualResetEventSlim completionEvent; public static int MaxId; - public int Id; - public bool Ready - { - get { return resultReady; } - } + protected string codePath, codeMethod; + protected int codeLine; + + public bool Ready => resultReady; public bool Failed => exception != null; public Exception Exception => exception; + public object Result => result; + public static AsyncReply FromResult(T result) => new AsyncReply(result); public object Wait() { - if (resultReady) - return result; + ManualResetEventSlim waiter; - mutex.WaitOne(); - - if (exception != null) - throw exception; - - return result; - } - - //int timeoutMilliseconds = 0; - public void Timeout(int milliseconds, Action callback = null) - { - - //timeoutMilliseconds = milliseconds; - - Task.Delay(milliseconds).ContinueWith(x => + lock (asyncLock) { - if (!resultReady && exception == null) - { - TriggerError(new AsyncException(ErrorType.Management, - (ushort)ExceptionCode.Timeout, "Execution timeout expired.")); + if (resultReady) + return result; + if (exception != null) + throw observedException ?? exception; - callback?.Invoke(); - } - }); + waiter = completionEvent ?? (completionEvent = new ManualResetEventSlim(false)); + } + waiter.Wait(); + return GetWaitResult(); } public object Wait(int millisecondsTimeout) { - if (resultReady) - return result; + ManualResetEventSlim waiter; - //if (Debug) - // Console.WriteLine($"AsyncReply: {Id} Wait"); - - if (!mutex.WaitOne(millisecondsTimeout)) - { - var e = new Exception("AsyncReply timeout"); - TriggerError(e); - throw e; - } - - //if (Debug) - // Console.WriteLine($"AsyncReply: {Id} Wait ended"); - - return result; - } - - public object Result - { - get { return result; } - } - - - protected string codePath, codeMethod; - protected int codeLine; - - public AsyncReply Then(Action callback, [CallerMemberName] string methodName = null, [CallerFilePath] string filePath = null, [CallerLineNumber] int lineNumber = 0) - { - if (codeLine == 0) - { - codeLine = lineNumber; codeMethod = methodName; codePath = filePath; - } - //lock (callbacksLock) - //{ lock (asyncLock) { - // trace = new StackTrace(); + if (resultReady) + return result; + if (exception != null) + throw observedException ?? exception; + + waiter = completionEvent ?? (completionEvent = new ManualResetEventSlim(false)); + } + + if (!waiter.Wait(millisecondsTimeout)) + { + var timeoutException = new Exception("AsyncReply timeout"); + + // If completion won the timeout race, return that terminal state. Otherwise + // retain the timeout on the reply and preserve Wait's historical exception. + if (TrySetException(timeoutException)) + throw timeoutException; + } + + return GetWaitResult(); + } + + public void Timeout(int milliseconds, Action callback = null) + { + _ = Task.Delay(milliseconds).ContinueWith(_ => + { + var timeoutException = new AsyncException( + ErrorType.Management, + (ushort)ExceptionCode.Timeout, + "Execution timeout expired."); + + if (TrySetException(timeoutException)) + callback?.Invoke(); + }); + } + + public AsyncReply Then( + Action callback, + [CallerMemberName] string methodName = null, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = 0) + { + object completedResult = null; + var invokeImmediately = false; + + lock (asyncLock) + { + if (codeLine == 0) + { + codeLine = lineNumber; + codeMethod = methodName; + codePath = filePath; + } if (resultReady) { - //if (Debug) - // Console.WriteLine($"AsyncReply: {Id} Then ready"); - - callback(result); - return this; + completedResult = result; + invokeImmediately = true; + } + else if (exception == null) + { + (callbacks ?? (callbacks = new List>())).Add(callback); } - - - //timeout = new Timer(x => - //{ - // // Get calling method name - // Console.WriteLine(trace.GetFrame(1).GetMethod().Name); - - // var tr = String.Join("\r\n", trace.GetFrames().Select(f => f.GetMethod().Name)); - // timeout.Dispose(); - - // tr = trace.ToString(); - // throw new Exception("Request timeout " + Id); - //}, null, 15000, 0); - - - //if (Debug) - // Console.WriteLine($"AsyncReply: {Id} Then pending"); - - callbacks.Add(callback); - - return this; } + + if (invokeImmediately) + callback(completedResult); + + return this; } - - public AsyncReply Error(Action callback) { + AsyncException completedException = null; - if (errorCallbacks == null) - errorCallbacks = new List>(); + lock (asyncLock) + { + if (exception != null) + { + completedException = exception; + } + else if (!resultReady) + { + (errorCallbacks ?? (errorCallbacks = new List>())).Add(callback); + } + } - errorCallbacks.Add(callback); - - if (exception != null) - callback(exception); + if (completedException != null) + callback(completedException); return this; } public AsyncReply Progress(Action callback) { - if (progressCallbacks == null) - progressCallbacks = new List>(); + lock (asyncLock) + (progressCallbacks ?? (progressCallbacks = new List>())).Add(callback); - progressCallbacks.Add(callback); return this; } public AsyncReply Warning(Action callback) { - if (warningCallbacks == null) - warningCallbacks = new List>(); + lock (asyncLock) + (warningCallbacks ?? (warningCallbacks = new List>())).Add(callback); - warningCallbacks.Add(callback); return this; } public AsyncReply Chunk(Action callback) { - if (chunkCallbacks == null) - chunkCallbacks = new List>(); + lock (asyncLock) + (chunkCallbacks ?? (chunkCallbacks = new List>())).Add(callback); - chunkCallbacks.Add(callback); return this; } public AsyncReply Propagation(Action callback) { - if (propagationCallbacks == null) - propagationCallbacks = new List>(); + lock (asyncLock) + (propagationCallbacks ?? (propagationCallbacks = new List>())).Add(callback); - propagationCallbacks.Add(callback); return this; } public void Trigger(object result) { + Action singleCallback = null; + Action[] registeredCallbacks = null; + var callbackCount = 0; + ManualResetEventSlim waiter; + lock (asyncLock) { + if (exception != null || resultReady) + return; + ReadyTime = DateTime.Now; - - //timeout?.Dispose(); - - if (exception != null) - return; - - //if (Debug) - // Console.WriteLine($"AsyncReply: {Id} Trigger"); - - if (resultReady) - return; - this.result = result; - resultReady = true; - //if (mutex != null) - mutex.Set(); - - foreach (var cb in callbacks) - cb(result); - - - //if (Debug) - // Console.WriteLine($"AsyncReply: {Id} Trigger ended"); + // AsyncQueue deliberately resets resultReady between deliveries. Snapshot a + // multi-callback list before unlocking, while keeping its usual one-callback + // delivery path allocation-free. + if (callbacks != null) + { + callbackCount = callbacks.Count; + if (callbackCount == 1) + singleCallback = callbacks[0]; + else if (callbackCount > 1) + registeredCallbacks = callbacks.ToArray(); + } + waiter = completionEvent; } - return; + waiter?.Set(); + + if (callbackCount == 1) + singleCallback(result); + else if (registeredCallbacks != null) + foreach (var callback in registeredCallbacks) + callback(result); } public virtual void TriggerError(Exception exception) { - //timeout?.Dispose(); + TrySetException(exception); + } - if (resultReady) - return; - - if (exception is AsyncException) - this.exception = exception as AsyncException; - else - this.exception = new AsyncException(exception); - - if (errorCallbacks != null) - { - foreach (var cb in errorCallbacks) - cb(this.exception); - } - else - { - // no error handlers found - throw exception; - } - - mutex?.Set(); + internal void TriggerErrorFromBuilder(Exception exception, bool preserveSourceForAwait) + { + TrySetException(exception, preserveSourceForAwait); + } + internal Exception GetExceptionForAwait() + { + lock (asyncLock) + return observedException ?? exception; } public void TriggerProgress(ProgressType type, uint value, uint max) { - //timeout?.Dispose(); + Action[] registeredCallbacks; - if (progressCallbacks != null) - foreach (var cb in progressCallbacks) - cb(type, value, max); + lock (asyncLock) + registeredCallbacks = progressCallbacks?.ToArray(); - return; + if (registeredCallbacks != null) + foreach (var callback in registeredCallbacks) + callback(type, value, max); } public void TriggerWarning(byte level, string message) { - //timeout?.Dispose(); + Action[] registeredCallbacks; - if (warningCallbacks != null) - foreach (var cb in warningCallbacks) - cb(level, message); + lock (asyncLock) + registeredCallbacks = warningCallbacks?.ToArray(); - return ; + if (registeredCallbacks != null) + foreach (var callback in registeredCallbacks) + callback(level, message); } - public void TriggerPropagation(object value) { - //timeout?.Dispose(); + Action[] registeredCallbacks; - if (propagationCallbacks != null) - foreach (var cb in propagationCallbacks) - cb(value); + lock (asyncLock) + registeredCallbacks = propagationCallbacks?.ToArray(); - return; + if (registeredCallbacks != null) + foreach (var callback in registeredCallbacks) + callback(value); } - - public void TriggerChunk(object value) { + Action[] registeredCallbacks; - //timeout?.Dispose(); - - - if (chunkCallbacks != null) - foreach (var cb in chunkCallbacks) - cb(value); - + lock (asyncLock) + registeredCallbacks = chunkCallbacks?.ToArray(); + if (registeredCallbacks != null) + foreach (var callback in registeredCallbacks) + callback(value); } - public AsyncAwaiter GetAwaiter() - { - return new AsyncAwaiter(this); - } - - + public AsyncAwaiter GetAwaiter() => new AsyncAwaiter(this); public AsyncReply() { - // this.Debug = true; - Id = MaxId++; + Id = Interlocked.Increment(ref MaxId) - 1; } public AsyncReply(object result) { - // this.Debug = true; - ReadyTime = DateTime.Now; - resultReady = true; this.result = result; - - Id = MaxId++; + Id = Interlocked.Increment(ref MaxId) - 1; } - /* -public AsyncReply Then(Action callback) + private object GetWaitResult() { - base.Then(new Action(o => callback((T)o))); - return this; - } - - public void Trigger(T result) - { - Trigger((object)result); - } - - public Task MoveNext(CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public void Dispose() - { - } - - public AsyncReply() - { - - } - - public new Task Task - { - get + lock (asyncLock) { - return base.Task.ContinueWith((t) => - { + if (exception != null) + throw observedException ?? exception; -#if NETSTANDARD - return (T)t.GetType().GetTypeInfo().GetProperty("Result").GetValue(t); -#else - return (T)t.GetType().GetProperty("Result").GetValue(t); -#endif - }); + return result; } } - public T Current => throw new NotImplementedException(); - - public AsyncReply(T result) - : base(result) + private bool TrySetException(Exception sourceException, bool preserveSourceForAwait = false) { + AsyncException asyncException; + Action singleCallback = null; + Action[] registeredCallbacks = null; + var callbackCount = 0; + ManualResetEventSlim waiter; + lock (asyncLock) + { + if (resultReady || exception != null) + return false; + + asyncException = sourceException as AsyncException ?? new AsyncException(sourceException); + observedException = preserveSourceForAwait ? sourceException : asyncException; + exception = asyncException; + + if (errorCallbacks != null) + { + callbackCount = errorCallbacks.Count; + if (callbackCount == 1) + singleCallback = errorCallbacks[0]; + else if (callbackCount > 1) + registeredCallbacks = errorCallbacks.ToArray(); + } + + waiter = completionEvent; + } + + waiter?.Set(); + + if (callbackCount == 1) + singleCallback(asyncException); + else if (registeredCallbacks != null) + foreach (var callback in registeredCallbacks) + callback(asyncException); + + return true; } - -*/ - - - } diff --git a/Libraries/Esiur/Core/AsyncReplyBuilder.cs b/Libraries/Esiur/Core/AsyncReplyBuilder.cs index 3f0c545..320f1da 100644 --- a/Libraries/Esiur/Core/AsyncReplyBuilder.cs +++ b/Libraries/Esiur/Core/AsyncReplyBuilder.cs @@ -3,12 +3,14 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text; +using System.Threading; namespace Esiur.Core; public class AsyncReplyBuilder { AsyncReply reply; + int hasSuspended; AsyncReplyBuilder(AsyncReply reply) { @@ -33,7 +35,9 @@ public class AsyncReplyBuilder public void SetException(Exception exception) { - reply.TriggerError(exception); + reply.TriggerErrorFromBuilder( + exception, + preserveSourceForAwait: Volatile.Read(ref hasSuspended) == 0); } public void SetResult() @@ -46,6 +50,7 @@ public class AsyncReplyBuilder where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { + Volatile.Write(ref hasSuspended, 1); awaiter.OnCompleted(stateMachine.MoveNext); } @@ -54,6 +59,7 @@ public class AsyncReplyBuilder where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine { + Volatile.Write(ref hasSuspended, 1); awaiter.UnsafeOnCompleted(stateMachine.MoveNext); } diff --git a/Libraries/Esiur/Core/AsyncReplyBuilderGeneric.cs b/Libraries/Esiur/Core/AsyncReplyBuilderGeneric.cs index 20b525f..ac112ba 100644 --- a/Libraries/Esiur/Core/AsyncReplyBuilderGeneric.cs +++ b/Libraries/Esiur/Core/AsyncReplyBuilderGeneric.cs @@ -3,12 +3,14 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text; +using System.Threading; namespace Esiur.Core; public class AsyncReplyBuilder { AsyncReply reply; + int hasSuspended; AsyncReplyBuilder(AsyncReply reply) { @@ -33,7 +35,9 @@ public class AsyncReplyBuilder public void SetException(Exception exception) { - reply.TriggerError(exception); + reply.TriggerErrorFromBuilder( + exception, + preserveSourceForAwait: Volatile.Read(ref hasSuspended) == 0); } public void SetResult(T result) @@ -46,6 +50,7 @@ public class AsyncReplyBuilder where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { + Volatile.Write(ref hasSuspended, 1); awaiter.OnCompleted(stateMachine.MoveNext); } @@ -54,6 +59,7 @@ public class AsyncReplyBuilder where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine { + Volatile.Write(ref hasSuspended, 1); awaiter.UnsafeOnCompleted(stateMachine.MoveNext); } diff --git a/Libraries/Esiur/Core/AsyncReplyGeneric.cs b/Libraries/Esiur/Core/AsyncReplyGeneric.cs index a19c197..bb7839a 100644 --- a/Libraries/Esiur/Core/AsyncReplyGeneric.cs +++ b/Libraries/Esiur/Core/AsyncReplyGeneric.cs @@ -1,4 +1,4 @@ -/* +/* Copyright (c) 2017 Ahmed Kh. Zamil @@ -22,31 +22,22 @@ SOFTWARE. */ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Esiur.Resource; -using System.Reflection; -using System.Threading; +using System; using System.Runtime.CompilerServices; -using System.Diagnostics; namespace Esiur.Core; [AsyncMethodBuilder(typeof(AsyncReplyBuilder<>))] public class AsyncReply : AsyncReply { - - public AsyncReply Then(Action callback, [CallerMemberName] string methodName = null, [CallerFilePath] string filePath = null, [CallerLineNumber] int lineNumber = 0) + public AsyncReply Then( + Action callback, + [CallerMemberName] string methodName = null, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = 0) { - if (base.codeLine == 0) - { - base.codeLine = lineNumber; base.codeMethod = methodName; base.codePath = filePath; - } - - base.Then((x) => callback((T)x)); + base.Then(value => callback((T)value), methodName, filePath, lineNumber); return this; } @@ -56,329 +47,25 @@ public class AsyncReply : AsyncReply return this; } - public AsyncReply Chunk(Action callback) { - chunkCallbacks.Add((x) => callback((T)x)); + base.Chunk(value => callback((T)value)); return this; } public AsyncReply(T result) - : base(result) + : base(result) { - } - public AsyncReply() : base() { - } - public new AsyncAwaiter GetAwaiter() - { - return new AsyncAwaiter(this); - } - - public new T Wait() - { - return (T)base.Wait(); - } - - public new T Wait(int millisecondsTimeout) - { - return (T)base.Wait(millisecondsTimeout); - } - - /* - protected new List callbacks = new List(); - protected new object result; - - protected new List> errorCallbacks = new List>(); - - protected new List> progressCallbacks = new List>(); - - protected new List chunkCallbacks = new List(); - - //List awaiters = new List(); - - object asyncLock = new object(); - - //public Timer timeout;// = new Timer() - - AsyncException exception; - // StackTrace trace; - AutoResetEvent mutex = new AutoResetEvent(false); - - public static int MaxId; - - public int Id; - - public bool Ready - { - get { return resultReady; } - - } - - - public T Wait() - { - - if (resultReady) - return result; - - if (Debug) - Console.WriteLine($"AsyncReply: {Id} Wait"); - - //mutex = new AutoResetEvent(false); - mutex.WaitOne(); - - if (Debug) - Console.WriteLine($"AsyncReply: {Id} Wait ended"); - - - return result; - } - - - public object Result - { - get { return result; } - } - - - public IAsyncReply Then(Action callback) - { - //lock (callbacksLock) - //{ - lock (asyncLock) - { - // trace = new StackTrace(); - - if (resultReady) - { - if (Debug) - Console.WriteLine($"AsyncReply: {Id} Then ready"); - - callback(result); - return this; - } - - - //timeout = new Timer(x => - //{ - // // Get calling method name - // Console.WriteLine(trace.GetFrame(1).GetMethod().Name); - - // var tr = String.Join("\r\n", trace.GetFrames().Select(f => f.GetMethod().Name)); - // timeout.Dispose(); - - // tr = trace.ToString(); - // throw new Exception("Request timeout " + Id); - //}, null, 15000, 0); - - - if (Debug) - Console.WriteLine($"AsyncReply: {Id} Then pending"); - - - - callbacks.Add(callback); - - return this; - } - } - - - - public IAsyncReply Error(Action callback) - { - // lock (callbacksLock) - // { - errorCallbacks.Add(callback); - - if (exception != null) - callback(exception); - - return this; - //} - } - - public IAsyncReply Progress(Action callback) - { - //lock (callbacksLock) - //{ - progressCallbacks.Add(callback); - return this; - //} - } - - - public IAsyncReply Chunk(Action callback) - { - // lock (callbacksLock) - // { - chunkCallbacks.Add(callback); - return this; - // } - } - - public void Trigger(object result) - { - lock (asyncLock) - { - //timeout?.Dispose(); - - if (Debug) - Console.WriteLine($"AsyncReply: {Id} Trigger"); - - if (resultReady) - return; - - this.result = (T)result; - - resultReady = true; - - //if (mutex != null) - mutex.Set(); - - foreach (var cb in callbacks) - cb((T)result); - - - if (Debug) - Console.WriteLine($"AsyncReply: {Id} Trigger ended"); - - } - } - - public void TriggerError(Exception exception) - { - //timeout?.Dispose(); - - if (resultReady) - return; - - if (exception is AsyncException) - this.exception = exception as AsyncException; - else - this.exception = new AsyncException(ErrorType.Management, 0, exception.Message); - - - // lock (callbacksLock) - // { - foreach (var cb in errorCallbacks) - cb(this.exception); - // } - - mutex?.Set(); - - } - - public void TriggerProgress(ProgressType type, int value, int max) - { - //timeout?.Dispose(); - - if (resultReady) - return; - - //lock (callbacksLock) - //{ - foreach (var cb in progressCallbacks) - cb(type, value, max); - - //} - } - - - public void TriggerChunk(object value) - { - - //timeout?.Dispose(); - - if (resultReady) - return; - - //lock (callbacksLock) - //{ - foreach (var cb in chunkCallbacks) - cb((T)value); - - //} - } - - public AsyncAwaiter GetAwaiter() - { - return new AsyncAwaiter(this); - } - - - - public AsyncReply() - { - // this.Debug = true; - Id = MaxId++; - } - - public AsyncReply(T result) - { - // this.Debug = true; - resultReady = true; - this.result = result; - - Id = MaxId++; - } - - /* -public AsyncReply Then(Action callback) - { - base.Then(new Action(o => callback((T)o))); - return this; - } - - public void Trigger(T result) - { - Trigger((object)result); - } - - public Task MoveNext(CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public void Dispose() - { - } - - public AsyncReply() - { - - } - - public new Task Task - { - get - { - return base.Task.ContinueWith((t) => - { - -#if NETSTANDARD - return (T)t.GetType().GetTypeInfo().GetProperty("Result").GetValue(t); -#else - return (T)t.GetType().GetProperty("Result").GetValue(t); -#endif - }); - } - } - - public T Current => throw new NotImplementedException(); - - - -*/ - - + public new AsyncAwaiter GetAwaiter() => new AsyncAwaiter(this); + public new T Wait() => (T)base.Wait(); + public new T Wait(int millisecondsTimeout) => (T)base.Wait(millisecondsTimeout); } diff --git a/Libraries/Esiur/Core/AsyncStreamReply.cs b/Libraries/Esiur/Core/AsyncStreamReply.cs index 7dfb47b..207f3be 100644 --- a/Libraries/Esiur/Core/AsyncStreamReply.cs +++ b/Libraries/Esiur/Core/AsyncStreamReply.cs @@ -204,9 +204,7 @@ public class AsyncStreamReply : AsyncReply } OnStreamError(exception); - - if (errorCallbacks != null) - base.TriggerError(exception); + base.TriggerError(exception); } /// diff --git a/Libraries/Esiur/Data/ParserGuard.cs b/Libraries/Esiur/Data/ParserGuard.cs index 8641e45..42b373e 100644 --- a/Libraries/Esiur/Data/ParserGuard.cs +++ b/Libraries/Esiur/Data/ParserGuard.cs @@ -56,6 +56,18 @@ internal static class ParserGuard "collection"); } + internal static void EnsureTypeMetadataDepth(Warehouse? warehouse, int depth) + { + // Some compatibility entry points accept a null Warehouse. Keep those safe by + // applying the built-in default rather than silently disabling this stack guard. + var limit = warehouse?.Configuration.Parser.MaximumTypeMetadataDepth + ?? ParserConfiguration.DefaultMaximumTypeMetadataDepth; + + if (limit > 0 && depth > limit) + throw new ParserLimitException( + $"TRU type metadata depth of {depth} exceeds the configured limit of {limit}."); + } + internal static ulong MultiplySaturated(ulong value, ulong multiplier) => value > ulong.MaxValue / multiplier ? ulong.MaxValue : value * multiplier; } diff --git a/Libraries/Esiur/Data/StringKeyList.cs b/Libraries/Esiur/Data/StringKeyList.cs index c6c4db9..199d4fd 100644 --- a/Libraries/Esiur/Data/StringKeyList.cs +++ b/Libraries/Esiur/Data/StringKeyList.cs @@ -37,7 +37,7 @@ namespace Esiur.Data; public class StringKeyList : IEnumerable> { - private List> m_Variables = new List>(); + private readonly List> m_Variables = new List>(); private bool allowMultiple; @@ -54,44 +54,32 @@ public class StringKeyList : IEnumerable> if (OnModified != null) OnModified(key, value); - key = key.ToLower(); - if (!allowMultiple) { - foreach (var kv in m_Variables) - { - if (kv.Key.ToLower() == key) - { - m_Variables.Remove(kv); - break; - } - } + var index = m_Variables.FindIndex( + item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + m_Variables.RemoveAt(index); } - m_Variables.Add(new KeyValuePair(key, value)); + m_Variables.Add(new KeyValuePair(key.ToLowerInvariant(), value)); } public string this[string key] { get { - key = key.ToLower(); foreach (var kv in m_Variables) - if (kv.Key.ToLower() == key) + if (string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase)) return kv.Value; return null; } set { - key = key.ToLower(); - - var toRemove = m_Variables.Where(x => x.Key.ToLower() == key).ToArray(); - - foreach (var item in toRemove) - m_Variables.Remove(item); - - + key = key.ToLowerInvariant(); + m_Variables.RemoveAll( + item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)); m_Variables.Add(new KeyValuePair(key, value)); OnModified?.Invoke(key, value); @@ -121,12 +109,10 @@ public class StringKeyList : IEnumerable> public List GetValues(string Key) { - var key = Key.ToLower(); - List values = new List(); foreach (var kv in m_Variables) - if (kv.Key.ToLower() == key) + if (string.Equals(kv.Key, Key, StringComparison.OrdinalIgnoreCase)) values.Add(kv.Value); return values; @@ -134,22 +120,26 @@ public class StringKeyList : IEnumerable> public void RemoveAll(string key) { - while (Remove(key)) { } + for (var i = m_Variables.Count - 1; i >= 0; i--) + { + if (!string.Equals(m_Variables[i].Key, key, StringComparison.OrdinalIgnoreCase)) + continue; + + m_Variables.RemoveAt(i); + OnModified?.Invoke(key, null); + } } public bool Remove(string key) { - key = key.ToLower(); - - foreach (var kv in m_Variables) + for (var i = 0; i < m_Variables.Count; i++) { - if (kv.Key.ToLower() == key) - { - if (OnModified != null) - OnModified(key, null); - m_Variables.Remove(kv); - return true; - } + if (!string.Equals(m_Variables[i].Key, key, StringComparison.OrdinalIgnoreCase)) + continue; + + m_Variables.RemoveAt(i); + OnModified?.Invoke(key, null); + return true; } return false; @@ -162,9 +152,8 @@ public class StringKeyList : IEnumerable> public bool ContainsKey(string key) { - key = key.ToLower(); foreach (var kv in m_Variables) - if (kv.Key.ToLower() == key) + if (string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase)) return true; return false; } @@ -172,12 +161,11 @@ public class StringKeyList : IEnumerable> public bool ContainsValue(string value) { - value = value.ToLower(); foreach (var kv in m_Variables) - if (kv.Value.ToLower() == value) + if (string.Equals(kv.Value, value, StringComparison.OrdinalIgnoreCase)) return true; return false; } -} \ No newline at end of file +} diff --git a/Libraries/Esiur/Data/Tru.cs b/Libraries/Esiur/Data/Tru.cs index c9c0a82..c8c5782 100644 --- a/Libraries/Esiur/Data/Tru.cs +++ b/Libraries/Esiur/Data/Tru.cs @@ -666,7 +666,16 @@ namespace Esiur.Data //} public static IParseResult Parse(byte[] data, uint offset, Warehouse warehouse) + => Parse(data, offset, warehouse, 1); + + private static IParseResult Parse( + byte[] data, + uint offset, + Warehouse warehouse, + int depth) { + ParserGuard.EnsureTypeMetadataDepth(warehouse, depth); + var oOffset = offset; var header = data[offset++]; @@ -753,7 +762,7 @@ namespace Esiur.Data var subTypes = new Tru[subsCount]; for (var i = 0; i < subsCount; i++) { - var pr = Tru.Parse(data, offset, warehouse); + var pr = Parse(data, offset, warehouse, depth + 1); subTypes[i] = pr.Value; offset += pr.Size; } @@ -793,8 +802,18 @@ namespace Esiur.Data - public static async AsyncReply> ParseAsync(byte[] data, uint offset, EpConnection connection, ulong[] requestSequence) + public static AsyncReply> ParseAsync(byte[] data, uint offset, EpConnection connection, ulong[] requestSequence) + => ParseAsync(data, offset, connection, requestSequence, 1); + + private static async AsyncReply> ParseAsync( + byte[] data, + uint offset, + EpConnection connection, + ulong[] requestSequence, + int depth) { + ParserGuard.EnsureTypeMetadataDepth(ParserGuard.GetWarehouse(connection), depth); + var oOffset = offset; var header = data[offset++]; @@ -891,7 +910,7 @@ namespace Esiur.Data var subTypes = new Tru[subsCount]; for (var i = 0; i < subsCount; i++) { - var pr = await Tru.ParseAsync(data, offset, connection, requestSequence); + var pr = await ParseAsync(data, offset, connection, requestSequence, depth + 1); subTypes[i] = pr.Value; offset += pr.Size; } @@ -930,4 +949,4 @@ namespace Esiur.Data } } } -} \ No newline at end of file +} diff --git a/Libraries/Esiur/Net/Http/HttpConnection.cs b/Libraries/Esiur/Net/Http/HttpConnection.cs index 3cb08f2..e02405e 100644 --- a/Libraries/Esiur/Net/Http/HttpConnection.cs +++ b/Libraries/Esiur/Net/Http/HttpConnection.cs @@ -42,55 +42,87 @@ using Esiur.Net.Packets.Http; namespace Esiur.Net.Http; public class HttpConnection : NetworkConnection { + const long InvalidPacket = long.MinValue; + const string WebSocketMagicString = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + const string GenericInternalServerError = "An internal server error occurred."; - + MemoryStream websocketFragmentBuffer = new MemoryStream(); + WebsocketPacket.WSOpcode? websocketFragmentOpcode; + ulong websocketFragmentLength; + bool websocketCloseSent; + uint parsedHttpPacketLength; public bool WSMode { get; internal set; } public HttpServer Server { get; internal set; } public WebsocketPacket WSRequest { get; set; } + public string WebSocketSubprotocol { get; private set; } public HttpRequestPacket Request { get; set; } public HttpResponsePacket Response { get; } = new HttpResponsePacket(); HttpSession session; + public HttpSession Session => session; + public KeyList Variables { get; } = new KeyList(); internal long Parse(byte[] data) { - if (WSMode) + parsedHttpPacketLength = 0; + try { - // now parse WS protocol - WebsocketPacket ws = new WebsocketPacket(); - - var pSize = ws.Parse(data, 0, (uint)data.Length); - - - if (pSize > 0) + if (WSMode) { - WSRequest = ws; - return 0; + var ws = new WebsocketPacket + { + ExpectedMask = true, + MaximumPayloadLength = Server?.MaximumWebSocketMessageLength + ?? WebsocketPacket.DefaultMaximumPayloadLength + }; + var packetSize = ws.Parse(data, 0, (uint)data.Length); + + if (packetSize > 0) + { + WSRequest = ws; + return 0; + } + + return packetSize == 0 ? InvalidPacket : packetSize; } else { - return pSize; + var request = new HttpRequestPacket(); + if (Server != null) + { + request.MaximumContentLength = Server.MaxPost; + request.MaximumHeaderLength = Server.MaximumHeaderLength; + request.MaximumHeaderCount = Server.MaximumHeaderCount; + request.MaximumFormFields = Server.MaximumFormFields; + request.MaximumFormKeyLength = Server.MaximumFormKeyLength; + request.MaximumFormValueLength = Server.MaximumFormValueLength; + request.MaximumMultipartPartLength = Server.MaximumMultipartPartLength; + } + + var packetSize = request.Parse(data, 0, (uint)data.Length); + if (packetSize > 0) + { + Request = request; + parsedHttpPacketLength = (uint)packetSize; + return 0; + } + + return packetSize == 0 ? InvalidPacket : packetSize; } } - else + catch (Exception exception) when ( + exception is InvalidDataException || + exception is ParserLimitException || + exception is ArgumentException) { - var rp = new HttpRequestPacket(); - var pSize = rp.Parse(data, 0, (uint)data.Length); - if (pSize > 0) - { - Request = rp; - return 0; - } - else - { - return pSize; - } + Global.Log(exception); + return InvalidPacket; } } @@ -98,16 +130,26 @@ public class HttpConnection : NetworkConnection public void Flush() { // close the connection - if (Request.Headers["connection"].ToLower() != "keep-alive" & IsConnected) + if (!string.Equals( + Request?.Headers?["connection"], + "keep-alive", + StringComparison.OrdinalIgnoreCase) && IsConnected) Close(); } public bool Upgrade() { - var ok = Upgrade(Request, Response); + var ok = Upgrade( + Request, + Response, + Server?.WebSocketSubprotocols, + out var selectedSubprotocol); if (ok) { + WebSocketSubprotocol = selectedSubprotocol; + websocketCloseSent = false; + ResetWebSocketFragment(); WSMode = true; Send(); } @@ -117,28 +159,44 @@ public class HttpConnection : NetworkConnection public static bool Upgrade(HttpRequestPacket request, HttpResponsePacket response) { - if (IsWebsocketRequest(request)) - { - string magicString = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - string ret = request.Headers["Sec-WebSocket-Key"] + magicString; - // Compute the SHA1 hash - SHA1 sha = SHA1.Create(); - byte[] sha1Hash = sha.ComputeHash(Encoding.UTF8.GetBytes(ret)); - response.Headers["Upgrade"] = request.Headers["Upgrade"]; - response.Headers["Connection"] = request.Headers["Connection"];// "Upgrade"; - response.Headers["Sec-WebSocket-Accept"] = Convert.ToBase64String(sha1Hash); + return Upgrade(request, response, null, out _); + } - if (request.Headers.ContainsKey("Sec-WebSocket-Protocol")) - response.Headers["Sec-WebSocket-Protocol"] = request.Headers["Sec-WebSocket-Protocol"]; + /// + /// Validates a WebSocket handshake and selects at most one mutually supported + /// subprotocol. Subprotocol names are case-sensitive as required by RFC 6455. + /// + public static bool Upgrade( + HttpRequestPacket request, + HttpResponsePacket response, + IEnumerable supportedSubprotocols, + out string selectedSubprotocol) + { + selectedSubprotocol = null; + response?.Headers.RemoveAll("Sec-WebSocket-Protocol"); + if (response == null || + !TryValidateWebSocketRequest(request, out var requestedSubprotocols)) + return false; - response.Number = HttpResponseCode.Switching; - response.Text = "Switching Protocols"; + selectedSubprotocol = SelectSubprotocol( + requestedSubprotocols, + supportedSubprotocols); - return true; - } + var challenge = request.Headers["Sec-WebSocket-Key"] + WebSocketMagicString; + byte[] sha1Hash; + using (var sha = SHA1.Create()) + sha1Hash = sha.ComputeHash(Encoding.ASCII.GetBytes(challenge)); - return false; + response.Headers["Upgrade"] = "websocket"; + response.Headers["Connection"] = "Upgrade"; + response.Headers["Sec-WebSocket-Accept"] = Convert.ToBase64String(sha1Hash); + if (selectedSubprotocol != null) + response.Headers["Sec-WebSocket-Protocol"] = selectedSubprotocol; + + response.Number = HttpResponseCode.Switching; + response.Text = "Switching Protocols"; + return true; } public HttpServer Parent @@ -151,8 +209,22 @@ public class HttpConnection : NetworkConnection public void Send(WebsocketPacket packet) { - if (packet.Data != null) + if (packet == null) + return; + + // This class is always the server side of the built-in WebSocket path. + // Recompose even prebuilt packets so caller-supplied masked data cannot be sent. + packet.Mask = false; + packet.MaximumPayloadLength = IsControlOpcode(packet.Opcode) + ? 125 + : Server?.MaximumWebSocketMessageLength + ?? WebsocketPacket.DefaultMaximumPayloadLength; + if (packet.Compose()) + { + if (packet.Opcode == WebsocketPacket.WSOpcode.ConnectionClose) + websocketCloseSent = true; base.Send(packet.Data); + } } public override void Send(string data) @@ -215,6 +287,8 @@ public class HttpConnection : NetworkConnection cookie.Expires = DateTime.MaxValue; cookie.Path = "/"; cookie.HttpOnly = true; + cookie.Secure = Server.SSL; + cookie.SameSite = HttpCookieSameSite.Lax; Response.Cookies.Add(cookie); } @@ -228,33 +302,224 @@ public class HttpConnection : NetworkConnection public static bool IsWebsocketRequest(HttpRequestPacket request) { - if (request.Headers.ContainsKey("connection") - && request.Headers["connection"].ToLower().Contains("upgrade") - && request.Headers.ContainsKey("upgrade") - && request.Headers["upgrade"].ToLower() == "websocket" - && request.Headers.ContainsKey("Sec-WebSocket-Version") - && request.Headers["Sec-WebSocket-Version"] == "13" - && request.Headers.ContainsKey("Sec-WebSocket-Key")) - //&& Request.Headers.ContainsKey("Sec-WebSocket-Protocol")) + return TryValidateWebSocketRequest(request, out _); + } + + private static bool TryValidateWebSocketRequest( + HttpRequestPacket request, + out string[] requestedSubprotocols) + { + requestedSubprotocols = Array.Empty(); + if (request == null || + request.Headers == null || + request.Method != Packets.Http.HttpMethod.GET || + (request.RawMethod != null && + !string.Equals(request.RawMethod, "GET", StringComparison.Ordinal)) || + !string.Equals(request.Version, "HTTP/1.1", StringComparison.Ordinal)) + return false; + + if (!TryParseTokenList(request.Headers["Connection"], out var connectionTokens) || + !ContainsToken(connectionTokens, "Upgrade", StringComparison.OrdinalIgnoreCase)) + return false; + + if (!TryParseUpgradeList(request.Headers["Upgrade"], out var hasWebSocket) || + !hasWebSocket) + return false; + + if (!string.Equals( + request.Headers["Sec-WebSocket-Version"], + "13", + StringComparison.Ordinal)) + return false; + + var key = request.Headers["Sec-WebSocket-Key"]; + if (!IsCanonicalWebSocketKey(key)) + return false; + + var protocols = request.Headers["Sec-WebSocket-Protocol"]; + if (protocols != null && !TryParseTokenList(protocols, out requestedSubprotocols)) + return false; + + return true; + } + + private static bool IsWebSocketUpgradeAttempt(HttpRequestPacket request) + { + var upgrade = request?.Headers?["Upgrade"]; + if (string.IsNullOrEmpty(upgrade)) + return false; + + for (var index = 0; index < upgrade.Length;) { - return true; + while (index < upgrade.Length && !IsHttpTokenCharacter(upgrade[index])) + index++; + var start = index; + while (index < upgrade.Length && IsHttpTokenCharacter(upgrade[index])) + index++; + + if (index > start && + string.Equals( + upgrade.Substring(start, index - start), + "websocket", + StringComparison.OrdinalIgnoreCase)) + return true; } - else + + return false; + } + + private static bool IsCanonicalWebSocketKey(string key) + { + if (string.IsNullOrEmpty(key)) + return false; + + try + { + var decoded = Convert.FromBase64String(key); + return decoded.Length == 16 && + string.Equals( + Convert.ToBase64String(decoded), + key, + StringComparison.Ordinal); + } + catch (FormatException) { return false; } } + private static bool TryParseTokenList(string value, out string[] tokens) + { + tokens = Array.Empty(); + if (string.IsNullOrEmpty(value)) + return false; + + var parts = value.Split(','); + for (var i = 0; i < parts.Length; i++) + { + parts[i] = parts[i].Trim(); + if (!IsHttpToken(parts[i])) + return false; + } + + tokens = parts; + return true; + } + + private static bool TryParseUpgradeList(string value, out bool hasWebSocket) + { + hasWebSocket = false; + if (string.IsNullOrEmpty(value)) + return false; + + foreach (var entry in value.Split(',')) + { + var protocol = entry.Trim(); + var slash = protocol.IndexOf('/'); + if (slash < 0) + { + if (!IsHttpToken(protocol)) + return false; + + if (string.Equals(protocol, "websocket", StringComparison.OrdinalIgnoreCase)) + hasWebSocket = true; + } + else + { + if (slash == 0 || + slash == protocol.Length - 1 || + protocol.IndexOf('/', slash + 1) >= 0 || + !IsHttpToken(protocol.Substring(0, slash)) || + !IsHttpToken(protocol.Substring(slash + 1))) + return false; + } + } + + return true; + } + + private static bool IsHttpToken(string value) + { + if (string.IsNullOrEmpty(value)) + return false; + + foreach (var character in value) + { + if (IsHttpTokenCharacter(character)) + continue; + + return false; + } + + return true; + } + + private static bool IsHttpTokenCharacter(char character) + => (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character == '!' || character == '#' || character == '$' || + character == '%' || character == '&' || character == '\'' || + character == '*' || character == '+' || character == '-' || + character == '.' || character == '^' || character == '_' || + character == '`' || character == '|' || character == '~'; + + private static bool ContainsToken( + string[] tokens, + string expected, + StringComparison comparison) + { + foreach (var token in tokens) + if (string.Equals(token, expected, comparison)) + return true; + + return false; + } + + private static string SelectSubprotocol( + string[] requestedSubprotocols, + IEnumerable supportedSubprotocols) + { + if (supportedSubprotocols == null) + return null; + + foreach (var supported in supportedSubprotocols) + { + if (!IsHttpToken(supported)) + continue; + + if (ContainsToken( + requestedSubprotocols, + supported, + StringComparison.Ordinal)) + return supported; + } + + return null; + } + protected override void DataReceived(NetworkBuffer data) { + if (WSMode) + { + ProcessWebSocketData(data); + return; + } byte[] msg = data.Read(); + if (msg == null) + return; var BL = Parse(msg); - if (BL == 0) + if (BL == InvalidPacket) { - if (Request.Method == Packets.Http.HttpMethod.UNKNOWN) + Close(); + return; + } + else if (BL == 0) + { + if (Request == null || Request.Method == Packets.Http.HttpMethod.UNKNOWN) { Close(); return; @@ -272,47 +537,42 @@ public class HttpConnection : NetworkConnection } else if (BL < 0) { - data.HoldFor(msg, (uint)(msg.Length - BL)); - return; - } - else if (BL > 0) - { - if (BL > Server.MaxPost) + var requiredLength = (ulong)msg.Length + (ulong)(-BL); + if (requiredLength > uint.MaxValue) { - Send( - "POST method content is larger than " - + Server.MaxPost - + " bytes."); - Close(); + return; } - else + + data.HoldFor(msg, (uint)requiredLength); + return; + } + + RestoreSessionFromRequest(); + + if (!WSMode && IsWebSocketUpgradeAttempt(Request)) + { + if (!IsWebsocketRequest(Request) || !Upgrade()) { - data.HoldFor(msg, (uint)(msg.Length + BL)); + Response.Number = HttpResponseCode.BadRequest; + Response.Text = "Bad Request"; + Response.Headers["Connection"] = "close"; + Send("Invalid WebSocket handshake."); + Close(); + return; } - return; } - else if (BL < 0) // for security - { - Close(); - return; - } - - - - if (IsWebsocketRequest(Request) & !WSMode) - { - Upgrade(); - //return; - } - - - //return; try { - if (!Server.Execute(this)) + if (Server == null || !Server.Execute(this)) { + if (WSMode) + { + FailWebSocket(1008, "No HTTP filter accepted the WebSocket connection."); + return; + } + Response.Number = HttpResponseCode.InternalServerError; Send("Bad Request"); Close(); @@ -325,23 +585,360 @@ public class HttpConnection : NetworkConnection Global.Log("HTTPServer", LogType.Error, ex.ToString()); - //Console.WriteLine(ex.ToString()); - //EventLog.WriteEntry("HttpServer", ex.ToString(), EventLogEntryType.Error); - Send(Error500(ex.Message)); + if (WSMode) + { + FailWebSocket(1011, "A WebSocket filter failed."); + return; + } + + Response.Number = HttpResponseCode.InternalServerError; + Response.Headers["Content-Type"] = "text/html; charset=utf-8"; + Send(FormatError500Page(ex)); } } + + if (WSMode && + IsConnected && + parsedHttpPacketLength > 0 && + parsedHttpPacketLength < msg.Length) + { + data.Write( + msg, + parsedHttpPacketLength, + (uint)msg.Length - parsedHttpPacketLength); + ProcessWebSocketData(data); + } } - private string Error500(string msg) + internal void RestoreSessionFromRequest() { + session = null; + var sessionId = Request?.Cookies?["SID"]; + if (Server?.TryGetSession(sessionId, out var restored) != true) + return; + + session = restored; + session.Refresh(); + } + + private void ProcessWebSocketData(NetworkBuffer data) + { + var message = data.Read(); + if (message == null) + return; + + var offset = 0u; + var ends = (uint)message.Length; + + while (offset < ends && IsConnected) + { + var packet = new WebsocketPacket + { + ExpectedMask = true, + MaximumPayloadLength = GetIncomingFrameLimit(message[offset]) + }; + + long packetLength; + try + { + packetLength = packet.Parse(message, offset, ends); + } + catch (ParserLimitException exception) + { + FailWebSocket(1009, exception.Message); + return; + } + catch (Exception exception) when ( + exception is InvalidDataException || + exception is ArgumentException) + { + FailWebSocket( + IsInvalidUtf8(exception) ? (ushort)1007 : (ushort)1002, + exception.Message); + return; + } + + if (packetLength < 0) + { + var remaining = ends - offset; + var required = (ulong)remaining + (ulong)(-packetLength); + if (required > int.MaxValue) + { + FailWebSocket(1009, "The incomplete WebSocket frame is too large to buffer."); + return; + } + + data.HoldFor(message, offset, remaining, (uint)required); + return; + } + + if (packetLength == 0 || (ulong)packetLength > ends - offset) + { + FailWebSocket(1002, "The WebSocket frame parser returned an invalid length."); + return; + } + + offset += (uint)packetLength; + if (!ProcessWebSocketFrame(packet)) + return; + } + } + + private ulong GetIncomingFrameLimit(byte firstHeaderByte) + { + var opcode = (WebsocketPacket.WSOpcode)(firstHeaderByte & 0x0F); + if (IsControlOpcode(opcode)) + return 125; + + return Server?.MaximumWebSocketMessageLength + ?? WebsocketPacket.DefaultMaximumPayloadLength; + } + + private static bool IsControlOpcode(WebsocketPacket.WSOpcode opcode) + => opcode == WebsocketPacket.WSOpcode.ConnectionClose || + opcode == WebsocketPacket.WSOpcode.Ping || + opcode == WebsocketPacket.WSOpcode.Pong; + + private bool ProcessWebSocketFrame(WebsocketPacket packet) + { + switch (packet.Opcode) + { + case WebsocketPacket.WSOpcode.Ping: + SendWebSocketFrame(WebsocketPacket.WSOpcode.Pong, packet.Message); + return IsConnected; + + case WebsocketPacket.WSOpcode.Pong: + return true; + + case WebsocketPacket.WSOpcode.ConnectionClose: + ResetWebSocketFragment(); + if (!websocketCloseSent) + { + websocketCloseSent = true; + SendWebSocketCloseAndClose(packet.Message); + } + else + { + Close(); + } + return false; + + case WebsocketPacket.WSOpcode.TextFrame: + case WebsocketPacket.WSOpcode.BinaryFrame: + if (websocketFragmentOpcode.HasValue) + { + FailWebSocket( + 1002, + "A new WebSocket data frame arrived before the fragmented message completed."); + return false; + } + + if (packet.FIN) + return DeliverWebSocketMessage(packet); + + websocketFragmentOpcode = packet.Opcode; + websocketFragmentLength = 0; + websocketFragmentBuffer.SetLength(0); + return AppendWebSocketFragment(packet.Message); + + case WebsocketPacket.WSOpcode.ContinuationFrame: + if (!websocketFragmentOpcode.HasValue) + { + FailWebSocket( + 1002, + "A WebSocket continuation frame arrived without an active fragmented message."); + return false; + } + + if (!AppendWebSocketFragment(packet.Message)) + return false; + + if (!packet.FIN) + return true; + + var opcode = websocketFragmentOpcode.Value; + var completeMessage = websocketFragmentBuffer.ToArray(); + ResetWebSocketFragment(); + + try + { + if (opcode == WebsocketPacket.WSOpcode.TextFrame) + WebsocketPacket.ValidateTextPayload(completeMessage); + } + catch (InvalidDataException exception) + { + FailWebSocket(1007, exception.Message); + return false; + } + + return DeliverWebSocketMessage(new WebsocketPacket + { + FIN = true, + Opcode = opcode, + Mask = true, + Message = completeMessage, + PayloadLength = completeMessage.LongLength + }); + + default: + FailWebSocket(1002, "Unsupported WebSocket opcode."); + return false; + } + } + + private bool AppendWebSocketFragment(byte[] payload) + { + payload ??= Array.Empty(); + var payloadLength = (ulong)payload.LongLength; + if (payloadLength > ulong.MaxValue - websocketFragmentLength) + { + FailWebSocket(1009, "The fragmented WebSocket message length overflowed."); + return false; + } + + var nextLength = websocketFragmentLength + payloadLength; + var maximumLength = Server?.MaximumWebSocketMessageLength + ?? WebsocketPacket.DefaultMaximumPayloadLength; + if (nextLength > int.MaxValue || + (maximumLength > 0 && nextLength > maximumLength)) + { + FailWebSocket( + 1009, + $"The fragmented WebSocket message exceeds the {maximumLength}-byte limit."); + return false; + } + + if (payload.Length > 0) + websocketFragmentBuffer.Write(payload, 0, payload.Length); + websocketFragmentLength = nextLength; + return true; + } + + private bool DeliverWebSocketMessage(WebsocketPacket packet) + { + WSRequest = packet; + try + { + if (Server == null) + { + FailWebSocket(1011, "The WebSocket connection is no longer assigned to a server."); + return false; + } + + if (!Server.Execute(this)) + { + FailWebSocket(1008, "No HTTP filter accepted the WebSocket message."); + return false; + } + + return IsConnected; + } + catch (Exception exception) + { + Global.Log("HTTPServer", LogType.Error, exception.ToString()); + FailWebSocket(1011, "A WebSocket filter failed."); + return false; + } + } + + private void SendWebSocketFrame( + WebsocketPacket.WSOpcode opcode, + byte[] payload) + { + var packet = new WebsocketPacket + { + FIN = true, + Mask = false, + Opcode = opcode, + Message = payload ?? Array.Empty(), + MaximumPayloadLength = 0 + }; + + if (packet.Compose()) + base.Send(packet.Data); + } + + private void FailWebSocket(ushort closeCode, string message) + { + Global.Log("HTTPServer", LogType.Warning, message); + ResetWebSocketFragment(); + + if (IsConnected && !websocketCloseSent) + { + websocketCloseSent = true; + SendWebSocketCloseAndClose( + new[] { (byte)(closeCode >> 8), (byte)closeCode }); + return; + } + + Close(); + } + + private void SendWebSocketCloseAndClose(byte[] payload) + { + var packet = new WebsocketPacket + { + FIN = true, + Mask = false, + Opcode = WebsocketPacket.WSOpcode.ConnectionClose, + Message = payload ?? Array.Empty(), + MaximumPayloadLength = 125 + }; + + if (!packet.Compose()) + { + Close(); + return; + } + + base.SendAsync(packet.Data, 0, packet.Data.Length) + .Then(_ => Close()) + .Error(_ => Close()); + } + + private void ResetWebSocketFragment() + { + websocketFragmentOpcode = null; + websocketFragmentLength = 0; + if (websocketFragmentBuffer.Capacity > 64 * 1024) + { + websocketFragmentBuffer.Dispose(); + websocketFragmentBuffer = new MemoryStream(); + } + else + { + websocketFragmentBuffer.SetLength(0); + } + } + + private static bool IsInvalidUtf8(Exception exception) + { + for (var current = exception; current != null; current = current.InnerException) + if (current is DecoderFallbackException) + return true; + + return false; + } + + internal static string FormatError500Page(string msg) + { + var encodedMessage = WebUtility.HtmlEncode(msg ?? string.Empty); return "500 Internal Server Error
\r\n" + "
\r\n" - + "500 Internal Server Error
" + msg + "\r\n" + + "500 Internal Server Error
" + encodedMessage + "\r\n" + "
\r\n" + "
\r\n"; } + internal string FormatError500Page(Exception exception) + { + var message = Server?.ExposeExceptionDetails == true + ? exception?.Message + : GenericInternalServerError; + return FormatError500Page(message); + } + public async AsyncReply SendFile(string filename) { if (Response.Handled == true) @@ -411,7 +1008,8 @@ public class HttpConnection : NetworkConnection while (true) { - var n = fs.Read(buffer, 0, 60000); + var n = await fs.ReadAsync(buffer, 0, buffer.Length) + .ConfigureAwait(false); if (n <= 0) break; @@ -447,6 +1045,6 @@ public class HttpConnection : NetworkConnection protected override void Disconnected() { - // do nothing + ResetWebSocketFragment(); } } diff --git a/Libraries/Esiur/Net/Http/HttpServer.cs b/Libraries/Esiur/Net/Http/HttpServer.cs index b85f7c1..8c1c300 100644 --- a/Libraries/Esiur/Net/Http/HttpServer.cs +++ b/Libraries/Esiur/Net/Http/HttpServer.cs @@ -41,11 +41,13 @@ using System.Text.RegularExpressions; using System.Linq; using System.Reflection; using Esiur.Net.Packets.Http; +using Esiur.Net.Packets.WebSocket; namespace Esiur.Net.Http; public class HttpServer : NetworkServer, IResource { Dictionary sessions = new Dictionary(); + readonly object sessionsLock = new object(); HttpFilter[] filters = new HttpFilter[0]; Dictionary> routes = new() @@ -156,6 +158,72 @@ public class HttpServer : NetworkServer, IResource //[Attribute] public virtual uint MaxPost + { + get; + set; + } = HttpPacketHelpers.DefaultMaximumContentLength; + + public virtual uint MaximumHeaderLength + { + get; + set; + } = HttpPacketHelpers.DefaultMaximumHeaderLength; + + public virtual int MaximumHeaderCount + { + get; + set; + } = HttpPacketHelpers.DefaultMaximumHeaderCount; + + public virtual int MaximumFormFields + { + get; + set; + } = HttpPacketHelpers.DefaultMaximumFormFields; + + public virtual int MaximumFormKeyLength + { + get; + set; + } = HttpPacketHelpers.DefaultMaximumFormKeyLength; + + public virtual int MaximumFormValueLength + { + get; + set; + } = HttpPacketHelpers.DefaultMaximumFormValueLength; + + public virtual int MaximumMultipartPartLength + { + get; + set; + } = HttpPacketHelpers.DefaultMaximumMultipartPartLength; + + /// + /// Maximum payload accumulated for one WebSocket application message, including + /// all of its fragments. Set to zero to disable the configured limit. + /// + public virtual ulong MaximumWebSocketMessageLength + { + get; + set; + } = WebsocketPacket.DefaultMaximumPayloadLength; + + /// + /// WebSocket subprotocols supported by this server, in server preference order. + /// A protocol is returned to the client only when it was also requested. + /// + public virtual string[] WebSocketSubprotocols + { + get; + set; + } = Array.Empty(); + + /// + /// Whether HTTP 500 responses may include exception messages. Disabled by default + /// to avoid disclosing implementation details to remote clients. + /// + public virtual bool ExposeExceptionDetails { get; set; @@ -179,39 +247,99 @@ public class HttpServer : NetworkServer, IResource public HttpSession CreateSession(string id, int timeout) { var s = new HttpSession(); + s.OnEnd += SessionEnded; + s.OnDestroy += SessionDestroyed; - s.Set(id, timeout); + lock (sessionsLock) + sessions.Add(id, s); - - sessions.Add(id, s); + try + { + s.Set(id, timeout); + } + catch + { + lock (sessionsLock) + sessions.Remove(id); + s.Destroy(); + throw; + } return s; } - public static string MakeCookie(string Item, string Value, DateTime Expires, string Domain, string Path, bool HttpOnly) + /// + /// Looks up a live HTTP session by its cookie identifier. + /// + public bool TryGetSession(string id, out HttpSession session) { + session = null; + if (string.IsNullOrEmpty(id)) + return false; - //Set-Cookie: ckGeneric=CookieBody; expires=Sun, 30-Dec-2001 21:00:00 GMT; domain=.com.au; path=/ - //Set-Cookie: SessionID=another; expires=Fri, 29 Jun 2006 20:47:11 UTC; path=/ - string Cookie = Item + "=" + Value; + lock (sessionsLock) + { + if (!sessions.TryGetValue(id, out var candidate) || candidate.IsDestroyed) + return false; - if (Expires.Ticks != 0) - { - Cookie += "; expires=" + Expires.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss") + " GMT"; + session = candidate; + return true; } - if (Domain != null) + } + + private void SessionEnded(HttpSession session) + { + RemoveSession(session); + session.Destroy(); + } + + private void SessionDestroyed(object sender) + { + if (sender is HttpSession session) + RemoveSession(session); + } + + private void RemoveSession(HttpSession session) + { + lock (sessionsLock) { - Cookie += "; domain=" + Domain; + if (session.Id != null && + sessions.TryGetValue(session.Id, out var current) && + ReferenceEquals(current, session)) + sessions.Remove(session.Id); } - if (Path != null) + } + + public static string MakeCookie(string Item, string Value, DateTime Expires, string Domain, string Path, bool HttpOnly) + => MakeCookie( + Item, + Value, + Expires, + Domain, + Path, + HttpOnly, + false, + HttpCookieSameSite.Unspecified); + + public static string MakeCookie( + string Item, + string Value, + DateTime Expires, + string Domain, + string Path, + bool HttpOnly, + bool Secure, + HttpCookieSameSite SameSite) + { + return new HttpCookie(Item, Value) { - Cookie += "; path=" + Path; - } - if (HttpOnly) - { - Cookie += "; HttpOnly"; - } - return Cookie; + Expires = Expires, + Domain = Domain, + Path = Path, + HttpOnly = HttpOnly, + Secure = Secure, + SameSite = SameSite, + }.ToString(); } protected override void ClientDisconnected(HttpConnection connection) @@ -321,6 +449,7 @@ public class HttpServer : NetworkServer, IResource else if (operation == ResourceOperation.Terminate) { Stop(); + DisposeSessions(); } else if (operation == ResourceOperation.SystemReloading) { @@ -336,6 +465,19 @@ public class HttpServer : NetworkServer, IResource } + private void DisposeSessions() + { + HttpSession[] activeSessions; + lock (sessionsLock) + { + activeSessions = sessions.Values.ToArray(); + sessions.Clear(); + } + + foreach (var activeSession in activeSessions) + activeSession.Destroy(); + } + public override void Add(HttpConnection connection) { diff --git a/Libraries/Esiur/Net/Http/HttpSession.cs b/Libraries/Esiur/Net/Http/HttpSession.cs index 58d59bd..b23b712 100644 --- a/Libraries/Esiur/Net/Http/HttpSession.cs +++ b/Libraries/Esiur/Net/Http/HttpSession.cs @@ -44,6 +44,9 @@ public class HttpSession : IDestructible // where T : TClient private string id; private Timer timer; private int timeout; + private readonly object timerLock = new object(); + private long timerGeneration; + private bool destroyed; DateTime creation; DateTime lastAction; @@ -63,25 +66,44 @@ public class HttpSession : IDestructible // where T : TClient variables = new KeyList(); variables.OnModified += new KeyList.Modified(VariablesModified); creation = DateTime.Now; + lastAction = creation; } internal void Set(string id, int timeout) { //modified = sessionModifiedEvent; //ended = sessionEndEvent; - this.id = id; + if (timeout < 0) + throw new ArgumentOutOfRangeException(nameof(timeout)); - if (this.timeout != 0) + lock (timerLock) { + if (destroyed) + throw new ObjectDisposedException(nameof(HttpSession)); + + this.id = id; this.timeout = timeout; - timer = new Timer(OnSessionEndTimerCallback, null, TimeSpan.FromSeconds(timeout), TimeSpan.FromSeconds(0)); creation = DateTime.Now; + lastAction = creation; + ScheduleTimerLocked(); } } private void OnSessionEndTimerCallback(object o) { - OnEnd?.Invoke(this); + SessionEndedEvent onEnd; + + lock (timerLock) + { + if (destroyed || !(o is long generation) || generation != timerGeneration) + return; + + timer?.Dispose(); + timer = null; + onEnd = OnEnd; + } + + onEnd?.Invoke(this); } void VariablesModified(string key, object oldValue, object newValue, KeyList sender) @@ -91,15 +113,54 @@ public class HttpSession : IDestructible // where T : TClient public void Destroy() { - OnDestroy?.Invoke(this); - timer.Dispose(); - timer = null; + DestroyedEvent onDestroy; + + lock (timerLock) + { + if (destroyed) + return; + + destroyed = true; + timerGeneration++; + timer?.Dispose(); + timer = null; + variables.OnModified -= VariablesModified; + onDestroy = OnDestroy; + OnDestroy = null; + OnEnd = null; + OnModify = null; + } + + onDestroy?.Invoke(this); } internal void Refresh() { - lastAction = DateTime.Now; - timer.Change(TimeSpan.FromSeconds(timeout), TimeSpan.FromSeconds(0)); + lock (timerLock) + { + if (destroyed) + return; + + lastAction = DateTime.Now; + ScheduleTimerLocked(); + } + } + + private void ScheduleTimerLocked() + { + timerGeneration++; + timer?.Dispose(); + timer = null; + + if (timeout <= 0) + return; + + var generation = timerGeneration; + timer = new Timer( + OnSessionEndTimerCallback, + generation, + TimeSpan.FromSeconds(timeout), + System.Threading.Timeout.InfiniteTimeSpan); } public int Timeout // Seconds @@ -110,8 +171,18 @@ public class HttpSession : IDestructible // where T : TClient } set { - timeout = value; - Refresh(); + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value)); + + lock (timerLock) + { + if (destroyed) + return; + + timeout = value; + lastAction = DateTime.Now; + ScheduleTimerLocked(); + } } } @@ -124,5 +195,14 @@ public class HttpSession : IDestructible // where T : TClient { get { return lastAction; } } + + internal bool IsDestroyed + { + get + { + lock (timerLock) + return destroyed; + } + } } diff --git a/Libraries/Esiur/Net/NetworkBuffer.cs b/Libraries/Esiur/Net/NetworkBuffer.cs index d525473..13c8cec 100644 --- a/Libraries/Esiur/Net/NetworkBuffer.cs +++ b/Libraries/Esiur/Net/NetworkBuffer.cs @@ -1,4 +1,4 @@ -/* +/* Copyright (c) 2017 Ahmed Kh. Zamil @@ -23,31 +23,25 @@ SOFTWARE. */ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Esiur.Data; -using Esiur.Misc; namespace Esiur.Net; + public class NetworkBuffer { - byte[] data; + private static readonly byte[] Empty = Array.Empty(); - uint neededDataLength = 0; - - object syncLock = new object(); - - public NetworkBuffer() - { - data = new byte[0]; - } + private readonly object syncLock = new object(); + private byte[] buffer = Empty; + private int start; + private int length; + private uint neededDataLength; public bool Protected { get { - return neededDataLength > data.Length; + lock (syncLock) + return (uint)length < neededDataLength; } } @@ -55,76 +49,88 @@ public class NetworkBuffer { get { - return (uint)data.Length; + lock (syncLock) + return (uint)length; } } - - public void HoldForNextWrite(byte[] src) { - HoldFor(src, (uint)src.Length + 1); + if (src == null) + throw new ArgumentNullException(nameof(src)); + + HoldFor(src, 0, (uint)src.Length, CheckedNextLength((uint)src.Length)); } public void HoldForNextWrite(byte[] src, uint offset, uint size) - { - HoldFor(src, offset, size, size + 1); - } - + => HoldFor(src, offset, size, CheckedNextLength(size)); public void HoldFor(byte[] src, uint offset, uint size, uint needed) { + ValidateRange(src, offset, size); + ValidateNeededLength(needed); + + if (size >= needed) + // Preserve the historical exception contract for this semantic error. + throw new Exception("Size >= Needed !"); + lock (syncLock) { - if (size >= needed) - throw new Exception("Size >= Needed !"); - - //trim = true; - data = DC.Combine(src, offset, size, data, 0, (uint)data.Length); + Prepend(src, (int)offset, (int)size); neededDataLength = needed; } } public void HoldFor(byte[] src, uint needed) { + if (src == null) + throw new ArgumentNullException(nameof(src)); + HoldFor(src, 0, (uint)src.Length, needed); } public bool Protect(byte[] data, uint offset, uint needed) { - uint dataLength = (uint)data.Length - offset; + if (data == null) + throw new ArgumentNullException(nameof(data)); + if (offset > (uint)data.Length) + throw new ArgumentOutOfRangeException(nameof(offset)); - // protection - if (dataLength < needed) - { - HoldFor(data, offset, dataLength, needed); - return true; - } - else + ValidateNeededLength(needed); + + var dataLength = (uint)data.Length - offset; + if (dataLength >= needed) return false; + + // HoldFor validates that dataLength is strictly less than needed. + HoldFor(data, offset, dataLength, needed); + return true; } public void Write(byte[] src) { + if (src == null) + throw new ArgumentNullException(nameof(src)); + Write(src, 0, (uint)src.Length); } public void Write(byte[] src, uint offset, uint length) { + ValidateRange(src, offset, length); + if (length == 0) + return; + lock (syncLock) - DC.Append(ref data, src, offset, length); + Append(src, (int)offset, (int)length); } public bool CanRead { get { - if (data.Length == 0) - return false; - if (data.Length < neededDataLength) - return false; - - return true; + lock (syncLock) + return length != 0 && (uint)length >= neededDataLength; } } @@ -132,34 +138,176 @@ public class NetworkBuffer { lock (syncLock) { - if (data.Length == 0) + if (length == 0 || (uint)length < neededDataLength) return null; - byte[] rt = null; + byte[] result; - if (neededDataLength == 0) + // A single write, and geometrically grown power-of-two payloads, can transfer + // ownership without one final copy. Otherwise return an exact-size array, as the + // historical API did, and release the working buffer. + if (start == 0 && length == buffer.Length) { - rt = data; - data = new byte[0]; - return rt; + result = buffer; } else { - - if (data.Length >= neededDataLength) - { - rt = data; - data = new byte[0]; - - neededDataLength = 0; - return rt; - } - else - { - return null; - } + result = new byte[length]; + Buffer.BlockCopy(buffer, start, result, 0, length); } + buffer = Empty; + start = 0; + length = 0; + neededDataLength = 0; + return result; } } + + private void Append(byte[] src, int offset, int count) + { + if (length == 0 && buffer.Length == 0) + { + // The overwhelmingly common path is one socket read followed by one Read(). + // Allocate exactly once so Read can transfer this array without copying it. + buffer = new byte[count]; + Buffer.BlockCopy(src, offset, buffer, 0, count); + start = 0; + length = count; + return; + } + + var requiredLength = CheckedCombinedLength(length, count); + var writeOffset = start + length; + + if (buffer.Length - writeOffset < count) + { + if (requiredLength <= buffer.Length) + { + // Reuse headroom left by a prepend operation. + Buffer.BlockCopy(buffer, start, buffer, 0, length); + start = 0; + } + else + { + Grow(requiredLength, prependLength: 0); + } + + writeOffset = start + length; + } + + Buffer.BlockCopy(src, offset, buffer, writeOffset, count); + length = requiredLength; + } + + private void Prepend(byte[] src, int offset, int count) + { + if (count == 0) + return; + + if (length == 0 && buffer.Length == 0) + { + buffer = new byte[count]; + Buffer.BlockCopy(src, offset, buffer, 0, count); + start = 0; + length = count; + return; + } + + var requiredLength = CheckedCombinedLength(length, count); + + if (start >= count) + { + start -= count; + } + else if (requiredLength <= buffer.Length) + { + // Re-center the live region once and leave any remaining spare capacity around it. + var combinedStart = (buffer.Length - requiredLength) / 2; + Buffer.BlockCopy(buffer, start, buffer, combinedStart + count, length); + start = combinedStart; + } + else + { + Grow(requiredLength, count); + } + + Buffer.BlockCopy(src, offset, buffer, start, count); + length = requiredLength; + } + + private void Grow(int requiredLength, int prependLength) + { + var newCapacity = GetExpandedCapacity(buffer.Length, requiredLength); + var replacement = new byte[newCapacity]; + + if (prependLength == 0) + { + if (length > 0) + Buffer.BlockCopy(buffer, start, replacement, 0, length); + start = 0; + } + else + { + var combinedStart = (newCapacity - requiredLength) / 2; + if (length > 0) + Buffer.BlockCopy(buffer, start, replacement, combinedStart + prependLength, length); + start = combinedStart; + } + + buffer = replacement; + } + + private static int GetExpandedCapacity(int currentCapacity, int requiredLength) + { + var capacity = currentCapacity == 0 ? 256 : currentCapacity; + + while (capacity < requiredLength) + { + if (capacity > int.MaxValue / 2) + return requiredLength; + + capacity *= 2; + } + + return capacity; + } + + private static int CheckedCombinedLength(int currentLength, int additionalLength) + { + if (additionalLength > int.MaxValue - currentLength) + throw new ArgumentOutOfRangeException( + nameof(additionalLength), + "The buffered data exceeds the maximum managed array length."); + + return currentLength + additionalLength; + } + + private static uint CheckedNextLength(uint size) + { + if (size >= int.MaxValue) + throw new ArgumentOutOfRangeException( + nameof(size), + "The requested held length exceeds the maximum managed array length."); + + return size + 1; + } + + private static void ValidateNeededLength(uint needed) + { + if (needed > int.MaxValue) + throw new ArgumentOutOfRangeException( + nameof(needed), + "The requested held length exceeds the maximum managed array length."); + } + + private static void ValidateRange(byte[] src, uint offset, uint count) + { + if (src == null) + throw new ArgumentNullException(nameof(src)); + if (offset > (uint)src.Length) + throw new ArgumentOutOfRangeException(nameof(offset)); + if (count > (uint)src.Length - offset) + throw new ArgumentOutOfRangeException(nameof(count)); + } } diff --git a/Libraries/Esiur/Net/NetworkConnection.cs b/Libraries/Esiur/Net/NetworkConnection.cs index f573874..eeb9cae 100644 --- a/Libraries/Esiur/Net/NetworkConnection.cs +++ b/Libraries/Esiur/Net/NetworkConnection.cs @@ -23,6 +23,7 @@ SOFTWARE. */ using System; +using System.Collections.Generic; using System.Net; using System.Text; using System.Threading; @@ -39,13 +40,27 @@ namespace Esiur.Net; /// public abstract class NetworkConnection : IDestructible, INetworkReceiver { - private ISocket sock; + private volatile ISocket sock; private DateTime lastAction; - // Re-entrancy guard for NetworkReceive. 0 = idle, 1 = a thread is draining the buffer. - // Interlocked is used instead of a plain bool so concurrent receive callbacks cannot - // both enter the drain loop (which is not safe to run from two threads at once). - private int receiving; + private readonly object receiveLock = new object(); + private readonly Queue pendingReceives = new Queue(); + private bool receiving; + private long socketGeneration; + + private readonly struct PendingReceive + { + public PendingReceive(ISocket sender, NetworkBuffer buffer, long generation) + { + Sender = sender; + Buffer = buffer; + Generation = generation; + } + + public ISocket Sender { get; } + public NetworkBuffer Buffer { get; } + public long Generation { get; } + } public delegate void NetworkConnectionEvent(NetworkConnection connection); @@ -69,9 +84,19 @@ public abstract class NetworkConnection : IDestructible, INetworkReceiver @@ -80,14 +105,19 @@ public abstract class NetworkConnection : IDestructible, INetworkReceiver public ISocket Unassign() { - if (sock == null) - return null; + lock (receiveLock) + { + if (sock == null) + return null; - sock.Receiver = null; + sock.Receiver = null; - var detached = sock; - sock = null; - return detached; + var detached = sock; + sock = null; + socketGeneration++; + pendingReceives.Clear(); + return detached; + } } public void Close() @@ -163,12 +193,24 @@ public abstract class NetworkConnection : IDestructible, INetworkReceiver 0 && !buffer.Protected) - DataReceived(buffer); - } - finally - { - Interlocked.Exchange(ref receiving, 0); + // A callback can outlive Unassign/Assign. Only the socket that currently + // owns this connection may enqueue work for its protocol parser. + if (!ReferenceEquals(sender, sock) || sender.State == SocketState.Closed) + return; + + lastAction = DateTime.Now; + pendingReceives.Enqueue(new PendingReceive(sender, buffer, socketGeneration)); + + if (receiving) + return; + + receiving = true; + drain = true; } + + if (drain) + DrainReceiveQueue(); } catch (Exception ex) { Global.Log("NetworkConnection:NetworkReceive", LogType.Warning, ex.ToString()); } } + + private void DrainReceiveQueue() + { + while (true) + { + PendingReceive pending = default; + var found = false; + + lock (receiveLock) + { + while (pendingReceives.Count > 0) + { + var candidate = pendingReceives.Dequeue(); + if (ReferenceEquals(candidate.Sender, sock) + && candidate.Generation == socketGeneration) + { + pending = candidate; + found = true; + break; + } + } + + if (!found) + { + receiving = false; + return; + } + } + + try + { + while (IsCurrentReceive(pending) + && pending.Buffer.Available > 0 + && !pending.Buffer.Protected) + { + DataReceived(pending.Buffer); + } + } + catch (Exception ex) + { + // Keep the queue usable after a protocol parser fails. Any work already + // queued for a replacement socket can still be drained safely. + Global.Log("NetworkConnection:NetworkReceive", LogType.Warning, ex.ToString()); + } + } + } + + private bool IsCurrentReceive(PendingReceive pending) + { + lock (receiveLock) + { + if (!ReferenceEquals(pending.Sender, sock) + || pending.Generation != socketGeneration) + return false; + + try + { + return pending.Sender.State != SocketState.Closed; + } + catch + { + return false; + } + } + } } diff --git a/Libraries/Esiur/Net/NetworkServer.cs b/Libraries/Esiur/Net/NetworkServer.cs index 01f5380..cb4eaa2 100644 --- a/Libraries/Esiur/Net/NetworkServer.cs +++ b/Libraries/Esiur/Net/NetworkServer.cs @@ -37,13 +37,20 @@ namespace Esiur.Net; public abstract class NetworkServer : IDestructible where TConnection : NetworkConnection, new() { - private Sockets.ISocket listener; + private volatile Sockets.ISocket listener; + private readonly object lifecycleLock = new object(); public AutoList> Connections { get; internal set; } private Thread thread; private Timer timer; + /// + /// Maximum time allowed for an accepted socket to finish protocol initialization + /// (for example, a TLS handshake). A zero or negative value disables the deadline. + /// + public TimeSpan ConnectionInitializationTimeout { get; set; } = TimeSpan.FromSeconds(10); + public event DestroyedEvent OnDestroy; @@ -78,68 +85,142 @@ public abstract class NetworkServer : IDestructible where TConnecti public void Start(Sockets.ISocket socket)//, uint timeout, uint clock) { - if (listener != null) - return; + if (socket == null) + throw new ArgumentNullException(nameof(socket)); - - Connections = new AutoList>(this); - - - if (Timeout > 0 & Clock > 0) + lock (lifecycleLock) { - timer = new Timer(MinuteThread, null, TimeSpan.FromMinutes(0), TimeSpan.FromSeconds(Clock)); - } + if (listener != null) + return; + Connections = new AutoList>(this); - listener = socket; - - thread = new Thread(new ThreadStart(() => - { - while (true) + if (Timeout > 0 && Clock > 0) { + timer = new Timer(MinuteThread, null, TimeSpan.FromMinutes(0), TimeSpan.FromSeconds(Clock)); + } + + listener = socket; + + // Bind this thread to this particular Start invocation. If the server is + // stopped and restarted before the old Accept call unwinds, the old thread + // must not begin accepting from the replacement listener. + thread = new Thread(() => AcceptLoop(socket)) + { + IsBackground = true + }; + thread.Start(); + } + } + + private void AcceptLoop(ISocket activeListener) + { + while (ReferenceEquals(listener, activeListener)) + { + try + { + var acceptedSocket = activeListener.Accept(); + + if (acceptedSocket == null) + return; + + TConnection connection = null; + var stopped = false; + + // Admission and Stop's connection snapshot share this gate. Therefore + // either the connection is added before Stop snapshots it, or Stop wins + // and this accepted socket is closed without being exposed to the server. + lock (lifecycleLock) + { + if (!ReferenceEquals(listener, activeListener)) + { + stopped = true; + } + else + { + connection = new TConnection(); + connection.Assign(acceptedSocket); + Add(connection); + stopped = !ReferenceEquals(listener, activeListener); + } + } + + if (stopped) + { + try { acceptedSocket.Close(); } catch { } + return; + } + + // 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(connection)) + continue; + try { - var s = listener.Accept(); - - if (s == null) - { - //Global.Log("NetworkServer", LogType.Error, "sock == null"); - return; - } - - var c = new TConnection(); - - 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); - } - catch - { - // something wrong with the child. - } - - s.Begin(); - + ClientConnected(connection); } - catch (Exception ex) + catch { - Global.Log(ex); + // something wrong with the child. + } + + if (!ReferenceEquals(listener, activeListener)) + { + try { connection.Close(); } catch { } + return; + } + + // Some socket implementations perform a protocol handshake in Begin + // (notably SSLSocket). Never run that handshake on the single accept + // thread: a peer that stops mid-handshake would otherwise prevent all + // subsequent clients from being accepted. + _ = BeginAcceptedSocketAsync(acceptedSocket); + } + catch (Exception ex) + { + if (!ReferenceEquals(listener, activeListener)) + return; + + Global.Log(ex); + } + } + } + + private async Task BeginAcceptedSocketAsync(ISocket socket) + { + try + { + var beginTask = AwaitSocketBeginAsync(socket); + var timeout = ConnectionInitializationTimeout; + + if (timeout > TimeSpan.Zero) + { + var completed = await Task.WhenAny(beginTask, Task.Delay(timeout)).ConfigureAwait(false); + if (!ReferenceEquals(completed, beginTask)) + { + try { socket.Close(); } catch { } + _ = beginTask.ContinueWith( + completedTask => _ = completedTask.Exception, + TaskContinuationOptions.OnlyOnFaulted); + return; } } - })); - - thread.Start(); + if (!await beginTask.ConfigureAwait(false)) + try { socket.Close(); } catch { } + } + catch (Exception ex) + { + Global.Log("NetworkServer", LogType.Warning, + $"Accepted socket initialization failed: {ex.Message}"); + try { socket.Close(); } catch { } + } } + private static async Task AwaitSocketBeginAsync(ISocket socket) + => await socket.BeginAsync(); + //[Attribute] public uint Timeout @@ -160,25 +241,39 @@ public abstract class NetworkServer : IDestructible where TConnecti public void Stop() { var port = 0; + ISocket currentListener = null; + TConnection[] connections = null; + Timer currentTimer = null; try { - var currentListener = listener; + lock (lifecycleLock) + { + currentListener = listener; + listener = null; + connections = Connections?.ToArray(); + currentTimer = timer; + timer = null; + } + if (currentListener != null) { // Reading the endpoint can throw if the socket is already disposed (e.g. a second // Stop or the finalizer after Destroy), so it is best-effort and only used for logging. try { port = currentListener.LocalEndPoint.Port; } catch { } try { currentListener.Close(); } catch { } - listener = null; // make Stop idempotent } - foreach (TConnection con in Connections.ToArray()) - try { con.Close(); } catch { } + if (connections != null) + { + foreach (TConnection con in connections) + try { con.Close(); } catch { } + } } finally { - Global.Log("NetworkServer", LogType.Warning, $"Server@{port} is down."); + try { currentTimer?.Dispose(); } catch { } + Global.Log("NetworkServer", LogType.Warning, $"Server on port {port} is down."); } } @@ -200,7 +295,8 @@ public abstract class NetworkServer : IDestructible where TConnecti { get { - return listener.State == SocketState.Listening; + var currentListener = listener; + return currentListener != null && currentListener.State == SocketState.Listening; } } diff --git a/Libraries/Esiur/Net/Packets/EpAuthPacket.cs b/Libraries/Esiur/Net/Packets/EpAuthPacket.cs index 321b433..d9ff020 100644 --- a/Libraries/Esiur/Net/Packets/EpAuthPacket.cs +++ b/Libraries/Esiur/Net/Packets/EpAuthPacket.cs @@ -125,19 +125,6 @@ public class EpAuthPacket : Packet set; } - private uint dataLengthNeeded; - - bool NotEnough(uint offset, uint ends, uint needed) - { - if (offset + needed > ends) - { - dataLengthNeeded = needed - (ends - offset); - return true; - } - else - return false; - } - public override string ToString() { return Command.ToString() + " " + Method.ToString(); @@ -145,12 +132,13 @@ public class EpAuthPacket : Packet public override long Parse(byte[] data, uint offset, uint ends) { + ValidateBounds(data, offset, ends); Tdu = null; var oOffset = offset; - if (NotEnough(offset, ends, 1)) - return -dataLengthNeeded; + if (TryGetMissingBytes(offset, ends, 1, out var incomplete)) + return incomplete; Command = (EpAuthPacketCommand)(data[offset] >> 6); var hasTdu = (data[offset] & 0x20) != 0; @@ -183,18 +171,21 @@ public class EpAuthPacket : Packet if (hasTdu) { - if (NotEnough(offset, ends, 1)) - return -dataLengthNeeded; + if (TryGetMissingBytes(offset, ends, 1, out incomplete)) + return incomplete; var maximumPacketSize = _warehouse.Configuration.Parser.MaximumPacketSize; + var maximumPayloadLength = maximumPacketSize == 0 + ? (ulong)int.MaxValue + : Math.Min(maximumPacketSize, (ulong)int.MaxValue); Tdu = PlainTdu.Parse( data, offset, ends, - maximumPacketSize == 0 ? ulong.MaxValue : maximumPacketSize); + maximumPayloadLength); if (Tdu.Value.Class == TduClass.Invalid) - return -(int)Tdu.Value.TotalLength; + return -(long)Tdu.Value.TotalLength; offset += (uint)Tdu.Value.TotalLength; diff --git a/Libraries/Esiur/Net/Packets/EpPacket.cs b/Libraries/Esiur/Net/Packets/EpPacket.cs index 9c7296a..233e0df 100644 --- a/Libraries/Esiur/Net/Packets/EpPacket.cs +++ b/Libraries/Esiur/Net/Packets/EpPacket.cs @@ -51,9 +51,6 @@ class EpPacket : Packet public PlainTdu? Tdu { get; set; } - private uint dataLengthNeeded; - private uint originalOffset; - Warehouse _warehouse; public EpPacket(Warehouse warehouse) @@ -61,11 +58,6 @@ class EpPacket : Packet _warehouse = warehouse; } - public override bool Compose() - { - return base.Compose(); - } - public override string ToString() { return Method switch @@ -78,24 +70,13 @@ class EpPacket : Packet }; } - bool NotEnough(uint offset, uint ends, uint needed) - { - if (offset + needed > ends) - { - dataLengthNeeded = needed - (ends - offset); - - return true; - } - else - return false; - } - public override long Parse(byte[] data, uint offset, uint ends) { - originalOffset = offset; + ValidateBounds(data, offset, ends); + var originalOffset = offset; - if (NotEnough(offset, ends, 1)) - return -dataLengthNeeded; + if (TryGetMissingBytes(offset, ends, 1, out var incomplete)) + return incomplete; var hasDTU = (data[offset] & 0x20) == 0x20; @@ -109,8 +90,8 @@ class EpPacket : Packet { Request = (EpPacketRequest)(data[offset++] & 0x1f); - if (NotEnough(offset, ends, 4)) - return -dataLengthNeeded; + if (TryGetMissingBytes(offset, ends, 4, out incomplete)) + return incomplete; CallbackId = data.GetUInt32(offset, Endian.Little); offset += 4; @@ -119,8 +100,8 @@ class EpPacket : Packet { Reply = (EpPacketReply)(data[offset++] & 0x1f); - if (NotEnough(offset, ends, 4)) - return -dataLengthNeeded; + if (TryGetMissingBytes(offset, ends, 4, out incomplete)) + return incomplete; CallbackId = data.GetUInt32(offset, Endian.Little); offset += 4; @@ -132,30 +113,26 @@ class EpPacket : Packet if (hasDTU) { - if (NotEnough(offset, ends, 1)) - return -dataLengthNeeded; + if (TryGetMissingBytes(offset, ends, 1, out incomplete)) + return incomplete; var maximumPacketSize = _warehouse.Configuration.Parser.MaximumPacketSize; + var maximumPayloadLength = maximumPacketSize == 0 + ? (ulong)int.MaxValue + : Math.Min(maximumPacketSize, (ulong)int.MaxValue); Tdu = PlainTdu.Parse( data, offset, ends, - maximumPacketSize == 0 ? ulong.MaxValue : maximumPacketSize); + maximumPayloadLength); if (Tdu.Value.Class == TduClass.Invalid) - return -(int)Tdu.Value.TotalLength; + return -(long)Tdu.Value.TotalLength; - //Tdu = ParsedTdu.ParseSync(data, offset, ends, _warehouse); - - //if (Tdu.Value.Class == TduClass.Invalid) - // return -(int)Tdu.Value.TotalLength; - - //offset += (uint)Tdu.Value.TotalLength; offset += (uint)Tdu.Value.TotalLength; } else { - //Tdu = null; Tdu = null; } diff --git a/Libraries/Esiur/Net/Packets/Http/HttpCookie.cs b/Libraries/Esiur/Net/Packets/Http/HttpCookie.cs index 8378404..e12cf05 100644 --- a/Libraries/Esiur/Net/Packets/Http/HttpCookie.cs +++ b/Libraries/Esiur/Net/Packets/Http/HttpCookie.cs @@ -1,9 +1,18 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text; namespace Esiur.Net.Packets.Http { + public enum HttpCookieSameSite + { + Unspecified, + Lax, + Strict, + None, + } + public struct HttpCookie { public string Name; @@ -11,6 +20,8 @@ namespace Esiur.Net.Packets.Http public DateTime Expires; public string Path; public bool HttpOnly; + public bool Secure; + public HttpCookieSameSite SameSite; public string Domain; public HttpCookie(string name, string value) @@ -20,6 +31,8 @@ namespace Esiur.Net.Packets.Http Path = null; Expires = DateTime.MinValue; HttpOnly = false; + Secure = false; + SameSite = HttpCookieSameSite.Unspecified; Domain = null; } @@ -29,6 +42,8 @@ namespace Esiur.Net.Packets.Http Value = value; Expires = expires; HttpOnly = false; + Secure = false; + SameSite = HttpCookieSameSite.Unspecified; Domain = null; Path = null; } @@ -40,7 +55,7 @@ namespace Esiur.Net.Packets.Http var cookie = Name + "=" + Value; if (Expires.Ticks != 0) - cookie += "; expires=" + Expires.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss") + " GMT"; + cookie += "; expires=" + Expires.ToUniversalTime().ToString("r", CultureInfo.InvariantCulture); if (Domain != null) cookie += "; domain=" + Domain; @@ -51,6 +66,12 @@ namespace Esiur.Net.Packets.Http if (HttpOnly) cookie += "; HttpOnly"; + if (Secure) + cookie += "; Secure"; + + if (SameSite != HttpCookieSameSite.Unspecified) + cookie += "; SameSite=" + SameSite; + return cookie; } } diff --git a/Libraries/Esiur/Net/Packets/Http/HttpPacketHelpers.cs b/Libraries/Esiur/Net/Packets/Http/HttpPacketHelpers.cs new file mode 100644 index 0000000..40d5ea8 --- /dev/null +++ b/Libraries/Esiur/Net/Packets/Http/HttpPacketHelpers.cs @@ -0,0 +1,80 @@ +using Esiur.Data; +using System; +using System.Text; + +namespace Esiur.Net.Packets.Http; + +internal static class HttpPacketHelpers +{ + internal const uint DefaultMaximumHeaderLength = 64 * 1024; + internal const uint DefaultMaximumContentLength = 8 * 1024 * 1024; + internal const int DefaultMaximumHeaderCount = 100; + internal const int DefaultMaximumFormFields = 1_024; + internal const int DefaultMaximumFormKeyLength = 2_048; + internal const int DefaultMaximumFormValueLength = 1024 * 1024; + internal const int DefaultMaximumMultipartPartLength = 4 * 1024 * 1024; + + internal static bool TryFindHeaderEnd( + byte[] data, + uint offset, + uint ends, + uint maximumHeaderLength, + out uint bodyOffset) + { + bodyOffset = 0; + var available = ends - offset; + var scanLength = maximumHeaderLength == 0 + ? available + : available < maximumHeaderLength ? available : maximumHeaderLength; + + if (scanLength >= 4) + { + var scanEnds = offset + scanLength; + for (var i = offset; i <= scanEnds - 4; i++) + { + if (data[i] == '\r' && data[i + 1] == '\n' && + data[i + 2] == '\r' && data[i + 3] == '\n') + { + bodyOffset = i + 4; + return true; + } + } + } + + if (maximumHeaderLength > 0 && available >= maximumHeaderLength) + throw new ParserLimitException( + $"HTTP header exceeds the {maximumHeaderLength}-byte limit."); + + return false; + } + + internal static string[] ReadHeaderLines( + byte[] data, + uint offset, + uint bodyOffset, + int maximumHeaderCount) + { + var headerContentLength = bodyOffset - offset - 4; + var headerEnd = offset + headerContentLength; + var headerCount = 0; + + // Count before Split allocates its result so a header made of thousands of + // tiny lines is rejected without creating thousands of strings first. + for (var i = offset; i + 1 < headerEnd; i++) + { + if (data[i] != '\r' || data[i + 1] != '\n') + continue; + + headerCount++; + if (maximumHeaderCount > 0 && headerCount > maximumHeaderCount) + throw new ParserLimitException( + $"HTTP header count exceeds the {maximumHeaderCount}-header limit."); + + i++; + } + + return Encoding.ASCII + .GetString(data, (int)offset, (int)headerContentLength) + .Split(new[] { "\r\n" }, StringSplitOptions.None); + } +} diff --git a/Libraries/Esiur/Net/Packets/Http/HttpRequestPacket.cs b/Libraries/Esiur/Net/Packets/Http/HttpRequestPacket.cs index 2a41670..2de2792 100644 --- a/Libraries/Esiur/Net/Packets/Http/HttpRequestPacket.cs +++ b/Libraries/Esiur/Net/Packets/Http/HttpRequestPacket.cs @@ -1,303 +1,358 @@ - -/* - -Copyright (c) 2017 Ahmed Kh. Zamil - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -*/ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Esiur.Misc; using Esiur.Data; +using Esiur.Misc; +using System; +using System.Globalization; using System.Net; -using System.Text.Json.Serialization; +using System.Text; namespace Esiur.Net.Packets.Http; + public class HttpRequestPacket : Packet { - - public StringKeyList Query; public HttpMethod Method; + public string RawMethod; public StringKeyList Headers; - public bool WSMode; - public string Version; - public StringKeyList Cookies; // String - public string URL; /// With query - public string Filename; /// Without query - + public StringKeyList Cookies; + public string URL; + public string Filename; public KeyList PostForms; public byte[] Message; - - private HttpMethod GetMethod(string method) - { - switch (method.ToLower()) - { - case "get": - return HttpMethod.GET; - case "post": - return HttpMethod.POST; - case "head": - return HttpMethod.HEAD; - case "put": - return HttpMethod.PUT; - case "delete": - return HttpMethod.DELETE; - case "options": - return HttpMethod.OPTIONS; - case "trace": - return HttpMethod.TRACE; - case "connect": - return HttpMethod.CONNECT; - default: - return HttpMethod.UNKNOWN; - } - } + public uint MaximumHeaderLength { get; set; } = HttpPacketHelpers.DefaultMaximumHeaderLength; + public uint MaximumContentLength { get; set; } = HttpPacketHelpers.DefaultMaximumContentLength; + public int MaximumHeaderCount { get; set; } = HttpPacketHelpers.DefaultMaximumHeaderCount; + public int MaximumFormFields { get; set; } = HttpPacketHelpers.DefaultMaximumFormFields; + public int MaximumFormKeyLength { get; set; } = HttpPacketHelpers.DefaultMaximumFormKeyLength; + public int MaximumFormValueLength { get; set; } = HttpPacketHelpers.DefaultMaximumFormValueLength; + public int MaximumMultipartPartLength { get; set; } = HttpPacketHelpers.DefaultMaximumMultipartPartLength; public override string ToString() - { - return "HTTPRequestPacket" - + "\n\tVersion: " + Version - + "\n\tMethod: " + Method - + "\n\tURL: " + URL - + "\n\tMessage: " + (Message != null ? Message.Length.ToString() : "NULL"); - } + => $"HTTPRequestPacket\n\tVersion: {Version}\n\tMethod: {Method}\n\tURL: {URL}" + + $"\n\tMessage: {(Message == null ? "NULL" : Message.Length.ToString())}"; public override long Parse(byte[] data, uint offset, uint ends) { - string[] sMethod = null; - string[] sLines = null; + ValidateBounds(data, offset, ends); + var originalOffset = offset; - uint headerSize = 0; - - for (uint i = offset; i < ends - 3; i++) - { - if (data[i] == '\r' && data[i + 1] == '\n' - && data[i + 2] == '\r' && data[i + 3] == '\n') - { - sLines = Encoding.ASCII.GetString(data, (int)offset, (int)(i - offset)).Split(new string[] { "\r\n" }, - StringSplitOptions.None); - - headerSize = i + 4; - break; - } - } - - if (headerSize == 0) + if (!HttpPacketHelpers.TryFindHeaderEnd( + data, offset, ends, MaximumHeaderLength, out var bodyOffset)) return -1; + var lines = HttpPacketHelpers.ReadHeaderLines( + data, offset, bodyOffset, MaximumHeaderCount); + + if (lines.Length == 0) + return 0; + Cookies = new StringKeyList(); PostForms = new KeyList(); Query = new StringKeyList(); Headers = new StringKeyList(); + Message = null; - sMethod = sLines[0].Split(' '); - Method = GetMethod(sMethod[0].Trim()); + var requestLine = lines[0].Split(new[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + if (requestLine.Length != 3) + return 0; - if (sMethod.Length == 3) + RawMethod = requestLine[0]; + Method = GetMethod(RawMethod); + Version = requestLine[2].Trim(); + + var target = requestLine[1].Trim(); + if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri)) + target = absoluteUri.PathAndQuery; + + var queryIndex = target.IndexOf('?'); + var rawFilename = queryIndex < 0 ? target : target.Substring(0, queryIndex); + Filename = WebUtility.UrlDecode(rawFilename); + URL = WebUtility.UrlDecode(target); + + var hasContentLength = false; + + for (var i = 1; i < lines.Length; i++) { - sMethod[1] = WebUtility.UrlDecode(sMethod[1]); - if (sMethod[1].Length >= 7) + var separator = lines[i].IndexOf(':'); + if (separator <= 0) + return 0; + + var name = lines[i].Substring(0, separator).Trim(); + var value = lines[i].Substring(separator + 1).Trim(); + + if (string.Equals(name, "transfer-encoding", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("HTTP Transfer-Encoding is not supported."); + + if (string.Equals(name, "content-length", StringComparison.OrdinalIgnoreCase)) { - if (sMethod[1].StartsWith("http://")) - { - sMethod[1] = sMethod[1].Substring(sMethod[1].IndexOf("/", 7)); - } + if (hasContentLength) + throw new InvalidDataException("Duplicate HTTP Content-Length headers are not accepted."); + + hasContentLength = true; } - URL = sMethod[1].Trim(); + Headers[name] = value; - if (URL.IndexOf("?", 0) != -1) + if (string.Equals(name, "cookie", StringComparison.OrdinalIgnoreCase)) + ParseCookies(value); + } + + if (queryIndex >= 0 && queryIndex + 1 < target.Length) + ParseQuery(target.Substring(queryIndex + 1)); + + var contentLength = 0u; + if (hasContentLength && !uint.TryParse( + Headers["content-length"], + NumberStyles.None, + CultureInfo.InvariantCulture, + out contentLength)) + throw new InvalidDataException("HTTP Content-Length is invalid."); + + if (MaximumContentLength > 0 && contentLength > MaximumContentLength) + throw new ParserLimitException( + $"HTTP content length of {contentLength} bytes exceeds the {MaximumContentLength}-byte limit."); + + var availableBody = ends - bodyOffset; + if (availableBody < contentLength) + return -(long)(contentLength - availableBody); + + var contentType = Headers["content-type"]; + if (Method == HttpMethod.POST && + (string.IsNullOrEmpty(contentType) || + contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))) + { + ParseUrlEncodedForm(Encoding.UTF8.GetString(data, (int)bodyOffset, (int)contentLength)); + } + else if (Method == HttpMethod.POST && + contentType.StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase)) + { + if (!TryParseMultipart(data, bodyOffset, contentLength, contentType)) + return 0; + } + else + { + Message = data.Clip(bodyOffset, contentLength); + } + + return bodyOffset - originalOffset + contentLength; + } + + private static HttpMethod GetMethod(string method) + { + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase)) return HttpMethod.GET; + if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase)) return HttpMethod.POST; + if (string.Equals(method, "HEAD", StringComparison.OrdinalIgnoreCase)) return HttpMethod.HEAD; + if (string.Equals(method, "PUT", StringComparison.OrdinalIgnoreCase)) return HttpMethod.PUT; + if (string.Equals(method, "DELETE", StringComparison.OrdinalIgnoreCase)) return HttpMethod.DELETE; + if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) return HttpMethod.OPTIONS; + if (string.Equals(method, "TRACE", StringComparison.OrdinalIgnoreCase)) return HttpMethod.TRACE; + if (string.Equals(method, "CONNECT", StringComparison.OrdinalIgnoreCase)) return HttpMethod.CONNECT; + return HttpMethod.UNKNOWN; + } + + private void ParseCookies(string header) + { + foreach (var segment in header.Split(';')) + { + var cookie = segment.Trim(); + if (cookie.Length == 0) + continue; + + var separator = cookie.IndexOf('='); + var name = separator < 0 ? cookie : cookie.Substring(0, separator).Trim(); + var value = separator < 0 ? string.Empty : cookie.Substring(separator + 1).Trim(); + if (!Cookies.ContainsKey(name)) + Cookies.Add(name, value); + } + } + + private void ParseQuery(string query) + { + foreach (var segment in query.Split('&')) + { + var separator = segment.IndexOf('='); + var name = WebUtility.UrlDecode(separator < 0 ? segment : segment.Substring(0, separator)); + var value = separator < 0 ? null : WebUtility.UrlDecode(segment.Substring(separator + 1)); + if (!Query.ContainsKey(name)) + Query.Add(name, value); + } + } + + private void ParseUrlEncodedForm(string form) + { + if (form.Length == 0) + return; + + var fieldCount = 0; + var start = 0; + StringBuilder unknown = null; + var hasUnknownValue = false; + + while (start <= form.Length) + { + fieldCount++; + EnsureWithinLimit(fieldCount, MaximumFormFields, "form fields"); + + var end = form.IndexOf('&', start); + if (end < 0) + end = form.Length; + + var separator = form.IndexOf('=', start, end - start); + if (separator >= 0) { - Filename = URL.Split(new char[] { '?' }, 2)[0]; + var key = DecodeFormComponent(form.Substring(start, separator - start)); + var value = DecodeFormComponent(form.Substring(separator + 1, end - separator - 1)); + EnsureStringWithinLimit(key, MaximumFormKeyLength, "form key"); + EnsureStringWithinLimit(value, MaximumFormValueLength, "form value"); + + if (string.Equals(key, "unknown", StringComparison.Ordinal)) + { + if (unknown == null) + unknown = new StringBuilder(value.Length); + else + unknown.Clear(); + unknown.Append(value); + hasUnknownValue = true; + } + + PostForms[key] = value; } else { - Filename = URL; + var value = DecodeFormComponent(form.Substring(start, end - start)); + EnsureStringWithinLimit(value, MaximumFormValueLength, "form value"); + + if (unknown == null) + unknown = new StringBuilder(value.Length); + + EnsureStringWithinLimit( + unknown.Length + (hasUnknownValue ? 1 : 0) + value.Length, + MaximumFormValueLength, + "combined form value"); + + if (hasUnknownValue) + unknown.Append('&'); + unknown.Append(value); + hasUnknownValue = true; } - if (Filename.IndexOf("%", 0) != -1) - { - Filename = WebUtility.UrlDecode(Filename); - } - - Version = sMethod[2].Trim(); + if (end == form.Length) + break; + start = end + 1; } - // Read all headers + if (unknown != null) + PostForms["unknown"] = unknown.ToString(); + } - for (int i = 1; i < sLines.Length; i++) + private bool TryParseMultipart( + byte[] data, + uint bodyOffset, + uint contentLength, + string contentType) + { + if (!TryGetMultipartBoundary(contentType, out var boundary)) + return false; + + var delimiter = "--" + boundary; + var body = Encoding.UTF8.GetString(data, (int)bodyOffset, (int)contentLength); + var position = 0; + var fieldCount = 0; + + while (position < body.Length) { - if (sLines[i] == string.Empty) - { - // Invalid header - return 0; - } + var delimiterStart = body.IndexOf(delimiter, position, StringComparison.Ordinal); + if (delimiterStart < 0) + return false; - if (sLines[i].IndexOf(':') == -1) - { - // Invalid header - return 0; - } + position = delimiterStart + delimiter.Length; + if (position + 2 <= body.Length && + string.CompareOrdinal(body, position, "--", 0, 2) == 0) + return true; - string[] header = sLines[i].Split(new char[] { ':' }, 2); + if (position + 2 > body.Length || + string.CompareOrdinal(body, position, "\r\n", 0, 2) != 0) + return false; + position += 2; - header[0] = header[0].ToLower(); - Headers[header[0]] = header[1].Trim(); + var nextDelimiter = body.IndexOf("\r\n" + delimiter, position, StringComparison.Ordinal); + if (nextDelimiter < 0) + return false; - if (header[0] == "cookie") - { - string[] cookies = header[1].Split(';'); + var partLength = nextDelimiter - position; + EnsureWithinLimit(partLength, MaximumMultipartPartLength, "multipart part length"); - foreach (string cookie in cookies) - { - if (cookie.IndexOf('=') != -1) - { - string[] splitCookie = cookie.Split('='); - splitCookie[0] = splitCookie[0].Trim(); - splitCookie[1] = splitCookie[1].Trim(); - if (!Cookies.ContainsKey(splitCookie[0].Trim())) - Cookies.Add(splitCookie[0], splitCookie[1]); - } - else - { - if (!Cookies.ContainsKey(cookie.Trim())) - { - Cookies.Add(cookie.Trim(), string.Empty); - } - } - } - } + var headerEnd = body.IndexOf("\r\n\r\n", position, partLength, StringComparison.Ordinal); + if (headerEnd < 0) + return false; + + var nameStart = body.IndexOf("name=\"", position, headerEnd - position, StringComparison.OrdinalIgnoreCase); + if (nameStart < 0) + return false; + nameStart += 6; + + var nameEnd = body.IndexOf('"', nameStart, headerEnd - nameStart); + if (nameEnd < 0 || nameEnd > headerEnd) + return false; + + fieldCount++; + EnsureWithinLimit(fieldCount, MaximumFormFields, "form fields"); + + var name = body.Substring(nameStart, nameEnd - nameStart); + EnsureStringWithinLimit(name, MaximumFormKeyLength, "form key"); + + var valueStart = headerEnd + 4; + PostForms[name] = body.Substring(valueStart, nextDelimiter - valueStart); + position = nextDelimiter + 2; } - // Query String - if (URL.IndexOf("?", 0) != -1) + return false; + } + + private static string DecodeFormComponent(string value) + => WebUtility.HtmlDecode(WebUtility.UrlDecode(value)); + + private static bool TryGetMultipartBoundary(string contentType, out string boundary) + { + boundary = null; + var parameterStart = contentType.IndexOf(';'); + while (parameterStart >= 0 && parameterStart + 1 < contentType.Length) { - string[] SQ = URL.Split(new char[] { '?' }, 2)[1].Split('&'); - foreach (string S in SQ) - { - if (S.IndexOf("=", 0) != -1) - { - string[] qp = S.Split(new char[] { '=' }, 2); + var parameterEnd = contentType.IndexOf(';', parameterStart + 1); + if (parameterEnd < 0) + parameterEnd = contentType.Length; - if (!Query.ContainsKey(WebUtility.UrlDecode(qp[0]))) - { - Query.Add(WebUtility.UrlDecode(qp[0]), WebUtility.UrlDecode(qp[1])); - } - } - else - { - if (!Query.ContainsKey(WebUtility.UrlDecode(S))) - { - Query.Add(WebUtility.UrlDecode(S), null); - } - } + var parameter = contentType.Substring( + parameterStart + 1, + parameterEnd - parameterStart - 1).Trim(); + var separator = parameter.IndexOf('='); + if (separator > 0 && string.Equals( + parameter.Substring(0, separator).Trim(), + "boundary", + StringComparison.OrdinalIgnoreCase)) + { + boundary = parameter.Substring(separator + 1).Trim().Trim('"'); + return boundary.Length > 0 && boundary.IndexOfAny(new[] { '\r', '\n' }) < 0; } + + parameterStart = parameterEnd < contentType.Length ? parameterEnd : -1; } - // Post Content-Length - if (Method == HttpMethod.POST) - { - try - { + return false; + } - uint postSize = uint.Parse(Headers["content-length"]); + private static void EnsureStringWithinLimit(string value, int limit, string kind) + => EnsureStringWithinLimit(value?.Length ?? 0, limit, kind); - // check limit - if (postSize > data.Length - headerSize) - return -(postSize - (data.Length - headerSize)); + private static void EnsureStringWithinLimit(int length, int limit, string kind) + => EnsureWithinLimit(length, limit, kind); - - if ( - Headers["content-type"] == null - || Headers["content-type"] == "" - || Headers["content-type"].StartsWith("application/x-www-form-urlencoded")) - { - string[] PostVars = null; - PostVars = Encoding.UTF8.GetString(data, (int)headerSize, (int)postSize).Split('&'); - for (int J = 0; J < PostVars.Length; J++) - { - if (PostVars[J].IndexOf("=") != -1) - { - string key = WebUtility.HtmlDecode( - WebUtility.UrlDecode(PostVars[J].Split(new char[] { '=' }, 2)[0])); - if (PostForms.Contains(key)) - PostForms[key] = WebUtility.HtmlDecode( - WebUtility.UrlDecode(PostVars[J].Split(new char[] { '=' }, 2)[1])); - else - PostForms.Add(key, WebUtility.HtmlDecode( - WebUtility.UrlDecode(PostVars[J].Split(new char[] { '=' }, 2)[1]))); - } - else - if (PostForms.Contains("unknown")) - PostForms["unknown"] = PostForms["unknown"] - + "&" + WebUtility.HtmlDecode(WebUtility.UrlDecode(PostVars[J])); - else - PostForms.Add("unknown", WebUtility.HtmlDecode(WebUtility.UrlDecode(PostVars[J]))); - } - } - else if (Headers["content-type"].StartsWith("multipart/form-data")) - { - int st = 1; - int ed = 0; - string strBoundry = "--" + Headers["content-type"].Substring( - Headers["content-type"].IndexOf("boundary=", 0) + 9); - - string[] sc = Encoding.UTF8.GetString(data, (int)headerSize, (int)postSize).Split( - new string[] { strBoundry }, StringSplitOptions.None); - - - for (int j = 1; j < sc.Length - 1; j++) - { - string[] ps = sc[j].Split(new string[] { "\r\n\r\n" }, 2, StringSplitOptions.None); - ps[1] = ps[1].Substring(0, ps[1].Length - 2); // remove the empty line - st = ps[0].IndexOf("name=", 0) + 6; - ed = ps[0].IndexOf("\"", st); - PostForms.Add(ps[0].Substring(st, ed - st), ps[1]); - } - } - //else if (Headers["content-type"] == "application/json") - //{ - // var json = DC.Clip(data, headerSize, postSize); - //} - else - { - //PostForms.Add(Headers["content-type"], Encoding.Default.GetString( )); - Message = data.Clip(headerSize, postSize); - } - - return headerSize + postSize; - - } - catch - { - return 0; - } - } - - return headerSize; + private static void EnsureWithinLimit(int value, int limit, string kind) + { + if (limit > 0 && value > limit) + throw new ParserLimitException( + $"HTTP {kind} of {value} exceeds the configured limit of {limit}."); } } diff --git a/Libraries/Esiur/Net/Packets/Http/HttpResponsePacket.cs b/Libraries/Esiur/Net/Packets/Http/HttpResponsePacket.cs index 4cf4d4d..2895465 100644 --- a/Libraries/Esiur/Net/Packets/Http/HttpResponsePacket.cs +++ b/Libraries/Esiur/Net/Packets/Http/HttpResponsePacket.cs @@ -1,217 +1,210 @@ -/* - -Copyright (c) 2017 Ahmed Kh. Zamil - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -*/ +using Esiur.Data; +using Esiur.Misc; using System; using System.Collections.Generic; -using System.Linq; +using System.Globalization; using System.Text; -using Esiur.Misc; -using Esiur.Data; namespace Esiur.Net.Packets.Http; + public class HttpResponsePacket : Packet { - public StringKeyList Headers { get; } = new StringKeyList(true); public string Version { get; set; } = "HTTP/1.1"; - public byte[] Message; public HttpResponseCode Number { get; set; } = HttpResponseCode.OK; public string Text; - public List Cookies { get; } = new List(); public bool Handled; + public uint MaximumHeaderLength { get; set; } = HttpPacketHelpers.DefaultMaximumHeaderLength; + public uint MaximumContentLength { get; set; } = HttpPacketHelpers.DefaultMaximumContentLength; + public int MaximumHeaderCount { get; set; } = HttpPacketHelpers.DefaultMaximumHeaderCount; + + /// + /// Maximum response body accepted by . + /// Zero preserves the legacy behavior of allowing any body that fits in a managed array. + /// + public uint MaximumComposedContentLength { get; set; } + public override string ToString() - { - return "HTTPResponsePacket" - + "\n\tVersion: " + Version - //+ "\n\tMethod: " + Method - //+ "\n\tURL: " + URL - + "\n\tMessage: " + (Message != null ? Message.Length.ToString() : "NULL"); - } + => $"HTTPResponsePacket\n\tVersion: {Version}" + + $"\n\tMessage: {(Message == null ? "NULL" : Message.Length.ToString())}"; - private string MakeHeader(HttpComposeOption options) + private byte[] ComposeHeader(HttpComposeOption options) { - string header = $"{Version} {(int)Number} {Text}\r\nServer: Esiur {Global.Version}\r\nDate: {DateTime.Now.ToUniversalTime().ToString("r")}\r\n"; - if (options == HttpComposeOption.AllCalculateLength) - Headers["Content-Length"] = Message?.Length.ToString() ?? "0"; + Headers["Content-Length"] = Message?.Length.ToString(CultureInfo.InvariantCulture) ?? "0"; - foreach (var kv in Headers) - header += kv.Key + ": " + kv.Value + "\r\n"; + var header = new StringBuilder(256); + header.Append(Version) + .Append(' ') + .Append((int)Number) + .Append(' ') + .Append(Text ?? string.Empty) + .Append("\r\nServer: Esiur ") + .Append(Global.Version) + .Append("\r\nDate: ") + .Append(DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture)) + .Append("\r\n"); + foreach (var entry in Headers) + header.Append(entry.Key).Append(": ").Append(entry.Value).Append("\r\n"); + foreach (var cookie in Cookies) + header.Append("Set-Cookie: ").Append(cookie).Append("\r\n"); - // Set-Cookie: ckGeneric=CookieBody; expires=Sun, 30-Dec-2007 21:00:00 GMT; path=/ - // Set-Cookie: ASPSESSIONIDQABBDSQA=IPDPMMMALDGFLMICEJIOCIPM; path=/ - - foreach (var Cookie in Cookies) - header += "Set-Cookie: " + Cookie.ToString() + "\r\n"; - - - header += "\r\n"; - - return header; + header.Append("\r\n"); + return Encoding.ASCII.GetBytes(header.ToString()); } - public bool Compose(HttpComposeOption options) { - List msg = new List(); + var header = options == HttpComposeOption.DataOnly + ? Array.Empty() + : ComposeHeader(options); + var body = options == HttpComposeOption.SpecifiedHeadersOnly || Message == null + ? Array.Empty() + : Message; - if (options != HttpComposeOption.DataOnly) - { - msg.AddRange(Encoding.UTF8.GetBytes(MakeHeader(options))); - } + if (MaximumComposedContentLength > 0 && body.LongLength > MaximumComposedContentLength) + throw new ParserLimitException( + $"HTTP content length of {body.LongLength} bytes exceeds the {MaximumComposedContentLength}-byte limit."); - if (options != HttpComposeOption.SpecifiedHeadersOnly) - { - if (Message != null) - msg.AddRange(Message); - } - - Data = msg.ToArray(); + Data = new byte[checked(header.Length + body.Length)]; + if (header.Length > 0) + Buffer.BlockCopy(header, 0, Data, 0, header.Length); + if (body.Length > 0) + Buffer.BlockCopy(body, 0, Data, header.Length, body.Length); return true; } - public override bool Compose() - { - return Compose(HttpComposeOption.AllDontCalculateLength); - } + public override bool Compose() => Compose(HttpComposeOption.AllDontCalculateLength); public override long Parse(byte[] data, uint offset, uint ends) { - string[] sMethod = null; - string[] sLines = null; + ValidateBounds(data, offset, ends); + var originalOffset = offset; - uint headerSize = 0; - - for (uint i = offset; i < ends - 3; i++) - { - if (data[i] == '\r' && data[i + 1] == '\n' - && data[i + 2] == '\r' && data[i + 3] == '\n') - { - sLines = Encoding.ASCII.GetString(data, (int)offset, (int)(i - offset)).Split(new string[] { "\r\n" }, - StringSplitOptions.None); - - headerSize = i + 4; - break; - } - } - - if (headerSize == 0) + if (!HttpPacketHelpers.TryFindHeaderEnd( + data, offset, ends, MaximumHeaderLength, out var bodyOffset)) return -1; + var lines = HttpPacketHelpers.ReadHeaderLines( + data, offset, bodyOffset, MaximumHeaderCount); - sMethod = sLines[0].Split(' '); - - if (sMethod.Length == 3) - { - Version = sMethod[0].Trim(); - Number = (HttpResponseCode)Convert.ToInt32(sMethod[1].Trim()); - Text = sMethod[2]; - } - - // Read all headers - - for (int i = 1; i < sLines.Length; i++) - { - if (sLines[i] == string.Empty) - { - // Invalid header - return 0; - } - - if (sLines[i].IndexOf(':') == -1) - { - // Invalid header - return 0; - } - - string[] header = sLines[i].Split(new char[] { ':' }, 2); - - header[0] = header[0].ToLower(); - Headers[header[0]] = header[1].Trim(); - - //Set-Cookie: NAME=VALUE; expires=DATE; - - if (header[0] == "set-cookie") - { - string[] cookie = header[1].Split(';'); - - if (cookie.Length >= 1) - { - string[] splitCookie = cookie[0].Split('='); - HttpCookie c = new HttpCookie(splitCookie[0], splitCookie[1]); - - for (int j = 1; j < cookie.Length; j++) - { - splitCookie = cookie[j].Split('='); - switch (splitCookie[0].ToLower()) - { - case "domain": - c.Domain = splitCookie[1]; - break; - case "path": - c.Path = splitCookie[1]; - break; - case "httponly": - c.HttpOnly = true; - break; - case "expires": - // Wed, 13-Jan-2021 22:23:01 GMT - c.Expires = DateTime.Parse(splitCookie[1]); - break; - } - } - - } - - } - } - - // Content-Length - - try - { - - uint contentLength = uint.Parse(Headers["content-length"]); - - // check limit - if (contentLength > data.Length - headerSize) - { - return contentLength - (data.Length - headerSize); - } - - Message = data.Clip(offset, contentLength); - - return headerSize + contentLength; - - } - catch - { + if (lines.Length == 0) return 0; + + var statusLine = lines[0].Split(new[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + if (statusLine.Length < 2 || + !int.TryParse(statusLine[1], NumberStyles.None, CultureInfo.InvariantCulture, out var statusCode)) + return 0; + + Version = statusLine[0]; + Number = (HttpResponseCode)statusCode; + Text = statusLine.Length == 3 ? statusLine[2] : string.Empty; + Headers.Clear(); + Cookies.Clear(); + Message = null; + + var hasContentLength = false; + + for (var i = 1; i < lines.Length; i++) + { + var separator = lines[i].IndexOf(':'); + if (separator <= 0) + return 0; + + var name = lines[i].Substring(0, separator).Trim(); + var value = lines[i].Substring(separator + 1).Trim(); + + if (string.Equals(name, "transfer-encoding", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("HTTP Transfer-Encoding is not supported."); + + if (string.Equals(name, "content-length", StringComparison.OrdinalIgnoreCase)) + { + if (hasContentLength) + throw new InvalidDataException("Duplicate HTTP Content-Length headers are not accepted."); + + hasContentLength = true; + } + + Headers.Add(name, value); + + if (string.Equals(name, "set-cookie", StringComparison.OrdinalIgnoreCase) && + TryParseCookie(value, out var cookie)) + Cookies.Add(cookie); } + + var contentLengthHeader = Headers["content-length"]; + if (contentLengthHeader == null) + { + Message = Array.Empty(); + return bodyOffset - originalOffset; + } + + if (!uint.TryParse( + contentLengthHeader, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var contentLength)) + return 0; + + if (MaximumContentLength > 0 && contentLength > MaximumContentLength) + throw new ParserLimitException( + $"HTTP content length of {contentLength} bytes exceeds the {MaximumContentLength}-byte limit."); + + var availableBody = ends - bodyOffset; + if (availableBody < contentLength) + return -(long)(contentLength - availableBody); + + Message = data.Clip(bodyOffset, contentLength); + return bodyOffset - originalOffset + contentLength; + } + + private static bool TryParseCookie(string header, out HttpCookie cookie) + { + cookie = default; + var segments = header.Split(';'); + if (segments.Length == 0) + return false; + + var nameValueSeparator = segments[0].IndexOf('='); + if (nameValueSeparator <= 0) + return false; + + cookie = new HttpCookie( + segments[0].Substring(0, nameValueSeparator).Trim(), + segments[0].Substring(nameValueSeparator + 1).Trim()); + + for (var i = 1; i < segments.Length; i++) + { + var segment = segments[i].Trim(); + var separator = segment.IndexOf('='); + var name = separator < 0 ? segment : segment.Substring(0, separator).Trim(); + var value = separator < 0 ? string.Empty : segment.Substring(separator + 1).Trim(); + + if (string.Equals(name, "domain", StringComparison.OrdinalIgnoreCase)) + cookie.Domain = value; + else if (string.Equals(name, "path", StringComparison.OrdinalIgnoreCase)) + cookie.Path = value; + else if (string.Equals(name, "httponly", StringComparison.OrdinalIgnoreCase)) + cookie.HttpOnly = true; + else if (string.Equals(name, "secure", StringComparison.OrdinalIgnoreCase)) + cookie.Secure = true; + else if (string.Equals(name, "samesite", StringComparison.OrdinalIgnoreCase) && + Enum.TryParse(value, true, out HttpCookieSameSite sameSite)) + cookie.SameSite = sameSite; + else if (string.Equals(name, "expires", StringComparison.OrdinalIgnoreCase) && + DateTime.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var expires)) + cookie.Expires = expires; + } + + return true; } } diff --git a/Libraries/Esiur/Net/Packets/Packet.cs b/Libraries/Esiur/Net/Packets/Packet.cs index 52a6513..db03079 100644 --- a/Libraries/Esiur/Net/Packets/Packet.cs +++ b/Libraries/Esiur/Net/Packets/Packet.cs @@ -1,367 +1,42 @@ - -/********************************************************************************\ -* Uruky Project * -* * -* Copyright (C) 2006 Ahmed Zamil - ahmed@dijlh.com * -* http://www.dijlh.com * -* * -* Permission is hereby granted, free of charge, to any person obtaining a copy * -* of this software and associated documentation files (the "Software"), to deal * -* in the Software without restriction, including without limitation the rights * -* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * -* copies of the Software, and to permit persons to whom the Software is * -* furnished to do so, subject to the following conditions: * -* * -* The above copyright notice and this permission notice shall be included in all * -* copies or substantial portions of the Software. * -* * -* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * -* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * -* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * -* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * -* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * -* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * -* SOFTWARE. * -* * -* File: Packet.cs * -* Description: Ethernet/ARP/IPv4/TCP/UDP Packet Decoding & Encoding Class * -* Compatibility: .Net Framework 2.0 / Mono 1.1.8 * -* * -\********************************************************************************/ - - - using System; -using System.Text; -using Esiur.Misc; -using Esiur.Net.DataLink; -using System.Net.NetworkInformation; -using Esiur.Data; namespace Esiur.Net.Packets; -internal static class Functions -{ - public static void AddData(ref byte[] dest, byte[] src) - { - int I = 0; - if (src == null) - { - return; - } - if (dest != null) - { - I = dest.Length; - Array.Resize(ref dest, dest.Length + src.Length); - //dest = (byte[])Resize(dest, dest.Length + src.Length); - } - else - { - dest = new byte[src.Length]; - } - Array.Copy(src, 0, dest, I, src.Length); - } - - /* - public static Array Resize(Array array, int newSize) - { - Type myType = Type.GetType(array.GetType().FullName.TrimEnd('[', ']')); - Array nA = Array.CreateInstance(myType, newSize); - Array.Copy(array, nA, (newSize > array.Length ? array.Length : newSize)); - return nA; - } */ - - //Computes the checksum used in IP, ARP..., ie the - // "The 16 bit one's complement of the one 's complement sum - //of all 16 bit words" as seen in RFCs - // Returns a 4 characters hex string - // data's lenght must be multiple of 4, else zero padding - public static ushort IP_CRC16(byte[] data) - { - ulong Sum = 0; - bool Padding = false; - /// * Padding if needed - if (data.Length % 2 != 0) - { - Array.Resize(ref data, data.Length + 1); - //data = (byte[])Resize(data, data.Length + 1); - Padding = true; - } - int count = data.Length; - ///* add 16-bit words */ - while (count > 0) //1) - { - ///* this is the inner loop */ - Sum += GetInteger(data[count - 2], data[count - 1]); - ///* Fold 32-bit sum to 16-bit */ - while (Sum >> 16 != 0) - { - Sum = (Sum & 0XFFFF) + (Sum >> 16); - } - count -= 2; - } - /// * reverse padding - if (Padding) - { - Array.Resize(ref data, data.Length - 1); - //data = (byte[])Resize(data, data.Length - 1); - } - ///* Return one's compliment of final sum. - //return (ushort)(ushort.MaxValue - (ushort)Sum); - return (ushort)(~Sum); - } - - public static ushort GetInteger(byte B1, byte B2) - { - return BitConverter.ToUInt16(new byte[] { B2, B1 }, 0); - //return System.Convert.ToUInt16("&h" + GetHex(B1) + GetHex(B2)); - } - - public static uint GetLong(byte B1, byte B2, byte B3, byte B4) - { - return BitConverter.ToUInt32(new byte[] { B4, B3, B2, B1 }, 0); - //return System.Convert.ToUInt32("&h" + GetHex(B1) + GetHex(B2) + GetHex(B3) + GetHex(B4)); - } - - public static string GetHex(byte B) - { - return (((B < 15) ? 0 + System.Convert.ToString(B, 16).ToUpper() : System.Convert.ToString(B, 16).ToUpper())); - } - - public static bool GetBit(uint B, byte Pos) - { - //return BitConverter.ToBoolean(BitConverter.GetBytes(B), Pos + 1); - return (B & (uint)(Math.Pow(2, (Pos - 1)))) == (Math.Pow(2, (Pos - 1))); - } - - public static ushort RemoveBit(ushort I, byte Pos) - { - return (ushort)RemoveBit((uint)I, Pos); - } - - public static uint RemoveBit(uint I, byte Pos) - { - if (GetBit(I, Pos)) - { - return I - (uint)(Math.Pow(2, (Pos - 1))); - } - else - { - return I; - } - } - - public static void SplitInteger(ushort I, ref byte BLeft, ref byte BRight) - { - byte[] b = BitConverter.GetBytes(I); - BLeft = b[1]; - BRight = b[0]; - //BLeft = I >> 8; - //BRight = (I << 8) >> 8; - } - - public static void SplitLong(uint I, ref byte BLeft, ref byte BLeftMiddle, ref byte BRightMiddle, ref byte BRight) - { - byte[] b = BitConverter.GetBytes(I); - BLeft = b[3]; - BLeftMiddle = b[2]; - BRightMiddle = b[1]; - BRight = b[0]; - //BLeft = I >> 24; - //BLeftMiddle = (I << 8) >> 24; - //BRightMiddle = (I << 16) >> 24; - //BRight = (I << 24) >> 24; - } - -} - -public class PosixTime -{ - ulong seconds; - ulong microseconds; - - PosixTime(ulong Seconds, ulong Microseconds) - { - seconds = Seconds; - microseconds = Microseconds; - } - - public override string ToString() - { - return seconds + "." + microseconds; - } -} +/// +/// Compatibility base for packet parsers and composers. +/// public class Packet { - //public EtherServer2.EthernetSource Source; - - public PacketSource Source; - - public DateTime Timestamp; - - public enum PPPType : ushort - { - IP = 0x0021, // Internet Protocol version 4 [RFC1332] - SDTP = 0x0049, // Serial Data Transport Protocol (PPP-SDTP) [RFC1963] - IPv6HeaderCompression = 0x004f, // IPv6 Header Compression - IPv6 = 0x0057, // Internet Protocol version 6 [RFC5072] - W8021dHelloPacket = 0x0201, // 802.1d Hello Packets [RFC3518] - IPv6ControlProtocol = 0x8057, // IPv6 Control Protocol [RFC5072] - } - - public enum ProtocolType : ushort - { - IP = 0x800, // IPv4 - ARP = 0x806, // Address Resolution Protocol - IPv6 = 0x86DD, // IPv6 - FrameRelayARP = 0x0808, // Frame Relay ARP [RFC1701] - VINESLoopback = 0x0BAE, // VINES Loopback [RFC1701] - VINESEcho = 0x0BAF, // VINES ECHO [RFC1701] - TransEtherBridging = 0x6558, // TransEther Bridging [RFC1701] - RawFrameRelay = 0x6559, // Raw Frame Relay [RFC1701] - IEE8021QVLAN = 0x8100, // IEEE 802.1Q VLAN-tagged frames (initially Wellfleet) - SNMP = 0x814C, // SNMP [JKR1] - TCPIP_Compression = 0x876B, // TCP/IP Compression [RFC1144] - IPAutonomousSystems = 0x876C, // IP Autonomous Systems [RFC1701] - SecureData = 0x876D, // Secure Data [RFC1701] - PPP = 0x880B, // PPP [IANA] - MPLS = 0x8847, // MPLS [RFC5332] - MPLS_UpstreamAssignedLabel = 0x8848, // MPLS with upstream-assigned label [RFC5332] - PPPoEDiscoveryStage = 0x8863, // PPPoE Discovery Stage [RFC2516] - PPPoESessionStage = 0x8864, // PPPoE Session Stage [RFC2516] - } - - - /* - public static void GetPacketMACAddresses(Packet packet, out byte[] srcMAC, out byte[] dstMAC) - { - - // get the node address - Packet root = packet.RootPacket; - if (root is TZSPPacket) - { - - TZSPPacket tp = (TZSPPacket)root; - if (tp.Protocol == TZSPPacket.TZSPEncapsulatedProtocol.Ethernet) - { - EthernetPacket ep = (EthernetPacket)tp.SubPacket; - srcMAC = ep.SourceMAC; - dstMAC = ep.DestinationMAC; - } - else if (tp.Protocol == TZSPPacket.TZSPEncapsulatedProtocol.IEEE802_11) - { - W802_11Packet wp = (W802_11Packet)tp.SubPacket; - srcMAC = wp.SA; - dstMAC = wp.DA; - } - else - { - srcMAC = null; - dstMAC = null; - } - } - else if (root is EthernetPacket) - { - EthernetPacket ep = (EthernetPacket)root; - srcMAC = ep.SourceMAC; - dstMAC = ep.DestinationMAC; - } - else if (root is W802_11Packet) - { - W802_11Packet wp = (W802_11Packet)root; - srcMAC = wp.SA; - dstMAC = wp.DA; - } - else - { - srcMAC = null; - dstMAC = null; - } - - } - - - public static void GetPacketAddresses(Packet packet, ref string srcMAC, ref string dstMAC, ref string srcIP, ref string dstIP) - { - - if (packet is TCPv4Packet) - { - if (packet.ParentPacket is IPv4Packet) - { - IPv4Packet ip = (IPv4Packet)packet.ParentPacket; - srcIP = ip.SourceIP.ToString(); - dstIP = ip.DestinationIP.ToString(); - } - } - - // get the node address - Packet root = packet.RootPacket; - if (root is TZSPPacket) - { - - TZSPPacket tp = (TZSPPacket)root; - if (tp.Protocol == TZSPPacket.TZSPEncapsulatedProtocol.Ethernet) - { - EthernetPacket ep = (EthernetPacket)tp.SubPacket; - srcMAC = DC.GetPhysicalAddress(ep.SourceMAC, 0).ToString(); - dstMAC = DC.GetPhysicalAddress(ep.DestinationMAC, 0).ToString(); - } - else if (tp.Protocol == TZSPPacket.TZSPEncapsulatedProtocol.IEEE802_11) - { - W802_11Packet wp = (W802_11Packet)tp.SubPacket; - srcMAC = DC.GetPhysicalAddress(wp.SA, 0).ToString(); - dstMAC = DC.GetPhysicalAddress(wp.DA, 0).ToString(); - } - } - else if (root is EthernetPacket) - { - EthernetPacket ep = (EthernetPacket)root; - srcMAC = DC.GetPhysicalAddress(ep.SourceMAC, 0).ToString(); - dstMAC = DC.GetPhysicalAddress(ep.DestinationMAC, 0).ToString(); - } - else if (root is W802_11Packet) - { - W802_11Packet wp = (W802_11Packet)root; - srcMAC = DC.GetPhysicalAddress(wp.SA, 0).ToString(); - dstMAC = DC.GetPhysicalAddress(wp.DA, 0).ToString(); - } - } - */ - - //PosixTime Timeval; - public byte[] Header; - public byte[] Preamble; - //public byte[] Payload; public byte[] Data; - public Packet SubPacket; - public Packet ParentPacket; - public virtual long Parse(byte[] data, uint offset, uint ends) { return 0; } - public virtual bool Compose() { return false; } + public virtual long Parse(byte[] data, uint offset, uint ends) => 0; - public Packet RootPacket + public virtual bool Compose() => false; + + protected static void ValidateBounds(byte[] data, uint offset, uint ends) { - get - { - Packet root = this; - while (root.ParentPacket != null) - root = root.ParentPacket; - return root; - } + if (data == null) + throw new ArgumentNullException(nameof(data)); + if (ends > data.Length) + throw new ArgumentOutOfRangeException(nameof(ends)); + if (offset > ends) + throw new ArgumentOutOfRangeException(nameof(offset)); } - public Packet LeafPacket + protected static bool TryGetMissingBytes( + uint offset, + uint ends, + uint needed, + out long parseResult) { - get + var available = ends - offset; + if (available < needed) { - Packet leaf = this; - while (leaf.SubPacket != null) - leaf = leaf.SubPacket; - return leaf; + parseResult = -(long)(needed - available); + return true; } + + parseResult = 0; + return false; } } - - -/************************************ EOF *************************************/ - diff --git a/Libraries/Esiur/Net/Packets/WebSocket/WebsocketPacket.cs b/Libraries/Esiur/Net/Packets/WebSocket/WebsocketPacket.cs index c2d7083..9a673dc 100644 --- a/Libraries/Esiur/Net/Packets/WebSocket/WebsocketPacket.cs +++ b/Libraries/Esiur/Net/Packets/WebSocket/WebsocketPacket.cs @@ -1,56 +1,26 @@ -/* - -Copyright (c) 2017 Ahmed Kh. Zamil - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -*/ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Esiur.Misc; using Esiur.Data; +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; namespace Esiur.Net.Packets.WebSocket; + public class WebsocketPacket : Packet { + private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); + public enum WSOpcode : byte { - ContinuationFrame = 0x0, // %x0 denotes a continuation frame - - TextFrame = 0x1, // %x1 denotes a text frame - - BinaryFrame = 0x2, // %x2 denotes a binary frame - - // %x3-7 are reserved for further non-control frames - - ConnectionClose = 0x8, // %x8 denotes a connection close - - Ping = 0x9, // %x9 denotes a ping - - Pong = 0xA, // %xA denotes a pong - - //* %xB-F are reserved for further control frames + ContinuationFrame = 0x0, + TextFrame = 0x1, + BinaryFrame = 0x2, + ConnectionClose = 0x8, + Ping = 0x9, + Pong = 0xA, } + public const ulong DefaultMaximumPayloadLength = 8 * 1024 * 1024; public bool FIN; public bool RSV1; @@ -59,157 +29,243 @@ public class WebsocketPacket : Packet public WSOpcode Opcode; public bool Mask; public long PayloadLength; - // public UInt32 MaskKey; public byte[] MaskKey; - public byte[] Message; + /// + /// Maximum accepted or composed payload. Set to zero to disable the limit. + /// + public ulong MaximumPayloadLength { get; set; } = DefaultMaximumPayloadLength; + + /// + /// Expected mask bit for an incoming frame. Servers set this to true, + /// clients set it to false, and standalone packet parsing can leave it + /// null to accept either direction. + /// + public bool? ExpectedMask { get; set; } + public override string ToString() - { - return "WebsocketPacket" - + "\n\tFIN: " + FIN - + "\n\tOpcode: " + Opcode - + "\n\tPayload: " + PayloadLength - + "\n\tMaskKey: " + MaskKey - + "\n\tMessage: " + (Message != null ? Message.Length.ToString() : "NULL"); - } + => $"WebsocketPacket\n\tFIN: {FIN}\n\tOpcode: {Opcode}\n\tPayload: {PayloadLength}" + + $"\n\tMaskKey: {MaskKey}\n\tMessage: {(Message == null ? "NULL" : Message.Length.ToString())}"; public override bool Compose() { - var pkt = new List(); - pkt.Add((byte)((FIN ? 0x80 : 0x0) | - (RSV1 ? 0x40 : 0x0) | - (RSV2 ? 0x20 : 0x0) | - (RSV3 ? 0x10 : 0x0) | - (byte)Opcode)); + var message = Message ?? Array.Empty(); + ValidateFrame(Opcode, FIN, (ulong)message.LongLength); + ValidateApplicationPayload(Opcode, FIN, message); + EnsureWithinLimit((ulong)message.LongLength); - // calculate length - if (Message.Length > ushort.MaxValue) - // 4 bytes + var extendedLengthSize = message.Length <= 125 + ? 0 + : message.Length <= ushort.MaxValue ? 2 : 8; + var headerLength = 2 + extendedLengthSize + (Mask ? 4 : 0); + Data = new byte[checked(headerLength + message.Length)]; + + var offset = 0; + Data[offset++] = (byte)((FIN ? 0x80 : 0) | + (RSV1 ? 0x40 : 0) | + (RSV2 ? 0x20 : 0) | + (RSV3 ? 0x10 : 0) | + (byte)Opcode); + + if (extendedLengthSize == 0) { - pkt.Add((byte)((Mask ? 0x80 : 0x0) | 127)); - pkt.AddRange(((ulong)Message.LongCount()).ToBytes(Endian.Big)); + Data[offset++] = (byte)((Mask ? 0x80 : 0) | message.Length); } - else if (Message.Length > 125) - // 2 bytes + else if (extendedLengthSize == 2) { - pkt.Add((byte)((Mask ? 0x80 : 0x0) | 126)); - pkt.AddRange(((ushort)Message.Length).ToBytes(Endian.Big)); + Data[offset++] = (byte)((Mask ? 0x80 : 0) | 126); + Data[offset++] = (byte)(message.Length >> 8); + Data[offset++] = (byte)message.Length; } else { - pkt.Add((byte)((Mask ? 0x80 : 0x0) | Message.Length)); + Data[offset++] = (byte)((Mask ? 0x80 : 0) | 127); + var length = (ulong)message.LongLength; + for (var shift = 56; shift >= 0; shift -= 8) + Data[offset++] = (byte)(length >> shift); } if (Mask) { - pkt.AddRange(MaskKey); + if (MaskKey == null || MaskKey.Length != 4) + { + MaskKey = new byte[4]; + using (var random = RandomNumberGenerator.Create()) + random.GetBytes(MaskKey); + } + + Buffer.BlockCopy(MaskKey, 0, Data, offset, MaskKey.Length); + offset += MaskKey.Length; + + for (var i = 0; i < message.Length; i++) + Data[offset + i] = (byte)(message[i] ^ MaskKey[i & 3]); + } + else if (message.Length > 0) + { + Buffer.BlockCopy(message, 0, Data, offset, message.Length); } - pkt.AddRange(Message); - - Data = pkt.ToArray(); - + PayloadLength = message.LongLength; return true; } public override long Parse(byte[] data, uint offset, uint ends) + { + ValidateBounds(data, offset, ends); + var originalOffset = offset; + + if (TryGetMissingBytes(offset, ends, 2, out var incomplete)) + return incomplete; + + var first = data[offset++]; + var second = data[offset++]; + + FIN = (first & 0x80) != 0; + RSV1 = (first & 0x40) != 0; + RSV2 = (first & 0x20) != 0; + RSV3 = (first & 0x10) != 0; + Opcode = (WSOpcode)(first & 0x0F); + Mask = (second & 0x80) != 0; + + if (ExpectedMask.HasValue && Mask != ExpectedMask.Value) + throw new InvalidDataException(ExpectedMask.Value + ? "WebSocket clients must mask every frame sent to a server." + : "WebSocket servers must not mask frames sent to a client."); + + if (RSV1 || RSV2 || RSV3) + throw new InvalidDataException("WebSocket extensions are not enabled for this connection."); + + ulong payloadLength = (byte)(second & 0x7F); + if (payloadLength == 126) + { + if (TryGetMissingBytes(offset, ends, 2, out incomplete)) + return incomplete; + + payloadLength = (uint)(data[offset] << 8 | data[offset + 1]); + offset += 2; + + if (payloadLength < 126) + throw new InvalidDataException("WebSocket payload length is not minimally encoded."); + } + else if (payloadLength == 127) + { + if (TryGetMissingBytes(offset, ends, 8, out incomplete)) + return incomplete; + if ((data[offset] & 0x80) != 0) + throw new InvalidDataException("WebSocket payload length exceeds the protocol limit."); + + payloadLength = 0; + for (var i = 0; i < 8; i++) + payloadLength = payloadLength << 8 | data[offset++]; + + if (payloadLength <= ushort.MaxValue) + throw new InvalidDataException("WebSocket payload length is not minimally encoded."); + } + + ValidateFrame(Opcode, FIN, payloadLength); + EnsureWithinLimit(payloadLength); + if (payloadLength > int.MaxValue) + throw new ParserLimitException("WebSocket payload cannot fit in a managed byte array."); + + if (Mask) + { + if (TryGetMissingBytes(offset, ends, 4, out incomplete)) + return incomplete; + + MaskKey = new byte[4]; + Buffer.BlockCopy(data, (int)offset, MaskKey, 0, MaskKey.Length); + offset += 4; + } + else + { + MaskKey = null; + } + + var availablePayload = ends - offset; + if ((ulong)availablePayload < payloadLength) + return -(long)(payloadLength - availablePayload); + + Message = new byte[(int)payloadLength]; + if (Mask) + { + for (var i = 0; i < Message.Length; i++) + Message[i] = (byte)(data[offset + i] ^ MaskKey[i & 3]); + } + else if (Message.Length > 0) + { + Buffer.BlockCopy(data, (int)offset, Message, 0, Message.Length); + } + + offset += (uint)payloadLength; + PayloadLength = (long)payloadLength; + ValidateApplicationPayload(Opcode, FIN, Message); + return offset - originalOffset; + } + + private void EnsureWithinLimit(ulong payloadLength) + { + if (MaximumPayloadLength > 0 && payloadLength > MaximumPayloadLength) + throw new ParserLimitException( + $"WebSocket payload of {payloadLength} bytes exceeds the {MaximumPayloadLength}-byte limit."); + } + + private static void ValidateFrame(WSOpcode opcode, bool final, ulong payloadLength) + { + var isControl = opcode == WSOpcode.ConnectionClose || + opcode == WSOpcode.Ping || + opcode == WSOpcode.Pong; + var isData = opcode == WSOpcode.ContinuationFrame || + opcode == WSOpcode.TextFrame || + opcode == WSOpcode.BinaryFrame; + + if (!isControl && !isData) + throw new InvalidDataException($"Unsupported WebSocket opcode: 0x{(byte)opcode:X}."); + if (isControl && (!final || payloadLength > 125)) + throw new InvalidDataException("WebSocket control frames must be final and at most 125 bytes."); + if (opcode == WSOpcode.ConnectionClose && payloadLength == 1) + throw new InvalidDataException("A WebSocket close frame cannot contain a one-byte payload."); + } + + private static void ValidateApplicationPayload(WSOpcode opcode, bool final, byte[] payload) + { + if (opcode == WSOpcode.TextFrame && final) + ValidateTextPayload(payload); + else if (opcode == WSOpcode.ConnectionClose) + ValidateClosePayload(payload); + } + + internal static void ValidateTextPayload(byte[] payload) + => ValidateTextPayload(payload ?? Array.Empty(), 0, payload?.Length ?? 0); + + private static void ValidateTextPayload(byte[] payload, int offset, int count) { try { - long needed = 2; - var length = ends - offset; - if (length < needed) - { - //Console.WriteLine("stage 1 " + needed); - return length - needed; - } - - uint oOffset = offset; - FIN = (data[offset] & 0x80) == 0x80; - RSV1 = (data[offset] & 0x40) == 0x40; - RSV2 = (data[offset] & 0x20) == 0x20; - RSV3 = (data[offset] & 0x10) == 0x10; - Opcode = (WSOpcode)(data[offset++] & 0xF); - Mask = (data[offset] & 0x80) == 0x80; - PayloadLength = data[offset++] & 0x7F; - - if (Mask) - needed += 4; - - if (PayloadLength == 126) - { - needed += 2; - if (length < needed) - { - //Console.WriteLine("stage 2 " + needed); - return length - needed; - } - PayloadLength = data.GetUInt16(offset, Endian.Big); - offset += 2; - } - else if (PayloadLength == 127) - { - needed += 8; - if (length < needed) - { - //Console.WriteLine("stage 3 " + needed); - return length - needed; - } - - PayloadLength = data.GetInt64(offset, Endian.Big); - offset += 8; - } - - /* - if (Mask) - { - MaskKey = new byte[4]; - MaskKey[0] = data[offset++]; - MaskKey[1] = data[offset++]; - MaskKey[2] = data[offset++]; - MaskKey[3] = data[offset++]; - - //MaskKey = DC.GetUInt32(data, offset); - //offset += 4; - } - */ - - needed += PayloadLength; - if (length < needed) - { - //Console.WriteLine("stage 4"); - return length - needed; - } - else - { - - if (Mask) - { - MaskKey = new byte[4]; - MaskKey[0] = data[offset++]; - MaskKey[1] = data[offset++]; - MaskKey[2] = data[offset++]; - MaskKey[3] = data[offset++]; - - Message = data.Clip(offset, (uint)PayloadLength); - - //var aMask = BitConverter.GetBytes(MaskKey); - for (int i = 0; i < Message.Length; i++) - Message[i] = (byte)(Message[i] ^ MaskKey[i % 4]); - } - else - Message = data.Clip(offset, (uint)PayloadLength); - - - return offset - oOffset + (int)PayloadLength; - } + _ = StrictUtf8.GetCharCount(payload, offset, count); } - catch (Exception ex) + catch (DecoderFallbackException exception) { - Global.Log(ex); - Global.Log("WebsocketPacket", Core.LogType.Debug, offset + "::" + data.ToHex()); - throw ex; + throw new InvalidDataException("WebSocket text payload is not valid UTF-8.", exception); } } + + private static void ValidateClosePayload(byte[] payload) + { + if (payload == null || payload.Length < 2) + return; + + var statusCode = payload[0] << 8 | payload[1]; + var isDefinedProtocolCode = statusCode >= 1000 && statusCode <= 1014 + && statusCode != 1004 + && statusCode != 1005 + && statusCode != 1006; + var isApplicationCode = statusCode >= 3000 && statusCode <= 4999; + + if (!isDefinedProtocolCode && !isApplicationCode) + throw new InvalidDataException($"Invalid WebSocket close status code: {statusCode}."); + + if (payload.Length > 2) + ValidateTextPayload(payload, 2, payload.Length - 2); + } } diff --git a/Libraries/Esiur/Net/Sockets/SSLSocket.cs b/Libraries/Esiur/Net/Sockets/SSLSocket.cs index 5fc77ab..a3f6892 100644 --- a/Libraries/Esiur/Net/Sockets/SSLSocket.cs +++ b/Libraries/Esiur/Net/Sockets/SSLSocket.cs @@ -41,6 +41,12 @@ namespace Esiur.Net.Sockets; public class SSLSocket : ISocket { + private sealed class PendingSend + { + public AsyncReply Reply; + public byte[] Buffer; + } + public INetworkReceiver Receiver { get; set; } Socket sock; @@ -54,7 +60,10 @@ public class SSLSocket : ISocket readonly object sendLock = new object(); - Queue, byte[]>> sendBufferQueue = new Queue, byte[]>>();// Queue(); + readonly Queue sendBufferQueue = new Queue(); + PendingSend currentSend; + long pendingSendBytes; + long maximumPendingSendBytes = 16 * 1024 * 1024; bool asyncSending; bool began = false; @@ -72,33 +81,50 @@ public class SSLSocket : ISocket bool server; string hostname; + public long PendingSendBytes => Interlocked.Read(ref pendingSendBytes); + + /// Maximum number of unsent plaintext bytes retained for a slow TLS peer. + public long MaximumPendingSendBytes + { + get => Interlocked.Read(ref maximumPendingSendBytes); + set + { + if (value <= 0) + throw new ArgumentOutOfRangeException(nameof(value)); + + Interlocked.Exchange(ref maximumPendingSendBytes, value); + } + } + public async AsyncReply Connect(string hostname, ushort port) { - var rt = new AsyncReply(); - this.hostname = hostname; this.server = false; state = SocketState.Connecting; - await sock.ConnectAsync(hostname, port); - - try { - await BeginAsync(); + await sock.ConnectAsync(hostname, port); + ssl = new SslStream(new NetworkStream(sock)); state = SocketState.Established; + + if (!await BeginAsync()) + { + Close(); + return false; + } + //OnConnect?.Invoke(); Receiver?.NetworkConnect(this); + return true; } catch (Exception ex) { - state = SocketState.Closed;// .Terminated; Close(); Global.Log(ex); + return false; } - - return true; } //private void DataSent(Task task) @@ -132,64 +158,55 @@ public class SSLSocket : ISocket //} - private void SendCallback(IAsyncResult ar) + private async Task ProcessSendQueueAsync() { - if (ar != null) + while (true) { - try + PendingSend pending; + + lock (sendLock) { - ssl.EndWrite(ar); - - if (ar.AsyncState != null) - ((AsyncReply)ar.AsyncState).Trigger(true); - } - catch - { - if (state != SocketState.Closed && !sock.Connected) - { - //state = SocketState.Closed;//.Terminated; - Close(); - } - } - } - - lock (sendLock) - { - - if (sendBufferQueue.Count > 0) - { - var kv = sendBufferQueue.Dequeue(); - - try - { - ssl.BeginWrite(kv.Value, 0, kv.Value.Length, SendCallback, kv.Key); - } - catch //(Exception ex) + if (held || state == SocketState.Closed || sendBufferQueue.Count == 0) { asyncSending = false; - try - { - if (kv.Key != null) - kv.Key.Trigger(false); - - if (state != SocketState.Closed && !sock.Connected) - { - //state = SocketState.Terminated; - Close(); - } - } - catch //(Exception ex2) - { - //state = SocketState.Closed;// .Terminated; - Close(); - } - - //Global.Log("TCPSocket", LogType.Error, ex.ToString()); + return; } + + pending = sendBufferQueue.Dequeue(); + currentSend = pending; } - else + + try { - asyncSending = false; + await ssl.WriteAsync(pending.Buffer, 0, pending.Buffer.Length).ConfigureAwait(false); + + lock (sendLock) + { + if (ReferenceEquals(currentSend, pending)) + { + currentSend = null; + Interlocked.Add(ref pendingSendBytes, -pending.Buffer.Length); + } + } + + TryCompleteSend(pending, true, null); + } + catch (Exception exception) + { + lock (sendLock) + { + if (ReferenceEquals(currentSend, pending)) + { + currentSend = null; + Interlocked.Add(ref pendingSendBytes, -pending.Buffer.Length); + } + + asyncSending = false; + } + + TryCompleteSend(pending, false, exception); + Close(); + return; } } } @@ -257,25 +274,52 @@ public class SSLSocket : ISocket public void Close() { - if (state != SocketState.Closed)// && state != SocketState.Terminated) + List abandoned; + lock (sendLock) { - state = SocketState.Closed; + if (state == SocketState.Closed) + return; - if (sock.Connected) + state = SocketState.Closed; + abandoned = sendBufferQueue.ToList(); + sendBufferQueue.Clear(); + if (currentSend != null) { - try - { - sock.Shutdown(SocketShutdown.Both); - } - catch - { - //state = SocketState.Terminated; - } + abandoned.Insert(0, currentSend); + currentSend = null; } - Receiver?.NetworkClose(this); - //OnClose?.Invoke(); + foreach (var pending in abandoned) + Interlocked.Add(ref pendingSendBytes, -pending.Buffer.Length); + asyncSending = false; } + + if (sock != null) + { + try + { + if (sock.Connected) + sock.Shutdown(SocketShutdown.Both); + } + catch + { + //state = SocketState.Terminated; + } + + // Closing the underlying socket is what aborts an in-progress TLS + // handshake. Shutdown alone can leave AuthenticateAsServerAsync waiting + // forever for a peer that never sends a ClientHello. + try { sock.Close(); } catch { } + } + + try { ssl?.Dispose(); } catch { } + + foreach (var pending in abandoned) + TryCompleteSend(pending, false, null); + + try { Receiver?.NetworkClose(this); } + catch (Exception exception) { Global.Log(exception); } + //OnClose?.Invoke(); } @@ -287,35 +331,26 @@ public class SSLSocket : ISocket public void Send(byte[] message, int offset, int size) { + ValidateRange(message, offset, size); + if (size == 0) + return; - - var msg = message.Clip((uint)offset, (uint)size); - + bool startPump = false; lock (sendLock) { - - if (!sock.Connected) + if (state != SocketState.Established) return; - if (asyncSending || held) - { - sendBufferQueue.Enqueue(new KeyValuePair, byte[]>(null, msg));// message.Clip((uint)offset, (uint)size)); - } - else - { - asyncSending = true; - try - { - ssl.BeginWrite(msg, 0, msg.Length, SendCallback, null); - } - catch - { - asyncSending = false; - //state = SocketState.Terminated; - Close(); - } - } + EnsureSendCapacity_NoLock(size); + var msg = new byte[size]; + Buffer.BlockCopy(message, offset, msg, 0, size); + sendBufferQueue.Enqueue(new PendingSend { Buffer = msg }); + Interlocked.Add(ref pendingSendBytes, size); + startPump = TryStartSendPump_NoLock(); } + + if (startPump) + _ = ProcessSendQueueAsync(); } //public void Send(byte[] message) @@ -438,15 +473,16 @@ public class SSLSocket : ISocket ssl.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveCallback, this); } - catch //(Exception ex) + catch (Exception ex) { - if (state != SocketState.Closed && !sock.Connected) - { - //state = SocketState.Terminated; + // Socket.Connected reports the state of the last operation and can + // remain true after a TLS read failure. Any read exception ends this + // receive loop, so close deterministically instead of leaving a + // half-open connection that will never read again. + if (state != SocketState.Closed) Close(); - } - //Global.Log("SSLSocket", LogType.Error, ex.ToString()); + Global.Log("SSLSocket", LogType.Warning, ex.ToString()); } } @@ -489,59 +525,101 @@ public class SSLSocket : ISocket public void Hold() { - held = true; + lock (sendLock) + held = true; } public void Unhold() { - try - { - SendCallback(null); - } - catch (Exception ex) - { - Global.Log(ex); - } - finally + bool startPump; + lock (sendLock) { held = false; + startPump = TryStartSendPump_NoLock(); } + + if (startPump) + _ = ProcessSendQueueAsync(); } public AsyncReply SendAsync(byte[] message, int offset, int length) { + ValidateRange(message, offset, length); + if (length == 0) + return new AsyncReply(true); - var msg = message.Clip((uint)offset, (uint)length); - + var rt = new AsyncReply(); + bool startPump = false; + Exception capacityError = null; lock (sendLock) { - if (!sock.Connected) + if (state != SocketState.Established) return new AsyncReply(false); - var rt = new AsyncReply(); - - if (asyncSending || held) + try { - sendBufferQueue.Enqueue(new KeyValuePair, byte[]>(rt, msg)); + EnsureSendCapacity_NoLock(length); + var msg = new byte[length]; + Buffer.BlockCopy(message, offset, msg, 0, length); + sendBufferQueue.Enqueue(new PendingSend { Reply = rt, Buffer = msg }); + Interlocked.Add(ref pendingSendBytes, length); + startPump = TryStartSendPump_NoLock(); } + catch (Exception exception) + { + capacityError = exception; + } + } + + if (capacityError != null) + rt.TriggerError(capacityError); + else if (startPump) + _ = ProcessSendQueueAsync(); + + return rt; + } + + private bool TryStartSendPump_NoLock() + { + if (asyncSending || held || state != SocketState.Established || sendBufferQueue.Count == 0) + return false; + + asyncSending = true; + return true; + } + + private void EnsureSendCapacity_NoLock(int length) + { + var limit = Interlocked.Read(ref maximumPendingSendBytes); + var pending = Interlocked.Read(ref pendingSendBytes); + if (length > limit - pending) + throw new InvalidOperationException($"The TLS send queue exceeded its {limit}-byte limit."); + } + + private static void ValidateRange(byte[] message, int offset, int length) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (offset < 0 || length < 0 || offset > message.Length - length) + throw new ArgumentOutOfRangeException(); + } + + private static void TryCompleteSend(PendingSend pending, bool succeeded, Exception exception) + { + if (pending?.Reply == null) + return; + + try + { + if (exception != null) + pending.Reply.TriggerError(exception); else - { - asyncSending = true; - try - { - ssl.BeginWrite(msg, 0, msg.Length, SendCallback, rt);// null); - } - catch (Exception ex) - { - rt.TriggerError(ex); - asyncSending = false; - //state = SocketState.Terminated; - Close(); - } - } - - return rt; + pending.Reply.Trigger(succeeded); + } + catch (Exception callbackException) + { + Global.Log(callbackException); } } diff --git a/Libraries/Esiur/Net/Sockets/TcpSocket.cs b/Libraries/Esiur/Net/Sockets/TcpSocket.cs index f77a1d6..cd41a0c 100644 --- a/Libraries/Esiur/Net/Sockets/TcpSocket.cs +++ b/Libraries/Esiur/Net/Sockets/TcpSocket.cs @@ -21,6 +21,20 @@ public class TcpSocket : ISocket public AsyncReply Reply; } + private readonly struct SendReplyCompletion + { + public SendReplyCompletion(AsyncReply reply, bool result, Exception error) + { + Reply = reply; + Result = result; + Error = error; + } + + public AsyncReply Reply { get; } + public bool Result { get; } + public Exception Error { get; } + } + public INetworkReceiver Receiver { get; set; } public event DestroyedEvent OnDestroy; @@ -37,6 +51,8 @@ public class TcpSocket : ISocket private SocketAsyncEventArgs sendArgs; private PendingSend currentSend; + private long pendingSendBytes; + private long maximumPendingSendBytes = 16 * 1024 * 1024; private bool sendInProgress; private bool began; private bool held; @@ -52,6 +68,24 @@ public class TcpSocket : ISocket public SocketState State => state; public int BytesSent => bytesSent; public int BytesReceived => bytesReceived; + public long PendingSendBytes => Interlocked.Read(ref pendingSendBytes); + + /// + /// Maximum number of unsent bytes retained by this socket. This bounds the + /// copies made by and + /// when a peer is slow. + /// + public long MaximumPendingSendBytes + { + get => Interlocked.Read(ref maximumPendingSendBytes); + set + { + if (value <= 0) + throw new ArgumentOutOfRangeException(nameof(value)); + + Interlocked.Exchange(ref maximumPendingSendBytes, value); + } + } public IPEndPoint LocalEndPoint => sock.LocalEndPoint as IPEndPoint; public IPEndPoint RemoteEndPoint => sock.RemoteEndPoint as IPEndPoint; @@ -227,14 +261,19 @@ public class TcpSocket : ISocket if (destroyed || state != SocketState.Established) return; - var copy = new byte[length]; - Buffer.BlockCopy(message, offset, copy, 0, length); + List completions = null; + Exception sendError = null; lock (sendLock) { if (destroyed || state != SocketState.Established) return; + EnsureSendCapacity_NoLock(length); + + var copy = new byte[length]; + Buffer.BlockCopy(message, offset, copy, 0, length); + sendQueue.Enqueue(new PendingSend { Buffer = copy, @@ -242,9 +281,12 @@ public class TcpSocket : ISocket Count = copy.Length, Reply = null }); + Interlocked.Add(ref pendingSendBytes, length); - TryStartNextSend_NoLock(); + TryStartNextSend_NoLock(ref completions, ref sendError); } + + FinishSendWork(completions, sendError); } public AsyncReply SendAsync(byte[] message, int offset, int length) @@ -268,8 +310,8 @@ public class TcpSocket : ISocket return rt; } - var copy = new byte[length]; - Buffer.BlockCopy(message, offset, copy, 0, length); + List completions = null; + Exception sendError = null; lock (sendLock) { @@ -279,6 +321,19 @@ public class TcpSocket : ISocket return rt; } + try + { + EnsureSendCapacity_NoLock(length); + } + catch (Exception ex) + { + rt.TriggerError(ex); + return rt; + } + + var copy = new byte[length]; + Buffer.BlockCopy(message, offset, copy, 0, length); + sendQueue.Enqueue(new PendingSend { Buffer = copy, @@ -286,10 +341,13 @@ public class TcpSocket : ISocket Count = copy.Length, Reply = rt }); + Interlocked.Add(ref pendingSendBytes, length); - TryStartNextSend_NoLock(); + TryStartNextSend_NoLock(ref completions, ref sendError); } + FinishSendWork(completions, sendError); + return rt; } @@ -323,15 +381,22 @@ public class TcpSocket : ISocket public void Hold() { - held = true; + lock (sendLock) + held = true; } public void Unhold() { - held = false; + List completions = null; + Exception sendError = null; lock (sendLock) - TryStartNextSend_NoLock(); + { + held = false; + TryStartNextSend_NoLock(ref completions, ref sendError); + } + + FinishSendWork(completions, sendError); } public void Close() @@ -444,16 +509,20 @@ public class TcpSocket : ISocket } } - private void TryStartNextSend_NoLock() + private void TryStartNextSend_NoLock( + ref List completions, + ref Exception sendError) { if (held || destroyed || state != SocketState.Established || sendInProgress) return; sendInProgress = true; - PumpSendQueue_NoLock(); + PumpSendQueue_NoLock(ref completions, ref sendError); } - private void PumpSendQueue_NoLock() + private void PumpSendQueue_NoLock( + ref List completions, + ref Exception sendError) { while (true) { @@ -492,9 +561,9 @@ public class TcpSocket : ISocket var reply = currentSend?.Reply; currentSend = null; sendInProgress = false; - reply?.TriggerError(ex); - FailPendingSends_NoLock(ex); - CloseDueToSendError_NoLock(ex); + QueueSendCompletion(ref completions, reply, false, ex); + FailPendingSends_NoLock(ex, ref completions); + sendError = ex; return; } @@ -503,23 +572,35 @@ public class TcpSocket : ISocket return; } - if (!ProcessSendCompletion_NoLock(sendArgs)) + if (!ProcessSendCompletion_NoLock( + sendArgs, + ref completions, + ref sendError)) return; } } private void ProcessSend(SocketAsyncEventArgs e) { + List completions = null; + Exception sendError = null; + lock (sendLock) { - if (!ProcessSendCompletion_NoLock(e)) - return; - - PumpSendQueue_NoLock(); + if (ProcessSendCompletion_NoLock( + e, + ref completions, + ref sendError)) + PumpSendQueue_NoLock(ref completions, ref sendError); } + + FinishSendWork(completions, sendError); } - private bool ProcessSendCompletion_NoLock(SocketAsyncEventArgs e) + private bool ProcessSendCompletion_NoLock( + SocketAsyncEventArgs e, + ref List completions, + ref Exception sendError) { try { @@ -532,26 +613,27 @@ public class TcpSocket : ISocket if (e.SocketError != SocketError.Success) { var ex = new SocketException((int)e.SocketError); - currentSend.Reply?.TriggerError(ex); + QueueSendCompletion(ref completions, currentSend.Reply, false, ex); currentSend = null; sendInProgress = false; - FailPendingSends_NoLock(ex); - CloseDueToSendError_NoLock(ex); + FailPendingSends_NoLock(ex, ref completions); + sendError = ex; return false; } if (e.BytesTransferred <= 0) { var ex = new SocketException((int)SocketError.ConnectionReset); - currentSend.Reply?.TriggerError(ex); + QueueSendCompletion(ref completions, currentSend.Reply, false, ex); currentSend = null; sendInProgress = false; - FailPendingSends_NoLock(ex); - CloseDueToSendError_NoLock(ex); + FailPendingSends_NoLock(ex, ref completions); + sendError = ex; return false; } Interlocked.Add(ref bytesSent, e.BytesTransferred); + Interlocked.Add(ref pendingSendBytes, -e.BytesTransferred); currentSend.Offset += e.BytesTransferred; currentSend.Count -= e.BytesTransferred; @@ -561,42 +643,42 @@ public class TcpSocket : ISocket return true; } - currentSend.Reply?.Trigger(true); + QueueSendCompletion(ref completions, currentSend.Reply, true, null); currentSend = null; return true; } catch (Exception ex) { - currentSend?.Reply?.TriggerError(ex); + QueueSendCompletion(ref completions, currentSend?.Reply, false, ex); currentSend = null; sendInProgress = false; - FailPendingSends_NoLock(ex); - CloseDueToSendError_NoLock(ex); + FailPendingSends_NoLock(ex, ref completions); + sendError = ex; return false; } } - private void FailPendingSends_NoLock(Exception ex) + private void FailPendingSends_NoLock( + Exception ex, + ref List completions) { while (sendQueue.Count > 0) { var item = sendQueue.Dequeue(); - try - { - item.Reply?.TriggerError(ex); - } - catch { } + QueueSendCompletion(ref completions, item.Reply, false, ex); } + + Interlocked.Exchange(ref pendingSendBytes, 0); } - private void CloseDueToSendError_NoLock(Exception ex) + private bool CloseDueToSendError(Exception ex) { bool notify = false; lock (stateLock) { if (state == SocketState.Closed) - return; + return false; state = SocketState.Closed; notify = !closeNotified; @@ -609,6 +691,17 @@ public class TcpSocket : ISocket Global.Log(ex); + return notify; + } + + private void FinishSendWork( + List completions, + Exception sendError) + { + var notify = sendError != null && CloseDueToSendError(sendError); + + CompleteSendReplies(completions); + if (notify) { try { Receiver?.NetworkClose(this); } @@ -616,9 +709,44 @@ public class TcpSocket : ISocket } } + private static void QueueSendCompletion( + ref List completions, + AsyncReply reply, + bool result, + Exception error) + { + if (reply == null) + return; + + completions ??= new List(); + completions.Add(new SendReplyCompletion(reply, result, error)); + } + + private static void CompleteSendReplies(List completions) + { + if (completions == null) + return; + + foreach (var completion in completions) + { + try + { + if (completion.Error != null) + completion.Reply.TriggerError(completion.Error); + else + completion.Reply.Trigger(completion.Result); + } + catch (Exception ex) + { + Global.Log(ex); + } + } + } + private void SafeClose(Exception ex, bool notifyReceiver) { bool notify = false; + List completions = null; lock (stateLock) { @@ -636,25 +764,30 @@ public class TcpSocket : ISocket if (ex != null) { - try { currentSend?.Reply?.TriggerError(ex); } catch { } + QueueSendCompletion(ref completions, currentSend?.Reply, false, ex); currentSend = null; - FailPendingSends_NoLock(ex); + FailPendingSends_NoLock(ex, ref completions); } else { + QueueSendCompletion(ref completions, currentSend?.Reply, false, null); currentSend = null; while (sendQueue.Count > 0) { var item = sendQueue.Dequeue(); - try { item.Reply?.Trigger(false); } catch { } + QueueSendCompletion(ref completions, item.Reply, false, null); } } + + Interlocked.Exchange(ref pendingSendBytes, 0); } try { sock.Shutdown(SocketShutdown.Both); } catch { } try { sock.Close(); } catch { } try { sock.Dispose(); } catch { } + CompleteSendReplies(completions); + if (ex != null) Global.Log(ex); @@ -667,7 +800,19 @@ public class TcpSocket : ISocket private static void ValidateRange(byte[] message, int offset, int length) { - if (offset < 0 || length < 0 || offset + length > message.Length) + if (offset < 0 || length < 0 || offset > message.Length - length) throw new ArgumentOutOfRangeException(); } -} \ No newline at end of file + + private void EnsureSendCapacity_NoLock(int length) + { + var limit = Interlocked.Read(ref maximumPendingSendBytes); + var pending = Interlocked.Read(ref pendingSendBytes); + + if (length > limit - pending) + { + throw new InvalidOperationException( + $"The socket send queue exceeded its {limit}-byte limit."); + } + } +} diff --git a/Libraries/Esiur/Net/Sockets/WSocket.cs b/Libraries/Esiur/Net/Sockets/WSocket.cs index 89d2bd9..09d4b7b 100644 --- a/Libraries/Esiur/Net/Sockets/WSocket.cs +++ b/Libraries/Esiur/Net/Sockets/WSocket.cs @@ -45,9 +45,16 @@ public class WSocket : ISocket, INetworkReceiver ISocket sock; NetworkBuffer receiveNetworkBuffer = new NetworkBuffer(); NetworkBuffer sendNetworkBuffer = new NetworkBuffer(); + NetworkBuffer fragmentedMessageBuffer = new NetworkBuffer(); + + bool fragmentedMessage; + WebsocketPacket.WSOpcode fragmentedMessageOpcode; + ulong fragmentedMessageLength; object sendLock = new object(); bool held; + bool destroyed; + ulong maximumMessageLength = WebsocketPacket.DefaultMaximumPayloadLength; //public event ISocketReceiveEvent OnReceive; //public event ISocketConnectEvent OnConnect; @@ -79,11 +86,32 @@ public class WSocket : ISocket, INetworkReceiver public INetworkReceiver Receiver { get; set; } - public WSocket(ISocket socket) + /// Whether this endpoint receives client frames and sends server frames. + public bool IsServer { get; } + + /// Maximum payload accepted for one complete message. + public ulong MaximumMessageLength { + get => maximumMessageLength; + set + { + maximumMessageLength = value; + pkt_receive.MaximumPayloadLength = value; + } + } + + public WSocket(ISocket socket) + : this(socket, true) + { + } + + public WSocket(ISocket socket, bool isServer) + { + IsServer = isServer; pkt_send.FIN = true; - pkt_send.Mask = false; + pkt_send.Mask = !isServer; pkt_send.Opcode = WebsocketPacket.WSOpcode.BinaryFrame; + pkt_receive.ExpectedMask = isServer; sock = socket; sock.Receiver = this; @@ -111,8 +139,11 @@ public class WSocket : ISocket, INetworkReceiver public void Send(WebsocketPacket packet) { lock (sendLock) + { + PrepareOutboundPacket(packet); if (packet.Compose()) sock.Send(packet.Data); + } } public void Send(byte[] message) @@ -131,6 +162,7 @@ public class WSocket : ISocket, INetworkReceiver pkt_send.Message = message; + PrepareOutboundPacket(pkt_send); if (pkt_send.Compose()) sock?.Send(pkt_send.Data); @@ -154,6 +186,7 @@ public class WSocket : ISocket, INetworkReceiver pkt_send.Message = new byte[size]; Buffer.BlockCopy(message, offset, pkt_send.Message, 0, size); + PrepareOutboundPacket(pkt_send); if (pkt_send.Compose()) sock.Send(pkt_send.Data); } @@ -184,18 +217,36 @@ public class WSocket : ISocket, INetworkReceiver public void Destroy() { - Close(); - //OnClose = null; - //OnConnect = null; - //OnReceive = null; - receiveNetworkBuffer = null; - //sock.OnReceive -= Sock_OnReceive; - //sock.OnClose -= Sock_OnClose; - //sock.OnConnect -= Sock_OnConnect; - sock.Receiver = null; - sock = null; - OnDestroy?.Invoke(this); - OnDestroy = null; + ISocket socket; + DestroyedEvent onDestroy; + + lock (sendLock) + { + if (destroyed) + return; + + destroyed = true; + socket = sock; + onDestroy = OnDestroy; + OnDestroy = null; + } + + // Close can synchronously re-enter Destroy through NetworkClose. The + // guard above keeps that path idempotent while the captured socket is + // still valid for the outer cleanup. + try { socket?.Close(); } catch (Exception ex) { Global.Log(ex); } + + lock (sendLock) + { + if (socket != null && ReferenceEquals(socket.Receiver, this)) + socket.Receiver = null; + + sock = null; + receiveNetworkBuffer = null; + fragmentedMessageBuffer = null; + } + + onDestroy?.Invoke(this); } public AsyncReply AcceptAsync() @@ -205,8 +256,8 @@ public class WSocket : ISocket, INetworkReceiver public void Hold() { - //Console.WriteLine("WS Hold "); - held = true; + lock (sendLock) + held = true; } public void Unhold() @@ -225,6 +276,7 @@ public class WSocket : ISocket, INetworkReceiver totalSent += message.Length; pkt_send.Message = message; + PrepareOutboundPacket(pkt_send); if (pkt_send.Compose()) sock.Send(pkt_send.Data); @@ -248,7 +300,7 @@ public class WSocket : ISocket, INetworkReceiver public void NetworkClose(ISocket sender) { - Receiver?.NetworkClose(sender); + Receiver?.NetworkClose(this); } public void NetworkReceive(ISocket sender, NetworkBuffer buffer) @@ -267,7 +319,8 @@ public class WSocket : ISocket, INetworkReceiver if (msg == null) return; - var wsPacketLength = pkt_receive.Parse(msg, 0, (uint)msg.Length); + if (!TryParseFrame(msg, 0, out var wsPacketLength)) + return; if (wsPacketLength < 0) { @@ -289,45 +342,33 @@ public class WSocket : ISocket, INetworkReceiver var pkt_pong = new WebsocketPacket() { FIN = true, - Mask = false, + Mask = !IsServer, Opcode = WebsocketPacket.WSOpcode.Pong, Message = pkt_receive.Message }; - - offset += (uint)wsPacketLength; - Send(pkt_pong); } - else if (pkt_receive.Opcode == WebsocketPacket.WSOpcode.Pong) - { - offset += (uint)wsPacketLength; - } else if (pkt_receive.Opcode == WebsocketPacket.WSOpcode.BinaryFrame || pkt_receive.Opcode == WebsocketPacket.WSOpcode.TextFrame || pkt_receive.Opcode == WebsocketPacket.WSOpcode.ContinuationFrame) { totalReceived += pkt_receive.Message.Length; - //Console.WriteLine("RX " + pkt_receive.Message.Length + "/" + totalReceived);// + " " + DC.ToHex(message, 0, (uint)size)); - - receiveNetworkBuffer.Write(pkt_receive.Message); - offset += (uint)wsPacketLength; - - //Console.WriteLine("WS IN: " + pkt_receive.Opcode.ToString() + " " + pkt_receive.Message.Length + " | " + offset + " " + string.Join(" ", pkt_receive.Message));// DC.ToHex(pkt_receive.Message)); - - } - else - { - Global.Log("WSocket", LogType.Debug, "Unknown WS opcode:" + pkt_receive.Opcode); + if (!ProcessDataFrame(pkt_receive)) + return; } + // Pong frames need no further processing. All successfully handled frames + // advance by the same parsed length. + offset += (uint)wsPacketLength; + if (offset == msg.Length) { - - Receiver?.NetworkReceive(this, receiveNetworkBuffer); + DeliverReceivedData(); return; } - wsPacketLength = pkt_receive.Parse(msg, offset, (uint)msg.Length); + if (!TryParseFrame(msg, offset, out wsPacketLength)) + return; } if (wsPacketLength < 0) @@ -339,13 +380,128 @@ public class WSocket : ISocket, INetworkReceiver //Console.WriteLine("WS IN: " + receiveNetworkBuffer.Available); - Receiver?.NetworkReceive(this, receiveNetworkBuffer); + DeliverReceivedData(); if (buffer.Available > 0 && !buffer.Protected) NetworkReceive(this, buffer); } + private bool ProcessDataFrame(WebsocketPacket packet) + { + var payload = packet.Message ?? Array.Empty(); + + if (MaximumMessageLength > 0 && (ulong)payload.LongLength > MaximumMessageLength) + return RejectProtocol($"WebSocket message exceeds the {MaximumMessageLength}-byte limit."); + + if (packet.Opcode == WebsocketPacket.WSOpcode.TextFrame + || packet.Opcode == WebsocketPacket.WSOpcode.BinaryFrame) + { + if (fragmentedMessage) + return RejectProtocol("A new WebSocket data frame arrived before the fragmented message completed."); + + if (packet.FIN) + { + receiveNetworkBuffer.Write(payload); + return true; + } + + fragmentedMessage = true; + fragmentedMessageOpcode = packet.Opcode; + fragmentedMessageLength = 0; + fragmentedMessageBuffer.Read(); + return AppendFragment(payload); + } + + if (!fragmentedMessage) + return RejectProtocol("A WebSocket continuation frame arrived without an active fragmented message."); + + if (!AppendFragment(payload)) + return false; + + if (!packet.FIN) + return true; + + var message = fragmentedMessageBuffer.Read() ?? Array.Empty(); + try + { + if (fragmentedMessageOpcode == WebsocketPacket.WSOpcode.TextFrame) + WebsocketPacket.ValidateTextPayload(message); + } + catch (InvalidDataException exception) + { + return RejectProtocol(exception.Message); + } + + receiveNetworkBuffer.Write(message); + ResetFragmentedMessage(); + return true; + } + + private bool AppendFragment(byte[] payload) + { + var nextLength = fragmentedMessageLength + (ulong)payload.LongLength; + if (nextLength < fragmentedMessageLength + || nextLength > int.MaxValue + || (MaximumMessageLength > 0 && nextLength > MaximumMessageLength)) + return RejectProtocol($"WebSocket fragmented message exceeds the {MaximumMessageLength}-byte limit."); + + fragmentedMessageBuffer.Write(payload); + fragmentedMessageLength = nextLength; + return true; + } + + private void DeliverReceivedData() + { + if (receiveNetworkBuffer != null && receiveNetworkBuffer.Available > 0) + Receiver?.NetworkReceive(this, receiveNetworkBuffer); + } + + private bool RejectProtocol(string message) + { + Global.Log("WSocket", LogType.Warning, message); + ResetFragmentedMessage(); + Close(); + return false; + } + + private void ResetFragmentedMessage() + { + fragmentedMessage = false; + fragmentedMessageLength = 0; + fragmentedMessageOpcode = default; + fragmentedMessageBuffer?.Read(); + } + + private void PrepareOutboundPacket(WebsocketPacket packet) + { + packet.Mask = !IsServer; + + // RFC 6455 requires a fresh unpredictable masking key for every client + // frame. pkt_send is intentionally reused, so discard its previous key. + if (packet.Mask) + packet.MaskKey = null; + } + + private bool TryParseFrame(byte[] message, uint offset, out long packetLength) + { + try + { + packetLength = pkt_receive.Parse(message, offset, (uint)message.Length); + return true; + } + catch (Exception exception) when ( + exception is InvalidDataException || + exception is ParserLimitException || + exception is ArgumentException) + { + Global.Log(exception); + packetLength = 0; + Close(); + return false; + } + } + public void NetworkConnect(ISocket sender) { diff --git a/Libraries/Esiur/Protocol/EpConnection.cs b/Libraries/Esiur/Protocol/EpConnection.cs index 89a6fcb..ba87e9b 100644 --- a/Libraries/Esiur/Protocol/EpConnection.cs +++ b/Libraries/Esiur/Protocol/EpConnection.cs @@ -1053,13 +1053,38 @@ public partial class EpConnection : NetworkConnection, IStore // check if the request through Websockets if (_initialPacket) { + var available = ends - offset; + var matchesGetPrefix = available > 0 && msg[offset] == 'G' && + (available < 2 || msg[offset + 1] == 'E') && + (available < 3 || msg[offset + 2] == 'T'); + + if (matchesGetPrefix && available < 3) + { + data.HoldFor(msg, offset, available, 3); + return ends; + } + _initialPacket = false; - if (msg.Length > 3 && Encoding.Default.GetString(msg, 0, 3) == "GET") + if (available >= 3 && + msg[offset] == 'G' && msg[offset + 1] == 'E' && msg[offset + 2] == 'T') { // Parse with http packet var req = new HttpRequestPacket(); - var pSize = req.Parse(msg, 0, (uint)msg.Length); + long pSize; + try + { + pSize = req.Parse(msg, offset, ends); + } + catch (Exception exception) when ( + exception is InvalidDataException || + exception is ParserLimitException || + exception is ArgumentException) + { + Global.Log(exception); + pSize = 0; + } + if (pSize > 0) { // check for WS upgrade @@ -1090,10 +1115,24 @@ public partial class EpConnection : NetworkConnection, IStore //@TODO: kill the connection } } + else if (pSize < 0) + { + _initialPacket = true; + var requiredLength = (ulong)available + (ulong)(-pSize); + if (requiredLength > uint.MaxValue) + throw new ParserLimitException("HTTP upgrade request is too large."); + + data.HoldFor(msg, offset, available, (uint)requiredLength); + return ends; + } else { - // packet incomplete - return (uint)pSize; + var res = new HttpResponsePacket + { + Number = HttpResponseCode.BadRequest + }; + res.Compose(HttpComposeOption.AllCalculateLength); + Send(res.Data); } // switching completed @@ -1130,7 +1169,9 @@ public partial class EpConnection : NetworkConnection, IStore if (_authPacket.Tdu != null) { - remoteHeaders = Codec.ParseIndexedType(_authPacket.Tdu.Value, null); + remoteHeaders = Codec.ParseIndexedType( + _authPacket.Tdu.Value, + _serverWarehouse); remoteAuthData = remoteHeaders.AuthenticationData; remoteHeaders.AuthenticationData = null; } @@ -1162,7 +1203,17 @@ public partial class EpConnection : NetworkConnection, IStore return offset; } - if (_session.RemoteHeaders.AuthenticationProtocol == null) + var authenticationProtocol = _session.RemoteHeaders.AuthenticationProtocol; + var allowedAuthenticationProviders = + Server?.AllowedAuthenticationProviders ?? Array.Empty(); + var provider = !string.IsNullOrWhiteSpace(authenticationProtocol) + && allowedAuthenticationProviders.Contains( + authenticationProtocol, + StringComparer.Ordinal) + ? _serverWarehouse?.TryGetAuthenticationProvider(authenticationProtocol) + : null; + + if (provider == null) { SendAuthHeaders(EpAuthPacketMethod.NotSupported, localHeaders); _invalidCredentials = true; @@ -1170,8 +1221,6 @@ public partial class EpConnection : NetworkConnection, IStore return offset; } - var provider = _serverWarehouse.GetAuthenticationProvider(_session.RemoteHeaders.AuthenticationProtocol); - var handler = provider.CreateAuthenticationHandler(new AuthenticationContext() { Direction = AuthenticationDirection.Responder, diff --git a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs index 6309e9c..67b9980 100644 --- a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs +++ b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs @@ -56,6 +56,20 @@ partial class EpConnection internal bool IsRateControlBlocked => Volatile.Read(ref _rateControlBlocked) != 0; + IEnumerable ResolveTypeDefManagers(TypeDef typeDef) + { + var definedType = typeDef switch + { + LocalTypeDef localTypeDef => localTypeDef.DefinedType, + RemoteTypeDef remoteTypeDef => remoteTypeDef.ProxyType, + _ => null + }; + + return definedType == null + ? Array.Empty() + : Instance.Warehouse.ResolveResourceManagers(definedType, null); + } + bool TryApplyManagers( MemberDef member, IResource? resource, @@ -1359,7 +1373,7 @@ partial class EpConnection var classNames = (string[])value; - var typeDefs = new List(); + var typeDefs = new List(); foreach (var className in classNames) { @@ -1372,7 +1386,7 @@ partial class EpConnection if (typeDefs.Count > 0) { var managers = typeDefs - .SelectMany(typeDef => Instance.Warehouse.ResolveResourceManagers(typeDef.DefinedType, null)); + .SelectMany(ResolveTypeDefManagers); if (!TryApplyManagers( null, @@ -1405,7 +1419,7 @@ partial class EpConnection if (t != null) { - var managers = Instance.Warehouse.ResolveResourceManagers(t.DefinedType, null); + var managers = ResolveTypeDefManagers(t); if (!TryApplyManagers( null, null, @@ -1670,7 +1684,6 @@ partial class EpConnection ErrorType.Management, ExceptionCode.InvokeDenied, out var managerDelay, - managers: Instance.Warehouse.ResolveResourceManagers(typeDef.DefinedType, null), supportsDelay: true)) return; diff --git a/Libraries/Esiur/Protocol/EpServer.cs b/Libraries/Esiur/Protocol/EpServer.cs index 7da1791..1b25a0a 100644 --- a/Libraries/Esiur/Protocol/EpServer.cs +++ b/Libraries/Esiur/Protocol/EpServer.cs @@ -56,8 +56,13 @@ public class EpServer : NetworkServer, IResource set; } - //[Attribute] - public string[] AllowedAuthenticationProviders { get; set; } + /// + /// Authentication provider protocol names that incoming connections may negotiate. + /// Providers must also be registered with the server Warehouse. An empty list denies + /// authenticated negotiation; anonymous access remains controlled separately by + /// . + /// + public string[] AllowedAuthenticationProviders { get; set; } = Array.Empty(); /// /// Encryption provider protocol names that incoming connections may negotiate. @@ -124,12 +129,6 @@ public class EpServer : NetworkServer, IResource | ExceptionLevel.Message | ExceptionLevel.Trace; - - - public void RegisterAuthenticationHandler(string[] domains, AuthenticationMode[] modes) where T : class, IAuthenticationHandler - { - - } public Instance Instance { diff --git a/Libraries/Esiur/Proxy/TypeDefGenerator.cs b/Libraries/Esiur/Proxy/TypeDefGenerator.cs index 97ce3c3..3b973fb 100644 --- a/Libraries/Esiur/Proxy/TypeDefGenerator.cs +++ b/Libraries/Esiur/Proxy/TypeDefGenerator.cs @@ -14,6 +14,7 @@ namespace Esiur.Proxy; public static class TypeDefGenerator { + private const string GeneratedFilesManifestName = ".esiur-generated-files"; internal static Regex urlRegex = new Regex(@"^(?:([\S]*)://([^/]*)/?)"); //public static string ToLiteral(string valueTextForCompiler) @@ -238,16 +239,25 @@ public static class TypeDefGenerator // no longer needed Warehouse.Default.Remove(con); + var generatedFiles = typeDefs + .Where(td => td.Kind == TypeDefKind.Resource + || td.Kind == TypeDefKind.Record + || td.Kind == TypeDefKind.Enum) + .Select(td => GetGeneratedFileName(td.Name)) + .Append("Initialization.g.cs") + .ToArray(); + + if (generatedFiles.Distinct(StringComparer.OrdinalIgnoreCase).Count() + != generatedFiles.Length) + throw new InvalidDataException("The remote type definitions produce duplicate filenames."); + var dstDir = new DirectoryInfo(tempDir ? Path.GetTempPath() + Path.DirectorySeparatorChar + Misc.Global.GenerateCode(20) + Path.DirectorySeparatorChar + dir : dir); if (!dstDir.Exists) dstDir.Create(); else - { - foreach (FileInfo file in dstDir.GetFiles()) - file.Delete(); - } + DeletePreviouslyGeneratedFiles(dstDir); // make sources foreach (var td in typeDefs) @@ -255,17 +265,23 @@ public static class TypeDefGenerator if (td.Kind == TypeDefKind.Resource) { var source = GenerateClass(td, typeDefs, asyncSetters); - File.WriteAllText(dstDir.FullName + Path.DirectorySeparatorChar + td.Name + ".g.cs", source); + File.WriteAllText( + Path.Combine(dstDir.FullName, GetGeneratedFileName(td.Name)), + source); } else if (td.Kind == TypeDefKind.Record) { var source = GenerateRecord(td, typeDefs); - File.WriteAllText(dstDir.FullName + Path.DirectorySeparatorChar + td.Name + ".g.cs", source); + File.WriteAllText( + Path.Combine(dstDir.FullName, GetGeneratedFileName(td.Name)), + source); } else if (td.Kind == TypeDefKind.Enum) { var source = GenerateEnum(td, typeDefs); - File.WriteAllText(dstDir.FullName + Path.DirectorySeparatorChar + td.Name + ".g.cs", source); + File.WriteAllText( + Path.Combine(dstDir.FullName, GetGeneratedFileName(td.Name)), + source); } } @@ -301,15 +317,52 @@ public static class TypeDefGenerator \r\n } \r\n}"; - File.WriteAllText(dstDir.FullName + Path.DirectorySeparatorChar + "Initialization.g.cs", typesFile); + File.WriteAllText(Path.Combine(dstDir.FullName, "Initialization.g.cs"), typesFile); + File.WriteAllLines( + Path.Combine(dstDir.FullName, GeneratedFilesManifestName), + generatedFiles); return dstDir.FullName; } - catch (Exception ex) + catch { - throw ex; + throw; + } + } + + internal static string GetGeneratedFileName(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + throw new InvalidDataException("A remote type has no name."); + + var fileName = typeName + ".g.cs"; + if (!string.Equals(Path.GetFileName(fileName), fileName, StringComparison.Ordinal) + || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new InvalidDataException( + $"Remote type name '{typeName}' cannot be used as a safe generated filename."); + + return fileName; + } + + internal static void DeletePreviouslyGeneratedFiles(DirectoryInfo destination) + { + var manifestPath = Path.Combine(destination.FullName, GeneratedFilesManifestName); + if (!File.Exists(manifestPath)) + return; + + foreach (var entry in File.ReadAllLines(manifestPath)) + { + var fileName = entry.Trim(); + if (!fileName.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase) + || !string.Equals(Path.GetFileName(fileName), fileName, StringComparison.Ordinal) + || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + continue; + + var generatedPath = Path.Combine(destination.FullName, fileName); + if (File.Exists(generatedPath)) + File.Delete(generatedPath); } } diff --git a/Libraries/Esiur/Resource/Warehouse.cs b/Libraries/Esiur/Resource/Warehouse.cs index 60ee576..1f152d0 100644 --- a/Libraries/Esiur/Resource/Warehouse.cs +++ b/Libraries/Esiur/Resource/Warehouse.cs @@ -847,10 +847,10 @@ public class Warehouse else return (T)store; } - catch (Exception ex) + catch { Remove(store); - throw ex; + throw; } } } @@ -946,10 +946,10 @@ public class Warehouse StoreConnected?.Invoke(resource as IStore); } - catch (Exception ex) + catch { Remove(resource); - throw ex; + throw; } return resource; diff --git a/Libraries/Esiur/Resource/WarehouseConfiguration.cs b/Libraries/Esiur/Resource/WarehouseConfiguration.cs index f041510..7f3f693 100644 --- a/Libraries/Esiur/Resource/WarehouseConfiguration.cs +++ b/Libraries/Esiur/Resource/WarehouseConfiguration.cs @@ -29,6 +29,9 @@ public sealed class EncryptionConfiguration /// public sealed class ParserConfiguration { + /// Default maximum nesting depth for TRU type metadata. + public const int DefaultMaximumTypeMetadataDepth = 64; + /// Maximum declared TDU payload retained for one packet. public uint MaximumPacketSize { get; set; } = 8 * 1024 * 1024; @@ -37,6 +40,12 @@ public sealed class ParserConfiguration /// Maximum number of values decoded into one collection. public int MaximumCollectionItems { get; set; } = 65_536; + + /// + /// Maximum number of nested TRU type-metadata nodes, including the root node. + /// A value of zero disables the limit. + /// + public int MaximumTypeMetadataDepth { get; set; } = DefaultMaximumTypeMetadataDepth; } /// diff --git a/Libraries/Esiur/Security/Permissions/UserPermissionsManager.cs b/Libraries/Esiur/Security/Permissions/UserPermissionsManager.cs index e2c4122..3d4fc61 100644 --- a/Libraries/Esiur/Security/Permissions/UserPermissionsManager.cs +++ b/Libraries/Esiur/Security/Permissions/UserPermissionsManager.cs @@ -35,6 +35,23 @@ namespace Esiur.Security.Permissions; public class UserPermissionsManager : IPermissionsManager { + private static readonly IReadOnlyDictionary ResourcePermissionKeys = + new Dictionary + { + [ActionType.Attach] = "_attach", + [ActionType.Detach] = "_detach", + [ActionType.Delete] = "_delete", + [ActionType.CreateResource] = "_create_resource", + [ActionType.InquireAttributes] = "_get_attributes", + [ActionType.UpdateAttributes] = "_set_attributes", + [ActionType.AddChild] = "_add_child", + [ActionType.RemoveChild] = "_remove_child", + [ActionType.AddParent] = "_add_parent", + [ActionType.RemoveParent] = "_remove_parent", + [ActionType.Rename] = "_rename", + [ActionType.ViewTypeDef] = "_view_typedef" + }; + IResource resource; Map settings; @@ -42,70 +59,39 @@ public class UserPermissionsManager : IPermissionsManager public Ruling Applicable(IResource resource, Session session, ActionType action, MemberDef member, object inquirer) { - Map userPermissions = null; - - if (settings.ContainsKey(session.RemoteIdentity)) - userPermissions = settings[session.RemoteIdentity] as Map; - else if (settings.ContainsKey("public")) - userPermissions = settings["public"] as Map; - else + if (settings == null || session == null) return Ruling.Denied; - if (action == ActionType.Attach)// || action == ActionType.Delete) - { - if ((string)userPermissions["_attach"] != "yes") - return Ruling.Denied; - } - else if (action == ActionType.Delete) - { - if ((string)userPermissions["_delete"] != "yes") - return Ruling.Denied; - } - else if (action == ActionType.InquireAttributes) - { - if ((string)userPermissions["_get_attributes"] == "yes") - return Ruling.Denied; - } - else if (action == ActionType.UpdateAttributes) - { - if ((string)userPermissions["_set_attributes"] != "yes") - return Ruling.Denied; - } - else if (action == ActionType.AddChild) - { - if ((string)userPermissions["_add_child"] != "yes") - return Ruling.Denied; - } - else if (action == ActionType.RemoveChild) - { - if ((string)userPermissions["_remove_child"] != "yes") - return Ruling.Denied; - } - else if (action == ActionType.AddParent) - { - if ((string)userPermissions["_add_parent"] != "yes") - return Ruling.Denied; - } - else if (action == ActionType.RemoveParent) - { - if ((string)userPermissions["_remove_parent"] != "yes") - return Ruling.Denied; - } - else if (action == ActionType.Rename) - { - if ((string)userPermissions["_rename"] != "yes") - return Ruling.Denied; - } - else if (userPermissions.ContainsKey(member?.Name)) - { - Map methodPermissions = userPermissions[member.Name] as Map; - if ((string)methodPermissions[action.ToString()] != "yes") - return Ruling.Denied; - } + Map userPermissions = null; + if (!string.IsNullOrEmpty(session.RemoteIdentity) + && settings.TryGetValue(session.RemoteIdentity, out var identityPermissions)) + userPermissions = identityPermissions as Map; + else if (settings.TryGetValue("public", out var publicPermissions)) + userPermissions = publicPermissions as Map; - return Ruling.DontCare; + if (userPermissions == null) + return Ruling.Denied; + + if (ResourcePermissionKeys.TryGetValue(action, out var resourcePermissionKey)) + return IsAllowed(userPermissions, resourcePermissionKey); + + // Member-level access is fail closed: an absent member/action entry must + // never fall through to Warehouse's compatibility defaults. + if (member == null + || !userPermissions.TryGetValue(member.Name, out var rawMemberPermissions) + || rawMemberPermissions is not Map memberPermissions) + return Ruling.Denied; + + return IsAllowed(memberPermissions, action.ToString()); } + private static Ruling IsAllowed(Map permissions, string key) + => permissions.TryGetValue(key, out var value) + && value is string text + && string.Equals(text, "yes", StringComparison.Ordinal) + ? Ruling.Allowed + : Ruling.Denied; + public UserPermissionsManager() { diff --git a/Stores/Esiur.Stores.EntityCore/Esiur.Stores.EntityCore.csproj b/Stores/Esiur.Stores.EntityCore/Esiur.Stores.EntityCore.csproj index 259ee3e..d289613 100644 --- a/Stores/Esiur.Stores.EntityCore/Esiur.Stores.EntityCore.csproj +++ b/Stores/Esiur.Stores.EntityCore/Esiur.Stores.EntityCore.csproj @@ -24,7 +24,6 @@ - diff --git a/Stores/Esiur.Stores.MongoDB/Esiur.Stores.MongoDB.csproj b/Stores/Esiur.Stores.MongoDB/Esiur.Stores.MongoDB.csproj index da08de0..ecd4c17 100644 --- a/Stores/Esiur.Stores.MongoDB/Esiur.Stores.MongoDB.csproj +++ b/Stores/Esiur.Stores.MongoDB/Esiur.Stores.MongoDB.csproj @@ -19,10 +19,15 @@ + + + + - \ No newline at end of file + diff --git a/Tests/RPC/Client/Esiur.Tests.RPC.Client.csproj b/Tests/RPC/Client/Esiur.Tests.RPC.Client.csproj index 90e812d..e594806 100644 --- a/Tests/RPC/Client/Esiur.Tests.RPC.Client.csproj +++ b/Tests/RPC/Client/Esiur.Tests.RPC.Client.csproj @@ -15,14 +15,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - diff --git a/Tests/RPC/SignalR/Esiur.Tests.RPC.SignalRServer.csproj b/Tests/RPC/SignalR/Esiur.Tests.RPC.SignalRServer.csproj index ea8d545..bdfe152 100644 --- a/Tests/RPC/SignalR/Esiur.Tests.RPC.SignalRServer.csproj +++ b/Tests/RPC/SignalR/Esiur.Tests.RPC.SignalRServer.csproj @@ -7,8 +7,9 @@ - - + + + diff --git a/Tests/RPC/Thrift/Esiur.Tests.RPC.ThriftServer.csproj b/Tests/RPC/Thrift/Esiur.Tests.RPC.ThriftServer.csproj index 920b374..750a847 100644 --- a/Tests/RPC/Thrift/Esiur.Tests.RPC.ThriftServer.csproj +++ b/Tests/RPC/Thrift/Esiur.Tests.RPC.ThriftServer.csproj @@ -9,7 +9,6 @@ - diff --git a/Tests/Serialization/ComplexObject/Esiur.Tests.ComplexModel.csproj b/Tests/Serialization/ComplexObject/Esiur.Tests.ComplexModel.csproj index 6481bf0..3e357be 100644 --- a/Tests/Serialization/ComplexObject/Esiur.Tests.ComplexModel.csproj +++ b/Tests/Serialization/ComplexObject/Esiur.Tests.ComplexModel.csproj @@ -12,7 +12,8 @@ - + + diff --git a/Tests/Serialization/Gvwie/Esiur.Tests.Gvwie.csproj b/Tests/Serialization/Gvwie/Esiur.Tests.Gvwie.csproj index 6481bf0..3e357be 100644 --- a/Tests/Serialization/Gvwie/Esiur.Tests.Gvwie.csproj +++ b/Tests/Serialization/Gvwie/Esiur.Tests.Gvwie.csproj @@ -12,7 +12,8 @@ - + + diff --git a/Tests/Unit/AsyncBagTests.cs b/Tests/Unit/AsyncBagTests.cs new file mode 100644 index 0000000..bd2edb6 --- /dev/null +++ b/Tests/Unit/AsyncBagTests.cs @@ -0,0 +1,37 @@ +using Esiur.Core; + +namespace Esiur.Tests.Unit; + +public class AsyncBagTests +{ + [Fact] + public void Seal_CollectsConcurrentReplyCompletionsExactlyOnce() + { + const int count = 2_000; + var replies = Enumerable.Range(0, count) + .Select(_ => new AsyncReply()) + .ToArray(); + var bag = new AsyncBag(); + foreach (var reply in replies) + bag.Add(reply); + + bag.Seal(); + Parallel.For(0, replies.Length, index => replies[index].Trigger(index)); + + Assert.Equal(Enumerable.Range(0, count), bag.Wait(5_000)); + } + + [Fact] + public void AddBag_UsesAStableSnapshot() + { + var source = new AsyncBag(); + source.Add(1); + source.Add(new AsyncReply(2)); + + var destination = new AsyncBag(); + destination.AddBag(source); + destination.Seal(); + + Assert.Equal(new[] { 1, 2 }, destination.Wait(1_000)); + } +} diff --git a/Tests/Unit/AsyncQueueTests.cs b/Tests/Unit/AsyncQueueTests.cs index f345fcc..1fc5ab9 100644 --- a/Tests/Unit/AsyncQueueTests.cs +++ b/Tests/Unit/AsyncQueueTests.cs @@ -94,4 +94,27 @@ public class AsyncQueueTests Assert.Equal(Enumerable.Range(1, itemCount), sequences.OrderBy(x => x)); } + + [Fact] + public void LargeReadyBatch_IsDeliveredInOrder() + { + const int itemCount = 5_000; + var queue = new AsyncQueue(); + var replies = Enumerable.Range(0, itemCount) + .Select(_ => new AsyncReply()) + .ToArray(); + var delivered = new List(itemCount); + queue.Then(delivered.Add); + + foreach (var reply in replies) + queue.Add(reply); + + // Keep the head pending while every later item becomes ready, then release + // the whole batch at once. This exercises the bulk-removal path. + for (var i = 1; i < replies.Length; i++) + replies[i].Trigger(i); + replies[0].Trigger(0); + + Assert.Equal(Enumerable.Range(0, itemCount), delivered); + } } diff --git a/Tests/Unit/AsyncReplyConcurrencyTests.cs b/Tests/Unit/AsyncReplyConcurrencyTests.cs new file mode 100644 index 0000000..a87905c --- /dev/null +++ b/Tests/Unit/AsyncReplyConcurrencyTests.cs @@ -0,0 +1,280 @@ +using Esiur.Core; +using Esiur.Data.Types; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Esiur.Tests.Unit; + +public class AsyncReplyConcurrencyTests +{ + [Fact] + public void CompletedRepliesKeepCallbackAndWaiterStorageLazy() + { + var reply = new AsyncReply(42); + var awaiter = reply.GetAwaiter(); + + Assert.True(reply.Ready); + Assert.True(awaiter.IsCompleted); + Assert.Equal(42, awaiter.GetResult()); + Assert.Null(GetBaseField(reply, "callbacks")); + Assert.Null(GetBaseField(reply, "errorCallbacks")); + Assert.Null(GetBaseField(reply, "completionEvent")); + } + + [Fact] + public void CompletedReplyAllocationStaysWithinObjectAndLockBudget() + { + const int count = 4096; + const int maximumBytesPerReply = 224; + var replies = new AsyncReply[count]; + + // Warm up constructors and property access before measuring this thread. + for (var i = 0; i < 32; i++) + _ = new AsyncReply(i).Ready; + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < replies.Length; i++) + replies[i] = new AsyncReply(i); + + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + GC.KeepAlive(replies); + + Assert.True( + allocated < count * (long)maximumBytesPerReply, + $"Completed replies allocated {allocated / (double)count:N1} bytes each."); + } + + [Fact] + public void TriggerErrorWithoutAHandlerIsRetainedAndObservedByWaiters() + { + var reply = new AsyncReply(); + var source = new InvalidOperationException("synchronous failure"); + + var triggerException = Record.Exception(() => reply.TriggerError(source)); + + Assert.Null(triggerException); + Assert.False(reply.Ready); + Assert.True(reply.Failed); + var stored = Assert.IsType(reply.Exception); + Assert.Same(source, stored.InnerException); + Assert.Same(stored, Assert.Throws(() => reply.Wait())); + Assert.Same(stored, Assert.Throws(() => reply.Wait(10))); + + AsyncException? observed = null; + reply.Error(error => observed = error); + Assert.Same(stored, observed); + } + + [Fact] + public async Task CustomAsyncBuilderRetainsAnErrorThrownBeforeTheFirstAwait() + { + AsyncReply? reply = null; + + var creationException = Record.Exception(() => reply = FailBeforeFirstAwait(fail: true)); + + Assert.Null(creationException); + Assert.NotNull(reply); + Assert.True(reply!.Failed); + await Assert.ThrowsAsync(async () => _ = await reply); + } + + [Fact] + public async Task CustomAsyncBuilderPreservesAsyncExceptionAfterSuspension() + { + var gate = new AsyncReply(); + var reply = FailAfterFirstAwait(gate); + var observation = AwaitAsTask(reply); + + gate.Trigger(null!); + + var exception = await Assert.ThrowsAsync(() => observation); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task CustomAsyncBuilderPublishesSuspensionBeforeInlineContinuation() + { + AsyncReply? reply = null; + + var creationException = Record.Exception(() => reply = FailAfterInlineCompletion()); + + Assert.Null(creationException); + var exception = await Assert.ThrowsAsync(async () => _ = await reply!); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void StreamErrorsReachTheBaseTerminalStateWithoutAnErrorHandler() + { + static AsyncReply CompletedControl() => new AsyncReply((object)null!); + + var stream = new AsyncStreamReply( + StreamMode.Push, + CompletedControl, + CompletedControl, + CompletedControl, + CompletedControl); + var expected = new AsyncException(ErrorType.Exception, 0, "stream failed"); + + stream.TriggerError(expected); + + Assert.True(stream.Failed); + Assert.Same(expected, stream.Exception); + Assert.Same(expected, Assert.Throws(() => stream.Wait())); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TerminalStateWakesEverySynchronousWaiter(bool fail) + { + const int waiterCount = 8; + var reply = new AsyncReply(); + var results = new int[waiterCount]; + var errors = new Exception?[waiterCount]; + var threads = new Thread[waiterCount]; + + for (var i = 0; i < threads.Length; i++) + { + var index = i; + threads[i] = new Thread(() => + { + try + { + results[index] = reply.Wait(); + } + catch (Exception exception) + { + errors[index] = exception; + } + }) + { + IsBackground = true + }; + threads[i].Start(); + } + + var allWaiting = SpinWait.SpinUntil( + () => threads.All(thread => + (thread.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0), + millisecondsTimeout: 5000); + + if (fail) + reply.TriggerError(new InvalidOperationException("failed")); + else + reply.Trigger(73); + + var allJoined = threads.Select(thread => thread.Join(2000)).ToArray(); + + Assert.True(allWaiting, "Not all test threads reached the blocking wait."); + Assert.All(allJoined, joined => Assert.True(joined, "A synchronous waiter was not released.")); + + if (fail) + Assert.All(errors, error => Assert.IsType(error)); + else + { + Assert.All(results, value => Assert.Equal(73, value)); + Assert.All(errors, Assert.Null); + } + } + + [Fact] + public void CompletionCallbacksRunOutsideTheReplyLock() + { + var reply = new AsyncReply(); + reply.Then(_ => + { + var concurrentRegistration = Task.Run(() => reply.Then(__ => { })); + if (!concurrentRegistration.Wait(2000)) + throw new TimeoutException("Concurrent callback registration was blocked by the completion callback."); + }); + + Assert.Null(Record.Exception(() => reply.Trigger(1))); + } + + [Fact] + public void ErrorRegistrationRacingWithFailureInvokesExactlyOnce() + { + for (var iteration = 0; iteration < 500; iteration++) + { + var reply = new AsyncReply(); + var expected = new AsyncException(ErrorType.Exception, 0, "failed"); + AsyncException? observed = null; + var calls = 0; + + Parallel.Invoke( + () => reply.Error(error => + { + observed = error; + Interlocked.Increment(ref calls); + }), + () => reply.TriggerError(expected)); + + Assert.Equal(1, Volatile.Read(ref calls)); + Assert.Same(expected, observed); + } + } + + [Fact] + public void AwaiterContinuationIsNotLostDuringCompletionRace() + { + for (var iteration = 0; iteration < 500; iteration++) + { + var reply = new AsyncReply(); + var awaiter = reply.GetAwaiter(); + using var continued = new ManualResetEventSlim(false); + + Parallel.Invoke( + () => awaiter.OnCompleted(continued.Set), + () => reply.Trigger(iteration)); + + Assert.True(continued.Wait(2000), "The awaiter continuation was lost."); + Assert.True(awaiter.IsCompleted); + Assert.Equal(iteration, awaiter.GetResult()); + } + } + + private static object? GetBaseField(AsyncReply reply, string name) + => typeof(AsyncReply) + .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetValue(reply); + + private static async AsyncReply FailBeforeFirstAwait(bool fail) + { + if (fail) + throw new InvalidOperationException("failed before await"); + + await Task.Yield(); + return 1; + } + + private static async AsyncReply FailAfterFirstAwait(AsyncReply gate) + { + await gate; + throw new InvalidOperationException("failed after await"); + } + + private static async Task AwaitAsTask(AsyncReply reply) => await reply; + + private static async AsyncReply FailAfterInlineCompletion() + { + await new InlineCompletion(); + throw new InvalidOperationException("failed after inline continuation"); + } + + private readonly struct InlineCompletion + { + public Awaiter GetAwaiter() => new Awaiter(); + + public readonly struct Awaiter : INotifyCompletion + { + public bool IsCompleted => false; + + public void GetResult() + { + } + + public void OnCompleted(Action continuation) => continuation(); + } + } +} diff --git a/Tests/Unit/HttpHardeningTests.cs b/Tests/Unit/HttpHardeningTests.cs new file mode 100644 index 0000000..913a9b4 --- /dev/null +++ b/Tests/Unit/HttpHardeningTests.cs @@ -0,0 +1,260 @@ +using System.Collections; +using System.Reflection; +using System.Text; +using Esiur.Data; +using Esiur.Net.Http; +using Esiur.Net.Packets.Http; + +namespace Esiur.Tests.Unit; + +public class HttpHardeningTests +{ + [Theory] + [InlineData("Content-Length: 1\r\nContent-Length: 1\r\n")] + [InlineData("Content-Length: 1\r\nContent-Length: 2\r\n")] + public void Request_RejectsDuplicateContentLength(string framingHeaders) + { + var data = RequestBytes("POST", framingHeaders, "x"); + + Assert.Throws(() => + new HttpRequestPacket().Parse(data, 0, (uint)data.Length)); + } + + [Theory] + [InlineData("Transfer-Encoding: chunked\r\n")] + [InlineData("Transfer-Encoding: identity\r\nContent-Length: 1\r\n")] + public void Request_RejectsUnsupportedTransferEncoding(string framingHeaders) + { + var data = RequestBytes("POST", framingHeaders, "x"); + + Assert.Throws(() => + new HttpRequestPacket().Parse(data, 0, (uint)data.Length)); + } + + [Theory] + [InlineData("PUT")] + [InlineData("GET")] + [InlineData("DELETE")] + public void Request_ConsumesDeclaredBodiesForEveryMethod(string method) + { + var data = RequestBytes( + method, + "Content-Type: application/octet-stream\r\nContent-Length: 3\r\n", + "abc"); + var packet = new HttpRequestPacket(); + + Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length)); + Assert.Equal("abc", Encoding.ASCII.GetString(packet.Message)); + } + + [Fact] + public void Request_AllowsPostWithoutABodyOrContentLength() + { + var data = RequestBytes("POST", string.Empty, string.Empty); + var packet = new HttpRequestPacket(); + + Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length)); + Assert.Empty(packet.PostForms); + } + + [Fact] + public void Request_EnforcesHeaderCountBeforeSplittingHeaders() + { + var data = RequestBytes("GET", "X-One: 1\r\nX-Two: 2\r\n", string.Empty); + var packet = new HttpRequestPacket { MaximumHeaderCount = 1 }; + + Assert.Throws(() => + packet.Parse(data, 0, (uint)data.Length)); + } + + [Fact] + public void UrlEncodedForm_EnforcesFieldKeyAndValueLimits() + { + AssertFormLimit("a=1&b=2", packet => packet.MaximumFormFields = 1); + AssertFormLimit("long=1", packet => packet.MaximumFormKeyLength = 3); + AssertFormLimit("a=long", packet => packet.MaximumFormValueLength = 3); + } + + [Fact] + public void UrlEncodedForm_CombinesUnkeyedFieldsWithinTheConfiguredBound() + { + const string body = "first&second&third"; + var data = RequestBytes( + "POST", + $"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {body.Length}\r\n", + body); + var packet = new HttpRequestPacket { MaximumFormValueLength = body.Length }; + + Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length)); + Assert.Equal(body, packet.PostForms["unknown"]); + + packet = new HttpRequestPacket { MaximumFormValueLength = body.Length - 1 }; + Assert.Throws(() => + packet.Parse(data, 0, (uint)data.Length)); + } + + [Fact] + public void MultipartForm_EnforcesPartLength() + { + const string boundary = "test-boundary"; + const string body = + "--test-boundary\r\n" + + "Content-Disposition: form-data; name=\"value\"\r\n\r\n" + + "payload\r\n" + + "--test-boundary--\r\n"; + var data = RequestBytes( + "POST", + $"Content-Type: multipart/form-data; boundary=\"{boundary}\"\r\n" + + $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n", + body); + var packet = new HttpRequestPacket { MaximumMultipartPartLength = 8 }; + + Assert.Throws(() => + packet.Parse(data, 0, (uint)data.Length)); + } + + [Fact] + public void MultipartForm_ParsesQuotedBoundaryIncrementally() + { + const string boundary = "test-boundary"; + const string body = + "--test-boundary\r\n" + + "Content-Disposition: form-data; name=\"first\"\r\n\r\n" + + "one\r\n" + + "--test-boundary\r\n" + + "Content-Disposition: form-data; name=\"second\"\r\n\r\n" + + "two\r\n" + + "--test-boundary--\r\n"; + var data = RequestBytes( + "POST", + $"Content-Type: multipart/form-data; boundary=\"{boundary}\"\r\n" + + $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n", + body); + var packet = new HttpRequestPacket(); + + Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length)); + Assert.Equal("one", packet.PostForms["first"]); + Assert.Equal("two", packet.PostForms["second"]); + } + + [Theory] + [InlineData("Content-Length: 1\r\nContent-Length: 1\r\n")] + [InlineData("Transfer-Encoding: chunked\r\n")] + [InlineData("Transfer-Encoding: identity\r\nContent-Length: 1\r\n")] + public void Response_RejectsAmbiguousOrUnsupportedFraming(string framingHeaders) + { + var data = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + framingHeaders + "\r\nx"); + + Assert.Throws(() => + new HttpResponsePacket().Parse(data, 0, (uint)data.Length)); + } + + [Fact] + public void Cookie_RoundTripsSecureAndSameSiteAttributes() + { + var cookie = new HttpCookie("sid", "value") + { + Path = "/", + HttpOnly = true, + Secure = true, + SameSite = HttpCookieSameSite.Strict, + }; + var serialized = cookie.ToString(); + Assert.Contains("; Secure", serialized, StringComparison.Ordinal); + Assert.Contains("; SameSite=Strict", serialized, StringComparison.Ordinal); + + var data = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nSet-Cookie: " + serialized + "\r\n\r\n"); + var response = new HttpResponsePacket(); + + Assert.Equal(data.Length, response.Parse(data, 0, (uint)data.Length)); + Assert.True(response.Cookies[0].Secure); + Assert.Equal(HttpCookieSameSite.Strict, response.Cookies[0].SameSite); + } + + [Fact] + public void Response_ComposeUsesAnExplicitOutboundLimitOnly() + { + var response = new HttpResponsePacket + { + MaximumContentLength = 1, + Message = new byte[] { 1, 2 }, + }; + + Assert.True(response.Compose(HttpComposeOption.DataOnly)); + Assert.Equal(response.Message, response.Data); + + response.MaximumComposedContentLength = 1; + Assert.Throws(() => + response.Compose(HttpComposeOption.DataOnly)); + } + + [Fact] + public async Task Session_TimerStartsAndServerRemovesExpiredSession() + { + var server = new HttpServer(); + var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var session = server.CreateSession("expiring", 1); + session.OnEnd += _ => ended.TrySetResult(true); + + await ended.Task.WaitAsync(TimeSpan.FromSeconds(3)); + + var sessions = (IDictionary)typeof(HttpServer) + .GetField("sessions", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(server)!; + Assert.Empty(sessions); + + // Destruction remains safe after the expiry callback has already disposed the timer. + session.Destroy(); + } + + [Fact] + public void Session_WithoutATimerCanBeDestroyedIdempotently() + { + var session = new HttpSession(); + + session.Destroy(); + session.Destroy(); + } + + [Fact] + public void ExplicitlyDestroyedSession_IsRemovedFromServer() + { + var server = new HttpServer(); + var session = server.CreateSession("destroyed", 0); + + session.Destroy(); + + var sessions = (IDictionary)typeof(HttpServer) + .GetField("sessions", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(server)!; + Assert.Empty(sessions); + } + + [Fact] + public void InternalServerErrorPage_HtmlEncodesExceptionText() + { + var page = HttpConnection.FormatError500Page("&"); + + Assert.DoesNotContain(""); + + var genericPage = connection.FormatError500Page(exception); + Assert.Contains("An internal server error occurred.", genericPage); + Assert.DoesNotContain("secret", genericPage, StringComparison.Ordinal); + + server.ExposeExceptionDetails = true; + var detailedPage = connection.FormatError500Page(exception); + Assert.Contains("<script>secret</script>", detailedPage); + Assert.DoesNotContain("