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/CHANGELOG.md b/CHANGELOG.md index 4d3c266..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. @@ -61,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/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/README.md b/README.md index 555bf2a..7793bd8 100644 --- a/README.md +++ b/README.md @@ -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 22c5353..aa45930 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -15,6 +15,7 @@ Most 1.x services run unchanged. Read the first list before you build, and the s ## 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.