diff --git a/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs index 3df78b3..0c32acb 100644 --- a/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs +++ b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs @@ -72,6 +72,7 @@ public async Task StartHost() _app = builder.Build(); _app.MapJsonRpc("/rpc"); + _app.MapJsonRpc("/raised-limit", new JsonRpcOptions { MaxRequestBytes = 6 * 1024 * 1024 }); await _app.StartAsync(); var addresses = _app.Services.GetRequiredService().Features.Get().Addresses; @@ -157,11 +158,28 @@ public async Task Http_DiService_IsBoundAndSeesHttpContext() [Test] public async Task Http_LargeBody_Is413() { - var big = "{\"jsonrpc\":\"2.0\",\"method\":\"internal.echo\",\"params\":[\"" + new string('x', 5 * 1024 * 1024) + "\"],\"id\":1}"; + var big = SizedDocument(4 * 1024 * 1024 + 1); var response = await PostAsync(big); Assert.AreEqual(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); } + [Test] + public async Task Http_RaisedTransportLimit_UsesCoreDocumentLimit() + { + var response = await _http.PostAsync("/raised-limit", + new StringContent(SizedDocument(4 * 1024 * 1024 + 1), Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\",\"data\":{\"limit\":\"maxDocumentBytes\",\"maximum\":4194304}},\"id\":null}", + await response.Content.ReadAsStringAsync()); + } + + private static string SizedDocument(int bytes) + { + const string prefix = "{\"method\":\"IntToInt\",\"params\":[\""; + const string suffix = "\"],\"id\":1}"; + return prefix + new string('x', bytes - prefix.Length - suffix.Length) + suffix; + } + [Test] public async Task Tcp_TwoDocumentsInOneWrite_AreAnsweredInOrder() { diff --git a/AustinHarris.JsonRpcTestN/LimitsTests.cs b/AustinHarris.JsonRpcTestN/LimitsTests.cs new file mode 100644 index 0000000..c975ae4 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/LimitsTests.cs @@ -0,0 +1,287 @@ +using System; +using System.Buffers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + [TestFixture] + [NonParallelizable] + public class LimitsTests + { + private const string Call = "{\"jsonrpc\":\"2.0\",\"method\":\"limits.hit\",\"id\":1}"; + private const string Notification = "{\"jsonrpc\":\"2.0\",\"method\":\"limits.hit\"}"; + private const string Result = "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}"; + private string _session; + private Service _service; + + public sealed class Service + { + public int Calls; + [JsonRpcMethod("limits.hit")] + public int Hit() { Interlocked.Increment(ref Calls); return 7; } + } + + private sealed class Segment : ReadOnlySequenceSegment + { + internal Segment(ReadOnlyMemory memory) { Memory = memory; } + internal Segment Append(ReadOnlyMemory memory) + { + var next = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = next; + return next; + } + + internal Segment AppendAt(long index, ReadOnlyMemory memory) + { + var next = new Segment(memory) { RunningIndex = index }; + Next = next; + return next; + } + } + + [SetUp] + public void SetUp() + { + _session = "limits-" + Guid.NewGuid().ToString("N"); + _service = new Service(); + ServiceBinder.BindService(_session, _service); + Config.SetLimits(JsonRpcLimits.Default); + } + + [TearDown] + public void TearDown() + { + Config.SetLimits(JsonRpcLimits.Default); + Handler.DestroySession(_session); + } + + private static ReadOnlySequence Split(byte[] bytes) + { + int middle = bytes.Length / 2; + var first = new Segment(bytes.AsMemory(0, middle)); + var last = first.Append(bytes.AsMemory(middle)); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); + } + + private static string Text(ArrayBufferWriter output) => Encoding.UTF8.GetString(output.WrittenSpan); + + private static string LimitError(string name, long maximum) => + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\",\"data\":{\"limit\":\"" + + name + "\",\"maximum\":" + maximum + "}},\"id\":null}"; + + [TestCase("jsmn")] + [TestCase("newtonsoft")] + [TestCase("stj")] + public async Task DocumentBytes_AllPublicEntriesRejectBeforeDispatch(string serializerName) + { + var serializer = SerializerCatalog.Create(serializerName); + byte[] bytes = Encoding.UTF8.GetBytes(Call); + long maximum = bytes.Length - 1; + string expected = LimitError("maxDocumentBytes", maximum); + Config.SetLimits(new JsonRpcLimits(maximum, 0)); + + var output = new ArrayBufferWriter(); + var single = new ReadOnlySequence(bytes); + JsonRpcProcessor.Process(_session, in single, output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "sync single-segment sequence"); + output.Clear(); + var multi = Split(bytes); + JsonRpcProcessor.Process(_session, in multi, output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "sync multi-segment sequence"); + output.Clear(); + JsonRpcProcessor.Process(_session, bytes.AsMemory(), output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "sync memory"); + output.Clear(); + JsonRpcProcessor.Process(_session, bytes.AsSpan(), output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "sync span"); + Assert.AreEqual(expected, Encoding.UTF8.GetString(JsonRpcProcessor.ProcessBytes(_session, bytes.AsSpan(), serializer: serializer))); + Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(_session, Call, null, serializer)); + Assert.AreEqual(expected, await JsonRpcProcessor.Process(_session, Call, null, serializer)); + + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, single, output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "async single-segment sequence"); + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, multi, output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "async multi-segment sequence"); + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, bytes.AsMemory(), output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "async memory"); + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, bytes.AsSpan(), output, serializer: serializer); + Assert.AreEqual(expected, Text(output), "async span"); + Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(_session, Call, serializer: serializer)); + Assert.AreEqual(0, _service.Calls); + } + + [Test] + public async Task DefaultSessionAndStateWrappersInheritByteCheck() + { + Config.SetLimits(new JsonRpcLimits(1, 0)); + string expected = LimitError("maxDocumentBytes", 1); + Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(Call)); + Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(SerializerCatalog.Create("jsmn"), Call)); + Assert.AreEqual(expected, await JsonRpcProcessor.Process(Call)); + Assert.AreEqual(expected, await JsonRpcProcessor.Process(SerializerCatalog.Create("jsmn"), Call)); + Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(Call)); + + async Task StateResult(bool defaultSession) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var state = new JsonRpcStateAsync(ar => completion.SetResult(((JsonRpcStateAsync)ar).Result), null) { JsonRpc = Call }; + if (defaultSession) JsonRpcProcessor.Process(state); + else JsonRpcProcessor.Process(_session, state); + return await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + Assert.AreEqual(expected, await StateResult(false)); + Assert.AreEqual(expected, await StateResult(true)); + Assert.AreEqual(0, _service.Calls); + } + + [Test] + public async Task SequenceLengthIsCheckedBeforeIntConversionOrFlattening() + { + Config.SetLimits(_session, new JsonRpcLimits(1, 0)); + var first = new Segment(ReadOnlyMemory.Empty); + var last = first.AppendAt((long)int.MaxValue + 1, ReadOnlyMemory.Empty); + var sequence = new ReadOnlySequence(first, 0, last, 0); + var output = new ArrayBufferWriter(); + JsonRpcProcessor.Process(_session, in sequence, output); + Assert.AreEqual(LimitError("maxDocumentBytes", 1), Text(output)); + output.Clear(); + await JsonRpcProcessor.ProcessAsync(_session, sequence, output); + Assert.AreEqual(LimitError("maxDocumentBytes", 1), Text(output)); + } + + [Test] + public void AsyncPreCancellationWinsOverDocumentLimit() + { + Config.SetLimits(_session, new JsonRpcLimits(1, 0)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var bytes = Encoding.UTF8.GetBytes(Call); + var output = new ArrayBufferWriter(); + var sequence = new ReadOnlySequence(bytes); + Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, sequence, output, cancellationToken: cts.Token).IsCanceled); + Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, bytes.AsMemory(), output, cancellationToken: cts.Token).IsCanceled); + Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, bytes.AsSpan(), output, cancellationToken: cts.Token).IsCanceled); + Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, Call, cancellationToken: cts.Token).IsCanceled); + Assert.AreEqual(0, output.WrittenCount); + } + + [TestCase("jsmn")] + [TestCase("newtonsoft")] + [TestCase("stj")] + public async Task BatchCountRejectsWholeMixedAndNotificationOnlyBatches(string serializerName) + { + var serializer = SerializerCatalog.Create(serializerName); + Config.SetLimits(_session, new JsonRpcLimits(0, 2)); + string expected = LimitError("maxBatchCount", 2); + string mixed = "[" + Call + "," + Notification + ",42]"; + string notifications = "[" + Notification + "," + Notification + "," + Notification + "]"; + foreach (string batch in new[] { mixed, notifications }) + { + Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(_session, batch, null, serializer)); + Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(_session, batch, serializer: serializer)); + } + Assert.AreEqual(0, _service.Calls); + } + + [TestCase("jsmn")] + [TestCase("newtonsoft")] + [TestCase("stj")] + public async Task StringLimitUsesUtf8ByteCount(string serializerName) + { + var serializer = SerializerCatalog.Create(serializerName); + string ascii = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"aa\"}"; + string multibyte = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"€€\"}"; + int maximum = Encoding.UTF8.GetByteCount(ascii); + Config.SetLimits(_session, new JsonRpcLimits(maximum, 0)); + Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, ascii, null, serializer)); + Assert.AreEqual(LimitError("maxDocumentBytes", maximum), JsonRpcProcessor.ProcessSync(_session, multibyte, null, serializer)); + Assert.AreEqual(LimitError("maxDocumentBytes", maximum), await JsonRpcProcessor.ProcessAsync(_session, multibyte, serializer: serializer)); + Assert.AreEqual(1, _service.Calls); + } + + [Test] + public void ConfigurationRejectsNegativesAndNullGlobal_AndSessionCanInherit() + { + Assert.AreEqual(4 * 1024 * 1024, JsonRpcLimits.Default.MaxDocumentBytes); + Assert.AreEqual(1024, JsonRpcLimits.Default.MaxBatchCount); + Assert.AreEqual(0, JsonRpcLimits.Unlimited.MaxDocumentBytes); + Assert.AreEqual(0, JsonRpcLimits.Unlimited.MaxBatchCount); + Assert.AreEqual("maxDocumentBytes", Assert.Throws(() => new JsonRpcLimits(-1, 1)).ParamName); + Assert.AreEqual("maxBatchCount", Assert.Throws(() => new JsonRpcLimits(1, -1)).ParamName); + Assert.Throws(() => Config.SetLimits(null)); + + var global = new JsonRpcLimits(1, 0); + Config.SetLimits(global); + Config.SetLimits(_session, null); + Assert.AreSame(global, Config.Limits); + Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null)); + Config.SetLimits(_session, new JsonRpcLimits(0, 0)); + Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, Call, null)); + Assert.AreEqual(1, _service.Calls); + Config.SetLimits(_session, null); + Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null)); + + string created = "limits-created-" + Guid.NewGuid().ToString("N"); + try + { + Config.SetLimits(created, null); + Assert.IsTrue(Handler.TryGetSessionHandler(created, out var handler)); + Assert.IsNull(handler.Limits); + } + finally { Handler.DestroySession(created); } + } + + [Test] + public void ZeroDisablesEachField_AndUnlimitedAcceptsFiveMiB() + { + Config.SetLimits(_session, new JsonRpcLimits(0, 1)); + string large = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"" + new string('x', 5 * 1024 * 1024) + "\"}"; + Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, large, null)); + Config.SetLimits(_session, new JsonRpcLimits(0, 0)); + Assert.AreEqual("[" + Result + "," + Result + "]", JsonRpcProcessor.ProcessSync(_session, "[" + Call + "," + Call + "]", null)); + Config.SetLimits(_session, JsonRpcLimits.Unlimited); + Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, large, null)); + Assert.AreEqual(4, _service.Calls); + } + + [Test] + public async Task LimitErrorsReachParseHandler_ButNotPreProcessHandler() + { + int parsed = 0, pre = 0; + Config.SetLimits(_session, new JsonRpcLimits(1, 1)); + Config.SetParseErrorHandler(_session, (raw, error) => + { + Assert.IsNotNull(raw); + Assert.AreEqual(-32600, error.code); + Assert.IsInstanceOf(error.data); + parsed++; + return error; + }); + Config.SetPreProcessHandler(_session, (request, context) => { pre++; return null; }); + try + { + Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null)); + Config.SetLimits(_session, new JsonRpcLimits(0, 1)); + Assert.AreEqual(LimitError("maxBatchCount", 1), await JsonRpcProcessor.ProcessAsync(_session, "[" + Call + "," + Call + "]")); + Assert.AreEqual(2, parsed); + Assert.AreEqual(0, pre); + Assert.AreEqual(0, _service.Calls); + } + finally + { + Config.SetParseErrorHandler(_session, null); + Config.SetPreProcessHandler(_session, null); + } + } + } +} diff --git a/AustinHarris.JsonRpcTestN/ReservedNameTests.cs b/AustinHarris.JsonRpcTestN/ReservedNameTests.cs new file mode 100644 index 0000000..1267194 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/ReservedNameTests.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using AustinHarris.JsonRpc; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Reserved method names (rpc.-prefixed and $/cancelRequest) are refused by + /// itself, so every registration path refuses them. + /// + [TestFixture] + public sealed class ReservedNameTests + { + private const string Session = "reserved-names"; + private static readonly string[] Reserved = { "rpc.x", "$/cancelRequest" }; + private static readonly string[] Allowed = { "$/progress", "rpcx", "Rpc.x", "x.rpc.y", "$/cancelrequest" }; + private static SMDServiceCollection Services => Handler.GetSessionHandler(Session).MetaData.Services; + + [TearDown] + public void Clean() => Handler.DestroySession(Session); + + private static SMDService NewService(int result) + { + return new SMDService("POST", "JSON-RPC-2.0", new Dictionary { ["returns"] = typeof(int) }, new Dictionary(), new Func(() => result)); + } + + private static SMDService Find(string name) => Services.Find(Encoding.UTF8.GetBytes(name)); + + private static void AssertReserved(string name, TestDelegate register) + { + var ex = Assert.Throws(register, name); + StringAssert.StartsWith("'" + name + "' is a reserved JSON-RPC method name.", ex.Message); + Assert.IsFalse(Services.ContainsKey(name), name); + Assert.IsNull(Find(name), name); + } + + private static void AssertRegistered(string name) + { + Assert.IsTrue(Services.ContainsKey(name), name); + Assert.IsNotNull(Find(name), name); + } + + private interface IPair + { + int First(); + int Second(); + } + + private sealed class Pair : IPair + { + public int First() => 1; + public int Second() => 2; + } + + private sealed class ReservedAlias + { + [JsonRpcMethod("rpc.x")] + public int M() => 1; + } + + private sealed class ReservedCancelAlias + { + [JsonRpcMethod("$/cancelRequest")] + public int M() => 1; + } + + private sealed class AllowedAliases + { + [JsonRpcMethod("$/progress")] + [JsonRpcMethod("rpcx")] + [JsonRpcMethod("Rpc.x")] + [JsonRpcMethod("x.rpc.y")] + [JsonRpcMethod("$/cancelrequest")] + public int M() => 1; + } + + private sealed class NullKeyEntries : IReadOnlyDictionary + { + public int Count => 1; + public IEnumerable Keys => new string[] { null }; + public IEnumerable Values => new SMDService[] { null }; + public SMDService this[string key] => throw new NotImplementedException(); + public bool ContainsKey(string key) => false; + public bool TryGetValue(string key, out SMDService value) { value = null; return false; } + public IEnumerator> GetEnumerator() + { + yield return new KeyValuePair(null, null); + } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Test] + public void BindMethod_RefusesReserved_AcceptsTheRest() + { + foreach (var name in Reserved) + AssertReserved(name, () => ServiceBinder.BindMethod(Session, name, () => 1)); + foreach (var name in Allowed) + { + ServiceBinder.BindMethod(Session, name, () => 7); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}", + JsonRpcProcessor.ProcessSync(Session, "{\"method\":\"" + name + "\",\"id\":1}", null), name); + } + } + + [Test] + public void BindInterface_RefusesReserved_AcceptsTheRest() + { + foreach (var name in Reserved) + { + AssertReserved(name, () => ServiceBinder.BindInterface(Session, new Pair(), + new RpcInterfaceBindingOptions { NameRule = m => m.Leaf == "First" ? name : "second" })); + Assert.AreEqual(0, Services.Count); + } + foreach (var name in Allowed) + { + ServiceBinder.BindInterface(Session, new Pair(), + new RpcInterfaceBindingOptions { NameRule = m => m.Leaf == "First" ? name : "second." + name }); + AssertRegistered(name); + AssertRegistered("second." + name); + } + } + + [Test] + public void AttributeBinder_RefusesReservedAliases_AcceptsTheRest() + { + AssertReserved("rpc.x", () => ServiceBinder.BindService(Session, new ReservedAlias())); + AssertReserved("$/cancelRequest", () => ServiceBinder.BindService(Session, new ReservedCancelAlias())); + Assert.AreEqual(0, Services.Count); + ServiceBinder.BindService(Session, new AllowedAliases()); + foreach (var name in Allowed) AssertRegistered(name); + } + + [Test] + public void RegisterFuction_RefusesReserved_AcceptsTheRest() + { + var handler = Handler.GetSessionHandler(Session); +#pragma warning disable CS0618 + foreach (var name in Reserved) + AssertReserved(name, () => handler.RegisterFuction(name, new Dictionary { ["returns"] = typeof(int) }, null, new Func(() => 1))); + foreach (var name in Allowed) + { + handler.RegisterFuction(name, new Dictionary { ["returns"] = typeof(int) }, null, new Func(() => 1)); + AssertRegistered(name); + } +#pragma warning restore CS0618 + } + + [Test] + public void DirectCollectionAdds_RefuseReserved_AcceptTheRest() + { + var services = Services; + foreach (var name in Reserved) + { + AssertReserved(name, () => services.Add(name, NewService(1))); + AssertReserved(name, () => services.Add(new KeyValuePair(name, NewService(1)))); + AssertReserved(name, () => services[name] = NewService(1)); + Assert.AreEqual("key", Assert.Throws(() => services.Add(name, NewService(1))).ParamName); + Assert.AreEqual("key", Assert.Throws(() => services[name] = NewService(1)).ParamName); + } + Assert.AreEqual(0, services.Count); + + foreach (var name in Allowed) + { + services.Add(name, NewService(1)); + AssertRegistered(name); + Assert.IsTrue(services.Remove(name)); + services.Add(new KeyValuePair(name, NewService(2))); + AssertRegistered(name); + var replacement = NewService(3); + services[name] = replacement; + Assert.AreSame(replacement, Find(name)); + } + + Assert.Throws(() => services.Add(null, NewService(1))); + Assert.Throws(() => services[null] = NewService(1)); + } + + [Test] + public void Names_AreComparedAsGiven() + { + // no trimming and no case folding: these are ordinary names + foreach (var name in new[] { " rpc.x", "RPC.x", "$/CancelRequest", "$/cancelRequest ", "rpc" }) + { + Services.Add(name, NewService(1)); + AssertRegistered(name); + } + AssertReserved("rpc.", () => Services.Add("rpc.", NewService(1))); + } + + [Test] + public void AddBatch_WithOneReservedEntry_LeavesTheCollectionUnchanged() + { + ServiceBinder.BindMethod(Session, "before", () => 1); + var before = Find("before"); + int count = Services.Count; + + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new Pair(), + new RpcInterfaceBindingOptions { NameRule = m => m.Leaf == "First" ? "ok" : "$/cancelRequest" })); + Assert.AreEqual("entries", ex.ParamName); + + Assert.AreEqual(count, Services.Count); + Assert.AreSame(before, Find("before")); + Assert.IsNull(Find("ok")); + Assert.IsNull(Find("$/cancelRequest")); + Assert.IsFalse(Services.ContainsKey("ok")); + CollectionAssert.AreEqual(new[] { "before" }, Services.Keys); + } + + [Test] + public void AddBatch_NullName_RetainsArgumentNullException() + { + var ex = Assert.Throws(() => Services.AddBatch(new NullKeyEntries())); + Assert.AreEqual(0, Services.Count); + } + + [Test] + public void AddReserved_AcceptsReservedOnce_AndKeepsTheDuplicateRule() + { + var first = NewService(1); + Services.AddReserved("rpc.discover", first); + Assert.AreSame(first, Find("rpc.discover")); + Assert.AreSame(first, Services["rpc.discover"]); + + Assert.Throws(() => Services.AddReserved("rpc.discover", NewService(2))); + Assert.AreSame(first, Find("rpc.discover"), "the first registration stands"); + Assert.AreEqual(1, Services.Count); + + Assert.Throws(() => Services.AddReserved(null, NewService(1))); + Assert.Throws(() => Services.AddReserved("rpc.other", null)); + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b34b3..5ace396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ behaviour: a breaking change to either means a new major version. ### Added +- `JsonRpcLimits` and `Config.SetLimits`: the core rejects documents over 4 MiB and batches over 1024 entries with `-32600` and a `data` object naming the limit; `JsonRpcLimits.Unlimited` restores the 1.x behaviour. - `ServiceBinder.BindInterface` registers interface trees atomically, with contract naming, filtering, defaults and ownership-aware disposal (`RpcBinding`). - `ServiceBinder.BindMethod` registers any delegate as a method without attributes or a service class. - `JsonRpcProcessor.ProcessAsync` awaits `Task` and `ValueTask` methods with typed result writing, sequential batches and cooperative cancellation; `[JsonRpcCancellation]` injects the processor's token. @@ -38,6 +39,7 @@ behaviour: a breaking change to either means a new major version. - The AspNetCore host binds every registered service, `JsonRpcService` subclasses included, to its effective session (the registration's session, then `JsonRpcOptions.SessionId`, then the default). It no longer skips a subclass on the default session. - The core package's description says "no JSON library dependency" instead of "no dependencies". The session registry uses the framework's `ConcurrentDictionary`; the `NonBlocking` package reference is gone, so the core has no dependencies on `net8.0` and `net10.0` (measured with `SessionRegistryBenchmarks`: unknown-id lookups and register/destroy cycles got faster, stable lookups and dispatch are unchanged). - `SMD.Services` is an `SMDServiceCollection`; every mutation through it updates the dispatch table at once. `SMD.Types` is a process-wide registry. +- Registration refuses reserved method names (`rpc.`-prefixed and `$/cancelRequest`) on every path, including `BindMethod`, attribute binding and direct additions to `SMDServiceCollection`; `BindInterface` refused `rpc.` alone before. - The `jsonrpc` member is checked (`Config.VersionPolicy`, default `Lenient`): a missing member is accepted, `"jsonrpc":"1.0"` or a non-string value is `-32600`. - A parameter value the serializer cannot convert is `-32602` with structured data naming the parameter (it was `-32603`); `-32601` names the requested method in its data. - Named parameters are checked against the method's parameter list: an unknown or repeated name is `-32602`. @@ -60,6 +62,7 @@ behaviour: a breaking change to either means a new major version. ### Security +- Documents and batches are bounded in the core by default (`JsonRpcLimits`), independent of the transport. - With `Config.IncludeExceptionDetails` off (the default), an unhandled exception is answered as `-32603` with `data: null`: the exception's type name and message are no longer sent. Error handlers still receive the exception itself and can author what the client sees. The same applies to an exception thrown while writing a result. `ExceptionInfo.ForResponse` returns null when details are off. - The session registry no longer grows from untrusted session ids on the request path (see Changed). diff --git a/Json-Rpc/Config.cs b/Json-Rpc/Config.cs index 3fad4c5..4a0939c 100644 --- a/Json-Rpc/Config.cs +++ b/Json-Rpc/Config.cs @@ -67,6 +67,23 @@ public static JsonRpcVersionPolicy VersionPolicy set { _versionPolicy = value; } } + private static volatile JsonRpcLimits _limits = JsonRpcLimits.Default; + + /// The process-wide document and batch limits, used when a session has no override. + public static JsonRpcLimits Limits => _limits; + + /// Sets the process-wide document and batch limits. Null is not allowed. + public static void SetLimits(JsonRpcLimits limits) + { + _limits = limits ?? throw new ArgumentNullException(nameof(limits)); + } + + /// Sets one session's limits; null makes it inherit . Creates the session if needed. + public static void SetLimits(string sessionId, JsonRpcLimits limits) + { + Handler.GetSessionHandler(sessionId).Limits = limits; + } + /// Sets the version policy for one session; null makes the session follow . public static void SetVersionPolicy(string sessionId, JsonRpcVersionPolicy? policy) { diff --git a/Json-Rpc/Handler.cs b/Json-Rpc/Handler.cs index 7933d00..f4692bd 100644 --- a/Json-Rpc/Handler.cs +++ b/Json-Rpc/Handler.cs @@ -151,6 +151,15 @@ public void Destroy() /// public JsonRpcVersionPolicy? VersionPolicy { get; set; } + private volatile JsonRpcLimits _limits; + + /// The limits for this session. Null inherits . + public JsonRpcLimits Limits + { + get { return _limits; } + set { _limits = value; } + } + /// /// Provides access to a context specific to each JsonRpc method invocation. /// Warning: Must be called from within the execution context of the jsonRpc Method to return the context @@ -816,6 +825,9 @@ private static void WriteErrorData(IBufferWriter output, JsonRpcSerializer case MethodNotFoundInfo notFound: notFound.WriteTo(output); break; + case LimitExceededInfo limitExceeded: + limitExceeded.WriteTo(output); + break; case ParameterErrorInfo parameterError: parameterError.WriteTo(output); break; diff --git a/Json-Rpc/JsonRpcLimits.cs b/Json-Rpc/JsonRpcLimits.cs new file mode 100644 index 0000000..6a837b4 --- /dev/null +++ b/Json-Rpc/JsonRpcLimits.cs @@ -0,0 +1,33 @@ +using System; + +namespace AustinHarris.JsonRpc +{ + /// + /// Immutable bounds on the UTF-8 bytes in one JSON-RPC document and the number of top-level elements in a + /// batch. A zero value disables that bound. A transport's own byte limit, such as Kestrel's + /// MaxRequestBytes, is checked first when present. + /// + public sealed class JsonRpcLimits + { + /// Creates document and batch limits. Negative values are invalid; zero disables a limit. + public JsonRpcLimits(long maxDocumentBytes = 4 * 1024 * 1024, int maxBatchCount = 1024) + { + if (maxDocumentBytes < 0) throw new ArgumentOutOfRangeException(nameof(maxDocumentBytes)); + if (maxBatchCount < 0) throw new ArgumentOutOfRangeException(nameof(maxBatchCount)); + MaxDocumentBytes = maxDocumentBytes; + MaxBatchCount = maxBatchCount; + } + + /// The maximum UTF-8 bytes in one document, or zero for no byte limit. + public long MaxDocumentBytes { get; } + + /// The maximum number of top-level batch elements, or zero for no batch limit. + public int MaxBatchCount { get; } + + /// The default bounds: 4 MiB per document and 1024 elements per batch. + public static JsonRpcLimits Default { get; } = new JsonRpcLimits(); + + /// No core document-byte or batch-count bound. + public static JsonRpcLimits Unlimited { get; } = new JsonRpcLimits(0, 0); + } +} diff --git a/Json-Rpc/JsonRpcProcessor.Async.cs b/Json-Rpc/JsonRpcProcessor.Async.cs index 564ed31..a163b2d 100644 --- a/Json-Rpc/JsonRpcProcessor.Async.cs +++ b/Json-Rpc/JsonRpcProcessor.Async.cs @@ -17,7 +17,21 @@ public static partial class JsonRpcProcessor public static Task ProcessAsync(string sessionId, ReadOnlySequence request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) { - if (request.IsSingleSegment) return ProcessAsync(sessionId, request.First, output, context, serializer, cancellationToken); + if (request.IsSingleSegment) + return StartAsyncDocument(sessionId, request.First, output, context, serializer, cancellationToken); + Handler handler; + JsonRpcLimits limits; + try + { + cancellationToken.ThrowIfCancellationRequested(); + handler = GetRequestHandler(sessionId); + limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + return RejectAsyncDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request.ToArray()) : null, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } + catch (Exception ex) { return Task.FromException(ex); } var scratch = AsyncScratch.Rent(); int length; byte[] buffer; @@ -28,7 +42,8 @@ public static Task ProcessAsync(string sessionId, ReadOnlySequence request request.CopyTo(buffer); } catch { scratch.Return(); throw; } - return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, length), output, context, serializer, cancellationToken, scratch); + return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, length), output, context, + serializer, cancellationToken, handler, limits, scratch); } /// @@ -38,7 +53,7 @@ public static Task ProcessAsync(string sessionId, ReadOnlySequence request public static Task ProcessAsync(string sessionId, ReadOnlyMemory request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) { - return StartAsyncDocument(sessionId, request, output, context, serializer, cancellationToken, AsyncScratch.Rent()); + return StartAsyncDocument(sessionId, request, output, context, serializer, cancellationToken); } /// @@ -48,6 +63,19 @@ public static Task ProcessAsync(string sessionId, ReadOnlyMemory request, public static Task ProcessAsync(string sessionId, ReadOnlySpan request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) { + Handler handler; + JsonRpcLimits limits; + try + { + cancellationToken.ThrowIfCancellationRequested(); + handler = GetRequestHandler(sessionId); + limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + return RejectAsyncDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request) : null, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } + catch (Exception ex) { return Task.FromException(ex); } var scratch = AsyncScratch.Rent(); byte[] buffer; try @@ -56,17 +84,31 @@ public static Task ProcessAsync(string sessionId, ReadOnlySpan request, IB request.CopyTo(buffer); } catch { scratch.Return(); throw; } - return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, cancellationToken, scratch); + return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, + context, serializer, cancellationToken, handler, limits, scratch); } /// Processes a string asynchronously on the selected session, returning an empty string for notifications. public static async Task ProcessAsync(string sessionId, string jsonRpc, object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) { - var input = Encoding.UTF8.GetBytes(jsonRpc); + cancellationToken.ThrowIfCancellationRequested(); + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + int length = Encoding.UTF8.GetByteCount(jsonRpc); using (var output = new PooledByteBufferWriter()) { - await ProcessAsync(sessionId, new ReadOnlyMemory(input), output, context, serializer, cancellationToken).ConfigureAwait(false); + if (ExceedsDocumentLimit(length, limits)) + { + await RejectAsyncDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? jsonRpc : null, cancellationToken).ConfigureAwait(false); + } + else + { + var input = Encoding.UTF8.GetBytes(jsonRpc); + await StartAsyncDocument(sessionId, new ReadOnlyMemory(input), output, context, + serializer, cancellationToken, handler, limits).ConfigureAwait(false); + } return output.ToString(); } } @@ -78,13 +120,22 @@ public static Task ProcessAsync(string jsonRpc, object context = null, C } private static Task StartAsyncDocument(string sessionId, ReadOnlyMemory document, IBufferWriter destination, - object context, JsonRpcSerializer serializer, CancellationToken token, AsyncScratch scratch) + object context, JsonRpcSerializer serializer, CancellationToken token, Handler handler = null, + JsonRpcLimits limits = null, AsyncScratch scratch = null) { bool transferred = false; try { token.ThrowIfCancellationRequested(); - if (!Handler.TryGetSessionHandler(sessionId, out var handler)) handler = Handler.UnknownSessionHandler; + if (handler == null) + { + handler = GetRequestHandler(sessionId); + limits = handler.Limits ?? Config.Limits; + } + if (ExceedsDocumentLimit(document.Length, limits)) + return RejectAsyncDocument(handler, serializer, destination, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(document.Span) : null, token); + scratch = scratch ?? AsyncScratch.Rent(); serializer = serializer ?? handler.Serializer ?? Config.Serializer; scratch.DocumentLength = document.Length; var reader = scratch.GetReader(serializer); @@ -106,6 +157,11 @@ private static Task StartAsyncDocument(string sessionId, ReadOnlyMemory do } pending.GetAwaiter().GetResult(); } + else if (limits.MaxBatchCount != 0 && reader.Count > limits.MaxBatchCount) + { + WriteLimitError(output, handler, serializer, "maxBatchCount", limits.MaxBatchCount, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(document.Span) : null); + } else if (reader.Count == 0) { var ex = new JsonRpcException(-32600, "Invalid Request", "Batch of calls was empty."); @@ -145,7 +201,25 @@ private static Task StartAsyncDocument(string sessionId, ReadOnlyMemory do { return Task.FromException(ex); } - finally { if (!transferred) scratch.Return(); } + finally { if (!transferred) scratch?.Return(); } + } + + private static Task RejectAsyncDocument(Handler handler, JsonRpcSerializer serializer, IBufferWriter destination, + long maximum, string rawDocument, CancellationToken token) + { + AsyncScratch scratch = null; + try + { + token.ThrowIfCancellationRequested(); + scratch = AsyncScratch.Rent(); + WriteLimitError(scratch.Output, handler, serializer ?? handler.Serializer ?? Config.Serializer, + "maxDocumentBytes", maximum, rawDocument); + CommitAsyncDocument(scratch, destination, token); + return Task.CompletedTask; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { return Task.FromCanceled(token); } + catch (Exception ex) { return Task.FromException(ex); } + finally { scratch?.Return(); } } private static async Task FinishSingleDocumentAsync(ValueTask pending, AsyncScratch scratch, IBufferWriter destination, CancellationToken token) diff --git a/Json-Rpc/JsonRpcProcessor.cs b/Json-Rpc/JsonRpcProcessor.cs index cd5b530..47be7ea 100644 --- a/Json-Rpc/JsonRpcProcessor.cs +++ b/Json-Rpc/JsonRpcProcessor.cs @@ -19,9 +19,17 @@ public static partial class JsonRpcProcessor /// Processes one document (a request or a batch). Writes the response bytes to ; writes nothing for notifications. public static void Process(string sessionId, in ReadOnlySequence request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + { + RejectDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request.ToArray()) : null); + return; + } if (request.IsSingleSegment) { - Process(sessionId, request.First, output, context, serializer); + ProcessMemory(handler, limits, request.First, output, context, serializer); return; } int length = checked((int)request.Length); @@ -30,7 +38,7 @@ public static void Process(string sessionId, in ReadOnlySequence request, { var buffer = scratch.Input(length); request.CopyTo(buffer); - ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, length), output, context, serializer, scratch); + ProcessCore(handler, limits, new ReadOnlyMemory(buffer, 0, length), output, context, serializer, scratch); } finally { @@ -40,11 +48,25 @@ public static void Process(string sessionId, in ReadOnlySequence request, /// Processes one document held in memory. The memory must stay valid until the call returns. public static void Process(string sessionId, ReadOnlyMemory request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) + { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + { + RejectDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request.Span) : null); + return; + } + ProcessMemory(handler, limits, request, output, context, serializer); + } + + private static void ProcessMemory(Handler handler, JsonRpcLimits limits, ReadOnlyMemory request, + IBufferWriter output, object context, JsonRpcSerializer serializer) { var scratch = Scratch.Rent(); try { - ProcessCore(sessionId, request, output, context, serializer, scratch); + ProcessCore(handler, limits, request, output, context, serializer, scratch); } finally { @@ -55,12 +77,20 @@ public static void Process(string sessionId, ReadOnlyMemory request, IBuff /// Processes one document from a span (copied into a pooled buffer). public static void Process(string sessionId, ReadOnlySpan request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + { + RejectDocument(handler, serializer, output, limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request) : null); + return; + } var scratch = Scratch.Rent(); try { var buffer = scratch.Input(request.Length); request.CopyTo(buffer); - ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch); + ProcessCore(handler, limits, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch); } finally { @@ -71,6 +101,22 @@ public static void Process(string sessionId, ReadOnlySpan request, IBuffer /// Processes a UTF-8 document and returns the UTF-8 response (empty for notifications). public static byte[] ProcessBytes(string sessionId, ReadOnlySpan request, object context = null, JsonRpcSerializer serializer = null) { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + if (ExceedsDocumentLimit(request.Length, limits)) + { + var rejected = Scratch.Rent(); + try + { + var error = rejected.Output; + error.Clear(); + WriteLimitError(error, handler, serializer ?? handler.Serializer ?? Config.Serializer, + "maxDocumentBytes", limits.MaxDocumentBytes, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(request) : null); + return error.ToArray(); + } + finally { rejected.Return(); } + } var scratch = Scratch.Rent(); try { @@ -78,7 +124,7 @@ public static byte[] ProcessBytes(string sessionId, ReadOnlySpan request, request.CopyTo(buffer); var output = scratch.Output; output.Clear(); - ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch, true); + ProcessCore(handler, limits, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch, true); return output.ToArray(); } finally @@ -143,15 +189,30 @@ public static string ProcessSync(JsonRpcSerializer serializer, string jsonRpc, o // (null converts to string better than to object) with a null document. public static string ProcessSync(string sessionId, string jsonRpc, object jsonRpcContext, JsonRpcSerializer serializer = null) { + var handler = GetRequestHandler(sessionId); + var limits = handler.Limits ?? Config.Limits; + int length = Encoding.UTF8.GetByteCount(jsonRpc); + if (ExceedsDocumentLimit(length, limits)) + { + var rejected = Scratch.Rent(); + try + { + var error = rejected.Output; + error.Clear(); + WriteLimitError(error, handler, serializer ?? handler.Serializer ?? Config.Serializer, + "maxDocumentBytes", limits.MaxDocumentBytes, handler.HasParseErrorHandler ? jsonRpc : null); + return error.ToString(); + } + finally { rejected.Return(); } + } var scratch = Scratch.Rent(); try { - int max = Encoding.UTF8.GetMaxByteCount(jsonRpc.Length); - var buffer = scratch.Input(max); - int length = Encoding.UTF8.GetBytes(jsonRpc, 0, jsonRpc.Length, buffer, 0); + var buffer = scratch.Input(length); + Encoding.UTF8.GetBytes(jsonRpc, 0, jsonRpc.Length, buffer, 0); var output = scratch.Output; output.Clear(); - ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, length), output, jsonRpcContext, serializer, scratch, true); + ProcessCore(handler, limits, new ReadOnlyMemory(buffer, 0, length), output, jsonRpcContext, serializer, scratch, true); return output.ToString(); } finally @@ -162,9 +223,8 @@ public static string ProcessSync(string sessionId, string jsonRpc, object jsonRp // ------------------------------------------------------------------ core - private static void ProcessCore(string sessionId, ReadOnlyMemory document, IBufferWriter destination, object context, JsonRpcSerializer serializer, Scratch scratch, bool destinationIsScratch = false) + private static void ProcessCore(Handler handler, JsonRpcLimits limits, ReadOnlyMemory document, IBufferWriter destination, object context, JsonRpcSerializer serializer, Scratch scratch, bool destinationIsScratch = false) { - if (!Handler.TryGetSessionHandler(sessionId, out var handler)) handler = Handler.UnknownSessionHandler; serializer = serializer ?? handler.Serializer ?? Config.Serializer; // Always render into the rewindable scratch buffer, then hand the bytes to the caller's writer. @@ -184,6 +244,11 @@ private static void ProcessCore(string sessionId, ReadOnlyMemory document, { handler.HandleRequest(reader, 0, serializer, output, context); } + else if (limits.MaxBatchCount != 0 && reader.Count > limits.MaxBatchCount) + { + WriteLimitError(output, handler, serializer, "maxBatchCount", limits.MaxBatchCount, + handler.HasParseErrorHandler ? Utf8Json.ToStringUtf8(document.Span) : null); + } else if (reader.Count == 0) { var ex = new JsonRpcException(-32600, "Invalid Request", "Batch of calls was empty."); @@ -225,6 +290,39 @@ private static void ProcessCore(string sessionId, ReadOnlyMemory document, } } + private static Handler GetRequestHandler(string sessionId) + { + return Handler.TryGetSessionHandler(sessionId, out var handler) ? handler : Handler.UnknownSessionHandler; + } + + private static bool ExceedsDocumentLimit(long length, JsonRpcLimits limits) + { + return limits.MaxDocumentBytes != 0 && length > limits.MaxDocumentBytes; + } + + private static void WriteLimitError(PooledByteBufferWriter output, Handler handler, JsonRpcSerializer serializer, + string limit, long maximum, string rawDocument) + { + var error = new JsonRpcException(-32600, "Invalid Request", new LimitExceededInfo(limit, maximum)); + if (handler.HasParseErrorHandler) error = handler.ProcessParseException(rawDocument, error); + Handler.WriteErrorEnvelope(output, serializer, error, default); + } + + private static void RejectDocument(Handler handler, JsonRpcSerializer serializer, IBufferWriter destination, + long maximum, string rawDocument) + { + var scratch = Scratch.Rent(); + try + { + var output = scratch.Output; + output.Clear(); + WriteLimitError(output, handler, serializer ?? handler.Serializer ?? Config.Serializer, + "maxDocumentBytes", maximum, rawDocument); + output.CopyTo(destination); + } + finally { scratch.Return(); } + } + /// Per-thread pooled buffers and a cached reader. Re-entrant calls get a fresh instance. private sealed class Scratch { diff --git a/Json-Rpc/SMDService.cs b/Json-Rpc/SMDService.cs index 5f07a27..cfe024d 100644 --- a/Json-Rpc/SMDService.cs +++ b/Json-Rpc/SMDService.cs @@ -109,7 +109,8 @@ private static string TypeHash(Dictionary jo) /// The services of one session keyed by JSON method name. A dictionary for callers; underneath, every /// mutation also replaces the lock-free UTF-8 dispatch table the request path resolves methods from, so /// an added, removed or replaced service is visible to the next request. Reads of the dictionary take a - /// lock; the request path never does. + /// lock; the request path never does. Names beginning with rpc. and the name $/cancelRequest + /// are reserved: every public way of adding a service refuses them with an . /// public sealed class SMDServiceCollection : IDictionary, IReadOnlyDictionary { @@ -117,11 +118,27 @@ public sealed class SMDServiceCollection : IDictionary, IRea private readonly Utf8KeyTable _table = new Utf8KeyTable(); private readonly object _sync = new object(); + private const string ReservedPrefix = "rpc."; + private const string CancelRequest = "$/cancelRequest"; + + /// + /// Refuses the names the specification reserves (rpc.-prefixed, ordinal and case-sensitive) and + /// $/cancelRequest. The name is compared as given: no trimming and no case folding. + /// + private static void ThrowIfReserved(string name, string paramName) + { + if (name == null) throw new ArgumentNullException(paramName); + if (name.StartsWith(ReservedPrefix, StringComparison.Ordinal) || string.Equals(name, CancelRequest, StringComparison.Ordinal)) + throw new ArgumentException("'" + name + "' is a reserved JSON-RPC method name.", paramName); + } + internal void AddBatch(IReadOnlyDictionary entries) { if (entries.Count == 0) return; lock (_sync) { + // Every name is checked before anything is copied or added, so a batch with one reserved name adds nothing. + foreach (var entry in entries) ThrowIfReserved(entry.Key, nameof(entries)); var next = new Dictionary(_services); foreach (var entry in entries) { @@ -173,6 +190,7 @@ public SMDService this[string key] { if (key == null) throw new ArgumentNullException(nameof(key)); if (value == null) throw new ArgumentNullException(nameof(value)); + ThrowIfReserved(key, nameof(key)); lock (_sync) { _services[key] = value; @@ -222,11 +240,30 @@ public ICollection Values IEnumerable IReadOnlyDictionary.Values => Values; public void Add(string key, SMDService value) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value == null) throw new ArgumentNullException(nameof(value)); + ThrowIfReserved(key, nameof(key)); + lock (_sync) + { + _services.Add(key, value); + _table.Set(key, value); + } + } + + /// + /// Adds a service under a reserved name, for the library's own rpc.discover registration. + /// It bypasses only the reserved-name check: an existing still throws + /// . Internal, so the reserved check is the only public path; unused in 2.0.0. + /// + internal void AddReserved(string key, SMDService value) { if (key == null) throw new ArgumentNullException(nameof(key)); if (value == null) throw new ArgumentNullException(nameof(value)); lock (_sync) { + if (_services.ContainsKey(key)) + throw new ArgumentException("JSON-RPC method '" + key + "' is already registered.", nameof(key)); _services.Add(key, value); _table.Set(key, value); } diff --git a/Json-Rpc/Serialization/ErrorInfo.cs b/Json-Rpc/Serialization/ErrorInfo.cs index 8274760..b32df19 100644 --- a/Json-Rpc/Serialization/ErrorInfo.cs +++ b/Json-Rpc/Serialization/ErrorInfo.cs @@ -4,6 +4,36 @@ namespace AustinHarris.JsonRpc.Serialization { + /// The structured data of a -32600 document-byte or batch-count limit error. + public sealed class LimitExceededInfo + { + private static readonly byte[] Prefix = Encoding.ASCII.GetBytes("{\"limit\":"); + private static readonly byte[] MaximumKey = Encoding.ASCII.GetBytes(",\"maximum\":"); + + /// Creates the error data with the limit's name and configured maximum. + public LimitExceededInfo(string limit, long maximum) + { + Limit = limit; + Maximum = maximum; + } + + /// maxDocumentBytes or maxBatchCount. + public string Limit { get; } + + /// The configured maximum that was exceeded. + public long Maximum { get; } + + /// Writes the same JSON data bytes for every serializer. + public void WriteTo(IBufferWriter output) + { + Utf8Json.WriteRaw(output, Prefix); + Utf8Json.WriteString(output, Limit); + Utf8Json.WriteRaw(output, MaximumKey); + Utf8Json.WriteInt64(output, Maximum); + Utf8Json.WriteByte(output, (byte)'}'); + } + } + /// /// The data of a -32601 error: {"method":"name"}, the effective method name (decoded, and as /// replaced by a pre-process handler). Written the same way by every serializer. Nothing else is disclosed: the diff --git a/Json-Rpc/ServiceBinder.Interface.cs b/Json-Rpc/ServiceBinder.Interface.cs index 90c7ec3..7bf8209 100644 --- a/Json-Rpc/ServiceBinder.Interface.cs +++ b/Json-Rpc/ServiceBinder.Interface.cs @@ -21,8 +21,8 @@ public static RpcBinding BindInterface(TInterface implementation, Rp /// Only public instance methods declared by the selected interfaces are exported; names, attributes, /// and optional defaults come from those declarations, including explicit implementations. /// Recursive getters run once per mount at registration and may have side effects. A failure publishes - /// nothing; getter side effects cannot be undone. Empty, reserved rpc., duplicate, and occupied - /// names are rejected. Generic methods and default interface bodies are unsupported. + /// nothing; getter side effects cannot be undone. Empty, reserved (rpc.-prefixed or $/cancelRequest), + /// duplicate, and occupied names are rejected. Generic methods and default interface bodies are unsupported. /// The returned handle owns the registrations, not the lifetime of the implementation objects. /// public static RpcBinding BindInterface(string sessionId, TInterface implementation, @@ -118,8 +118,8 @@ private void AddMethod(MethodInfo method, object target, string[] path, string a var description = new RpcInterfaceMethod(method, path, leaf, defaultName); if (_include != null && !_include(description)) return; string name = _nameRule == null ? defaultName : _nameRule(description); - if (string.IsNullOrWhiteSpace(name) || name.StartsWith("rpc.", StringComparison.Ordinal)) - throw new ArgumentException("Invalid or reserved JSON-RPC interface method name: '" + name + "'."); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Invalid JSON-RPC interface method name: '" + name + "'."); if (Entries.ContainsKey(name)) throw new ArgumentException("Duplicate JSON-RPC interface method name '" + name + "'."); if (method.ContainsGenericParameters) throw new ArgumentException("Generic interface method '" + method.Name + "' is not supported."); diff --git a/README.md b/README.md index 51d8cd1..7793bd8 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ That is the whole in-process server. The rest of this page is about exposing met ## Defining methods -A *method* is a callable identified by the `method` member of a request; its implementation is a delegate, a `[JsonRpcMethod]` member of a class, or a member of a bound interface. `ServiceBinder` never asks for a `MethodInfo`; the same word names the -32601 "Method not found" error. +A *method* is a callable identified by the `method` member of a request; its implementation is a delegate, a `[JsonRpcMethod]` member of a class, or a member of a bound interface. `ServiceBinder` never asks for a `MethodInfo`; the same word names the -32601 "Method not found" error. Names beginning with `rpc.` and the name `$/cancelRequest` are reserved and refused at registration. ### Classes @@ -279,7 +279,7 @@ builder.WebHost.ConfigureKestrel(k => Clients write JSON documents back to back (whitespace or newlines between them are fine) and read the responses in the same order, also back to back with no separator; notifications produce nothing. The framer accepts strict JSON only, so single-quoted strings and other lenient syntax are refused on a raw connection even with the Json.NET serializer. -A raw connection has no authentication, authorisation or rate limiting; those are HTTP middleware and do not run here. Listen on loopback or a Unix socket, or put something in front that authenticates. A document larger than `MaxRequestBytes` (4 MB) aborts the connection. +A raw connection has no authentication, authorisation or rate limiting; those are HTTP middleware and do not run here. Listen on loopback or a Unix socket, or put something in front that authenticates. A document larger than `MaxRequestBytes` (4 MB) aborts the connection. The core applies its own `JsonRpcLimits` to the document the host hands over, so the transport limit is met first and the core limit second. With `EnableAsyncMethods = true`, documents on one connection are processed one at a time in order, and replies already finished are flushed before the connection waits on a slow method. Separate connections run concurrently. @@ -333,6 +333,7 @@ The errors the library raises itself carry structured `data`, identical for ever | Code | `error.data` | Object seen by the error handler | | --- | --- | --- | | `-32601` Method not found | `{"method":""}` | `MethodNotFoundInfo` | +| `-32600` Invalid Request: the document or batch exceeds a configured limit | `{"limit":"maxDocumentBytes","maximum":4194304}` or `{"limit":"maxBatchCount","maximum":1024}` (the configured maximum) | `LimitExceededInfo` | | `-32602` Invalid params: count, missing, unknown or repeated named parameter | a sentence, e.g. `"Named parameter 'b' was not present."` | `string` | | `-32602` Invalid params: a value the serializer could not convert | `{"reason":"conversion","parameter":"b","index":1,"expectedType":"int32"}` plus `"message"` when `Config.IncludeExceptionDetails` is on; the value sent is never echoed | `ParameterErrorInfo` (with the serializer's exception in `Cause`) | | `-32603` Internal error: the method threw, its result could not be written, or a parameter's type is one the serializer cannot handle | `null`, or the full `ExceptionInfo` when `Config.IncludeExceptionDetails` is on, see [Exception disclosure](#exception-disclosure) | `Exception` | @@ -515,6 +516,15 @@ The built-in serializer is the default. All three serializers write the envelope The full contract, what the core fixes versus what a serializer decides, is in [docs/serializers.md](docs/serializers.md). +### Limits + +```csharp +Config.SetLimits(new JsonRpcLimits(maxDocumentBytes: 8 * 1024 * 1024, maxBatchCount: 2048)); +Config.SetLimits("legacy-clients", JsonRpcLimits.Unlimited); +``` + +Zero disables either bound; `JsonRpcLimits.Unlimited` disables both. A null per-session value inherits the process-wide limits. + ### Nesting depth Every serializer exposes `MaxDepth` (default 64). A request nested deeper is answered `-32700` before any handler or binding runs, so recursive parameter conversion is bounded by the same number the JSON library itself enforces: the built-in serializer's constructor argument, `JsonSerializerOptions.MaxDepth`, or `JsonSerializerSettings.MaxDepth`. @@ -541,16 +551,14 @@ What the library does by default: - **Exception details are off.** An unhandled exception reaches the client as `-32603` with `data: null`: no type name, no message. `Config.IncludeExceptionDetails = true` sends the type, message, stack trace, source, HResult and inner exceptions; use it in development only. See [Exception disclosure](#exception-disclosure). - **Rejected values are not echoed.** A `-32602` conversion error names the parameter and the expected type, never the value sent. - **Nesting is limited to 64 levels.** A deeper request is `-32700` before any of your code runs. -- **Request size is limited on the Kestrel host only.** `MaxRequestBytes` defaults to 4 MB: HTTP answers `413`, a raw connection is aborted. The core itself does not limit document length; that is the transport's job. There is no limit on how many requests a batch holds, no response-size limit and no request deadline; a batch runs sequentially, so a 4 MB batch of small requests ties up one request's worth of server time for all of them. +- **Document and batch size are limited.** The core rejects a document over `JsonRpcLimits.MaxDocumentBytes` (4 MiB by default) or a batch with more than `MaxBatchCount` entries (1024) with `-32600` and a `data` object naming the limit, before anything is parsed or executed; `Config.SetLimits` changes them, `JsonRpcLimits.Unlimited` disables them. The Kestrel host also bounds bytes while receiving (`MaxRequestBytes`, 4 MB: HTTP answers `413`, a raw connection is aborted), so the first applicable limit wins. There is no response-size limit and no request deadline; a batch runs sequentially, so a batch of small requests ties up one request's worth of server time for all of them. - **Every `[JsonRpcMethod]` is callable.** Visibility does not matter (private methods are exposed), and `AddJsonRpcServicesFromAssembly` exposes every class in the assembly that carries the attribute. - **Requests do not create sessions.** An unknown session id answers `-32601` and leaves the registry alone; sessions are created by binding and by the per-session `Config` setters, and live until destroyed; see [Sessions and context](#sessions-and-context). - **Cancellation is cooperative.** It waits for a running method and cannot undo what the method already did. What it leaves to you: -- **Authentication and authorisation.** On HTTP, use endpoint metadata: `app.MapJsonRpc("/rpc").RequireAuthorization("api")`. A raw connection has none; listen on loopback or a Unix socket, or authenticate in front of it. -- **Per-method authorisation.** Check `Handler.RpcContext()` (the `HttpContext` on HTTP) inside the method, or reject in a pre-process handler (which moves the session to the slower path). -- **Transport security, rate limiting and deadlines.** TLS, rate limits and timeouts are Kestrel's and the middleware pipeline's, not this library's. Raw connections bypass the HTTP middleware and need equivalent controls at the listener. +Authentication, connection identity, TLS, rate limiting, request logging and deadlines belong to the host. HTTP hosts use ASP.NET Core middleware and endpoint metadata (`RequireAuthorization`, `UseRateLimiter`, the request-timeouts middleware); raw connections bypass that pipeline, so listen on loopback or a Unix socket, authenticate in front of them and use listener limits. Methods enforce authorisation that depends on parameter values. Core limits constrain admitted documents and batches, while transports bound bytes during receipt. Authentication and credential handling remain application responsibilities. - **Service state.** One service instance serves every request concurrently; see [Classes](#classes). The `jsonrpc` member policy (`Lenient` by default) is a compatibility setting, not a control; see [The `jsonrpc` member](#the-jsonrpc-member). diff --git a/SECURITY.md b/SECURITY.md index f68a8f8..9242bd6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,6 +19,6 @@ alongside it. ## What the library does and does not do -The README's [Security](README.md#security) section lists what the library does by default (exception -redaction, nesting limit, request-size limit on the Kestrel host) and what it leaves to the host: authentication, -authorisation, transport security, rate limiting and deadlines. +The README's [Security](README.md#security) section lists what the library does by default (exception redaction, nesting limit, document and batch limits in the core, request-size limit on the Kestrel host) and what it leaves to the host. + +Authentication, connection identity, TLS, rate limiting, request logging and deadlines belong to the host. HTTP hosts use ASP.NET Core middleware and endpoint metadata (`RequireAuthorization`, `UseRateLimiter`, the request-timeouts middleware); raw connections bypass that pipeline, so listen on loopback or a Unix socket, authenticate in front of them and use listener limits. Methods enforce authorisation that depends on parameter values. Core limits constrain admitted documents and batches, while transports bound bytes during receipt. Authentication and credential handling remain application responsibilities. diff --git a/docs/upgrading.md b/docs/upgrading.md index 7bbd7d9..aa45930 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -11,9 +11,11 @@ Most 1.x services run unchanged. Read the first list before you build, and the s - **Overloads.** The default-session string overloads that take a serializer take it first: `Process(serializer, json, context)` and `ProcessSync(serializer, json, context)`. `ProcessSync(sessionId, json, context, serializer)` makes `context` required, so `ProcessSync(json, null)` still means the default session. `Process` and `ProcessAsync` do not: `Process(json, null)` no longer compiles (it is ambiguous with the `JsonRpcStateAsync` overload), and `ProcessAsync(json, null)` binds to the session overload with `json` as the session id and a null document, which throws `ArgumentNullException`. Write `Process(json)`, `Process(json, context: null)` or `ProcessAsync(json, context: null)`. - **DTOs.** `JsonRequest`, `JsonResponse` and `JsonRpcException` are plain DTOs without Json.NET attributes. `JsonRequest.Params` is the active serializer's object model, so cast to `JObject`/`JArray` only when the Json.NET serializer is active. - **SMD.** `SMD.Services` is an `SMDServiceCollection` (an `IDictionary`) instead of a `Dictionary`, and its setter is gone. Every mutation through it updates the dispatch table at once, so a removed method is unreachable immediately. `SMD.Types` is now `Dictionary>` and a process-wide registry (it was reset whenever a session was created). +- **Reserved names.** Names beginning with `rpc.` and the name `$/cancelRequest` are refused at registration on every path (`BindInterface` refused `rpc.` alone before). ## Changes clients will see on the wire +- **Limits.** A document over 4 MiB or a batch with more than 1024 entries is `-32600` with `data = {"limit":…,"maximum":…}` before anything runs; `Config.SetLimits(JsonRpcLimits.Unlimited)` restores the 1.x behaviour. - **Version member.** The `jsonrpc` member is checked (`Config.VersionPolicy`, default `Lenient`): a missing member is still accepted, but `"jsonrpc":"1.0"` or a non-string value is now `-32600`. Set `Ignore` for the 1.x behaviour. - **Parse errors.** Requests nested deeper than 64 levels are `-32700` (configurable per serializer, see [Nesting depth](../README.md#nesting-depth)). Invalid UTF-8 and non-strict JSON (unless the serializer is lenient) are `-32700` as well. - **Batches.** The empty-batch error code is the spec's `-32600` (it was `3200`). Batches made only of notifications produce an empty response instead of `[]` with a dangling comma. A batch always answers with a JSON array when it produces at least one response; a one-request batch is no longer unwrapped to a bare response object.