Skip to content

Commit a64baf9

Browse files
authored
Merge pull request #165 from Astn/aus-1004-limits
AUS-1004: Add JsonRpcLimits with document and batch bounds
2 parents 4724600 + cfcc278 commit a64baf9

12 files changed

Lines changed: 609 additions & 29 deletions

File tree

‎AustinHarris.JsonRpcTestN/AspNetCoreTests.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public async Task StartHost()
7272

7373
_app = builder.Build();
7474
_app.MapJsonRpc("/rpc");
75+
_app.MapJsonRpc("/raised-limit", new JsonRpcOptions { MaxRequestBytes = 6 * 1024 * 1024 });
7576
await _app.StartAsync();
7677

7778
var addresses = _app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>().Addresses;
@@ -157,11 +158,28 @@ public async Task Http_DiService_IsBoundAndSeesHttpContext()
157158
[Test]
158159
public async Task Http_LargeBody_Is413()
159160
{
160-
var big = "{\"jsonrpc\":\"2.0\",\"method\":\"internal.echo\",\"params\":[\"" + new string('x', 5 * 1024 * 1024) + "\"],\"id\":1}";
161+
var big = SizedDocument(4 * 1024 * 1024 + 1);
161162
var response = await PostAsync(big);
162163
Assert.AreEqual(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
163164
}
164165

166+
[Test]
167+
public async Task Http_RaisedTransportLimit_UsesCoreDocumentLimit()
168+
{
169+
var response = await _http.PostAsync("/raised-limit",
170+
new StringContent(SizedDocument(4 * 1024 * 1024 + 1), Encoding.UTF8, "application/json"));
171+
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
172+
Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\",\"data\":{\"limit\":\"maxDocumentBytes\",\"maximum\":4194304}},\"id\":null}",
173+
await response.Content.ReadAsStringAsync());
174+
}
175+
176+
private static string SizedDocument(int bytes)
177+
{
178+
const string prefix = "{\"method\":\"IntToInt\",\"params\":[\"";
179+
const string suffix = "\"],\"id\":1}";
180+
return prefix + new string('x', bytes - prefix.Length - suffix.Length) + suffix;
181+
}
182+
165183
[Test]
166184
public async Task Tcp_TwoDocumentsInOneWrite_AreAnsweredInOrder()
167185
{
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
using System;
2+
using System.Buffers;
3+
using System.Text;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using AustinHarris.JsonRpc;
7+
using AustinHarris.JsonRpc.Serialization;
8+
using NUnit.Framework;
9+
10+
namespace AustinHarris.JsonRpcTestN
11+
{
12+
[TestFixture]
13+
[NonParallelizable]
14+
public class LimitsTests
15+
{
16+
private const string Call = "{\"jsonrpc\":\"2.0\",\"method\":\"limits.hit\",\"id\":1}";
17+
private const string Notification = "{\"jsonrpc\":\"2.0\",\"method\":\"limits.hit\"}";
18+
private const string Result = "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}";
19+
private string _session;
20+
private Service _service;
21+
22+
public sealed class Service
23+
{
24+
public int Calls;
25+
[JsonRpcMethod("limits.hit")]
26+
public int Hit() { Interlocked.Increment(ref Calls); return 7; }
27+
}
28+
29+
private sealed class Segment : ReadOnlySequenceSegment<byte>
30+
{
31+
internal Segment(ReadOnlyMemory<byte> memory) { Memory = memory; }
32+
internal Segment Append(ReadOnlyMemory<byte> memory)
33+
{
34+
var next = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
35+
Next = next;
36+
return next;
37+
}
38+
39+
internal Segment AppendAt(long index, ReadOnlyMemory<byte> memory)
40+
{
41+
var next = new Segment(memory) { RunningIndex = index };
42+
Next = next;
43+
return next;
44+
}
45+
}
46+
47+
[SetUp]
48+
public void SetUp()
49+
{
50+
_session = "limits-" + Guid.NewGuid().ToString("N");
51+
_service = new Service();
52+
ServiceBinder.BindService(_session, _service);
53+
Config.SetLimits(JsonRpcLimits.Default);
54+
}
55+
56+
[TearDown]
57+
public void TearDown()
58+
{
59+
Config.SetLimits(JsonRpcLimits.Default);
60+
Handler.DestroySession(_session);
61+
}
62+
63+
private static ReadOnlySequence<byte> Split(byte[] bytes)
64+
{
65+
int middle = bytes.Length / 2;
66+
var first = new Segment(bytes.AsMemory(0, middle));
67+
var last = first.Append(bytes.AsMemory(middle));
68+
return new ReadOnlySequence<byte>(first, 0, last, last.Memory.Length);
69+
}
70+
71+
private static string Text(ArrayBufferWriter<byte> output) => Encoding.UTF8.GetString(output.WrittenSpan);
72+
73+
private static string LimitError(string name, long maximum) =>
74+
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\",\"data\":{\"limit\":\"" +
75+
name + "\",\"maximum\":" + maximum + "}},\"id\":null}";
76+
77+
[TestCase("jsmn")]
78+
[TestCase("newtonsoft")]
79+
[TestCase("stj")]
80+
public async Task DocumentBytes_AllPublicEntriesRejectBeforeDispatch(string serializerName)
81+
{
82+
var serializer = SerializerCatalog.Create(serializerName);
83+
byte[] bytes = Encoding.UTF8.GetBytes(Call);
84+
long maximum = bytes.Length - 1;
85+
string expected = LimitError("maxDocumentBytes", maximum);
86+
Config.SetLimits(new JsonRpcLimits(maximum, 0));
87+
88+
var output = new ArrayBufferWriter<byte>();
89+
var single = new ReadOnlySequence<byte>(bytes);
90+
JsonRpcProcessor.Process(_session, in single, output, serializer: serializer);
91+
Assert.AreEqual(expected, Text(output), "sync single-segment sequence");
92+
output.Clear();
93+
var multi = Split(bytes);
94+
JsonRpcProcessor.Process(_session, in multi, output, serializer: serializer);
95+
Assert.AreEqual(expected, Text(output), "sync multi-segment sequence");
96+
output.Clear();
97+
JsonRpcProcessor.Process(_session, bytes.AsMemory(), output, serializer: serializer);
98+
Assert.AreEqual(expected, Text(output), "sync memory");
99+
output.Clear();
100+
JsonRpcProcessor.Process(_session, bytes.AsSpan(), output, serializer: serializer);
101+
Assert.AreEqual(expected, Text(output), "sync span");
102+
Assert.AreEqual(expected, Encoding.UTF8.GetString(JsonRpcProcessor.ProcessBytes(_session, bytes.AsSpan(), serializer: serializer)));
103+
Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(_session, Call, null, serializer));
104+
Assert.AreEqual(expected, await JsonRpcProcessor.Process(_session, Call, null, serializer));
105+
106+
output.Clear();
107+
await JsonRpcProcessor.ProcessAsync(_session, single, output, serializer: serializer);
108+
Assert.AreEqual(expected, Text(output), "async single-segment sequence");
109+
output.Clear();
110+
await JsonRpcProcessor.ProcessAsync(_session, multi, output, serializer: serializer);
111+
Assert.AreEqual(expected, Text(output), "async multi-segment sequence");
112+
output.Clear();
113+
await JsonRpcProcessor.ProcessAsync(_session, bytes.AsMemory(), output, serializer: serializer);
114+
Assert.AreEqual(expected, Text(output), "async memory");
115+
output.Clear();
116+
await JsonRpcProcessor.ProcessAsync(_session, bytes.AsSpan(), output, serializer: serializer);
117+
Assert.AreEqual(expected, Text(output), "async span");
118+
Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(_session, Call, serializer: serializer));
119+
Assert.AreEqual(0, _service.Calls);
120+
}
121+
122+
[Test]
123+
public async Task DefaultSessionAndStateWrappersInheritByteCheck()
124+
{
125+
Config.SetLimits(new JsonRpcLimits(1, 0));
126+
string expected = LimitError("maxDocumentBytes", 1);
127+
Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(Call));
128+
Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(SerializerCatalog.Create("jsmn"), Call));
129+
Assert.AreEqual(expected, await JsonRpcProcessor.Process(Call));
130+
Assert.AreEqual(expected, await JsonRpcProcessor.Process(SerializerCatalog.Create("jsmn"), Call));
131+
Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(Call));
132+
133+
async Task<string> StateResult(bool defaultSession)
134+
{
135+
var completion = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
136+
var state = new JsonRpcStateAsync(ar => completion.SetResult(((JsonRpcStateAsync)ar).Result), null) { JsonRpc = Call };
137+
if (defaultSession) JsonRpcProcessor.Process(state);
138+
else JsonRpcProcessor.Process(_session, state);
139+
return await completion.Task.WaitAsync(TimeSpan.FromSeconds(5));
140+
}
141+
142+
Assert.AreEqual(expected, await StateResult(false));
143+
Assert.AreEqual(expected, await StateResult(true));
144+
Assert.AreEqual(0, _service.Calls);
145+
}
146+
147+
[Test]
148+
public async Task SequenceLengthIsCheckedBeforeIntConversionOrFlattening()
149+
{
150+
Config.SetLimits(_session, new JsonRpcLimits(1, 0));
151+
var first = new Segment(ReadOnlyMemory<byte>.Empty);
152+
var last = first.AppendAt((long)int.MaxValue + 1, ReadOnlyMemory<byte>.Empty);
153+
var sequence = new ReadOnlySequence<byte>(first, 0, last, 0);
154+
var output = new ArrayBufferWriter<byte>();
155+
JsonRpcProcessor.Process(_session, in sequence, output);
156+
Assert.AreEqual(LimitError("maxDocumentBytes", 1), Text(output));
157+
output.Clear();
158+
await JsonRpcProcessor.ProcessAsync(_session, sequence, output);
159+
Assert.AreEqual(LimitError("maxDocumentBytes", 1), Text(output));
160+
}
161+
162+
[Test]
163+
public void AsyncPreCancellationWinsOverDocumentLimit()
164+
{
165+
Config.SetLimits(_session, new JsonRpcLimits(1, 0));
166+
using var cts = new CancellationTokenSource();
167+
cts.Cancel();
168+
var bytes = Encoding.UTF8.GetBytes(Call);
169+
var output = new ArrayBufferWriter<byte>();
170+
var sequence = new ReadOnlySequence<byte>(bytes);
171+
Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, sequence, output, cancellationToken: cts.Token).IsCanceled);
172+
Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, bytes.AsMemory(), output, cancellationToken: cts.Token).IsCanceled);
173+
Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, bytes.AsSpan(), output, cancellationToken: cts.Token).IsCanceled);
174+
Assert.IsTrue(JsonRpcProcessor.ProcessAsync(_session, Call, cancellationToken: cts.Token).IsCanceled);
175+
Assert.AreEqual(0, output.WrittenCount);
176+
}
177+
178+
[TestCase("jsmn")]
179+
[TestCase("newtonsoft")]
180+
[TestCase("stj")]
181+
public async Task BatchCountRejectsWholeMixedAndNotificationOnlyBatches(string serializerName)
182+
{
183+
var serializer = SerializerCatalog.Create(serializerName);
184+
Config.SetLimits(_session, new JsonRpcLimits(0, 2));
185+
string expected = LimitError("maxBatchCount", 2);
186+
string mixed = "[" + Call + "," + Notification + ",42]";
187+
string notifications = "[" + Notification + "," + Notification + "," + Notification + "]";
188+
foreach (string batch in new[] { mixed, notifications })
189+
{
190+
Assert.AreEqual(expected, JsonRpcProcessor.ProcessSync(_session, batch, null, serializer));
191+
Assert.AreEqual(expected, await JsonRpcProcessor.ProcessAsync(_session, batch, serializer: serializer));
192+
}
193+
Assert.AreEqual(0, _service.Calls);
194+
}
195+
196+
[TestCase("jsmn")]
197+
[TestCase("newtonsoft")]
198+
[TestCase("stj")]
199+
public async Task StringLimitUsesUtf8ByteCount(string serializerName)
200+
{
201+
var serializer = SerializerCatalog.Create(serializerName);
202+
string ascii = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"aa\"}";
203+
string multibyte = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"€€\"}";
204+
int maximum = Encoding.UTF8.GetByteCount(ascii);
205+
Config.SetLimits(_session, new JsonRpcLimits(maximum, 0));
206+
Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, ascii, null, serializer));
207+
Assert.AreEqual(LimitError("maxDocumentBytes", maximum), JsonRpcProcessor.ProcessSync(_session, multibyte, null, serializer));
208+
Assert.AreEqual(LimitError("maxDocumentBytes", maximum), await JsonRpcProcessor.ProcessAsync(_session, multibyte, serializer: serializer));
209+
Assert.AreEqual(1, _service.Calls);
210+
}
211+
212+
[Test]
213+
public void ConfigurationRejectsNegativesAndNullGlobal_AndSessionCanInherit()
214+
{
215+
Assert.AreEqual(4 * 1024 * 1024, JsonRpcLimits.Default.MaxDocumentBytes);
216+
Assert.AreEqual(1024, JsonRpcLimits.Default.MaxBatchCount);
217+
Assert.AreEqual(0, JsonRpcLimits.Unlimited.MaxDocumentBytes);
218+
Assert.AreEqual(0, JsonRpcLimits.Unlimited.MaxBatchCount);
219+
Assert.AreEqual("maxDocumentBytes", Assert.Throws<ArgumentOutOfRangeException>(() => new JsonRpcLimits(-1, 1)).ParamName);
220+
Assert.AreEqual("maxBatchCount", Assert.Throws<ArgumentOutOfRangeException>(() => new JsonRpcLimits(1, -1)).ParamName);
221+
Assert.Throws<ArgumentNullException>(() => Config.SetLimits(null));
222+
223+
var global = new JsonRpcLimits(1, 0);
224+
Config.SetLimits(global);
225+
Config.SetLimits(_session, null);
226+
Assert.AreSame(global, Config.Limits);
227+
Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null));
228+
Config.SetLimits(_session, new JsonRpcLimits(0, 0));
229+
Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, Call, null));
230+
Assert.AreEqual(1, _service.Calls);
231+
Config.SetLimits(_session, null);
232+
Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null));
233+
234+
string created = "limits-created-" + Guid.NewGuid().ToString("N");
235+
try
236+
{
237+
Config.SetLimits(created, null);
238+
Assert.IsTrue(Handler.TryGetSessionHandler(created, out var handler));
239+
Assert.IsNull(handler.Limits);
240+
}
241+
finally { Handler.DestroySession(created); }
242+
}
243+
244+
[Test]
245+
public void ZeroDisablesEachField_AndUnlimitedAcceptsFiveMiB()
246+
{
247+
Config.SetLimits(_session, new JsonRpcLimits(0, 1));
248+
string large = "{\"method\":\"limits.hit\",\"id\":1,\"note\":\"" + new string('x', 5 * 1024 * 1024) + "\"}";
249+
Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, large, null));
250+
Config.SetLimits(_session, new JsonRpcLimits(0, 0));
251+
Assert.AreEqual("[" + Result + "," + Result + "]", JsonRpcProcessor.ProcessSync(_session, "[" + Call + "," + Call + "]", null));
252+
Config.SetLimits(_session, JsonRpcLimits.Unlimited);
253+
Assert.AreEqual(Result, JsonRpcProcessor.ProcessSync(_session, large, null));
254+
Assert.AreEqual(4, _service.Calls);
255+
}
256+
257+
[Test]
258+
public async Task LimitErrorsReachParseHandler_ButNotPreProcessHandler()
259+
{
260+
int parsed = 0, pre = 0;
261+
Config.SetLimits(_session, new JsonRpcLimits(1, 1));
262+
Config.SetParseErrorHandler(_session, (raw, error) =>
263+
{
264+
Assert.IsNotNull(raw);
265+
Assert.AreEqual(-32600, error.code);
266+
Assert.IsInstanceOf<LimitExceededInfo>(error.data);
267+
parsed++;
268+
return error;
269+
});
270+
Config.SetPreProcessHandler(_session, (request, context) => { pre++; return null; });
271+
try
272+
{
273+
Assert.AreEqual(LimitError("maxDocumentBytes", 1), JsonRpcProcessor.ProcessSync(_session, Call, null));
274+
Config.SetLimits(_session, new JsonRpcLimits(0, 1));
275+
Assert.AreEqual(LimitError("maxBatchCount", 1), await JsonRpcProcessor.ProcessAsync(_session, "[" + Call + "," + Call + "]"));
276+
Assert.AreEqual(2, parsed);
277+
Assert.AreEqual(0, pre);
278+
Assert.AreEqual(0, _service.Calls);
279+
}
280+
finally
281+
{
282+
Config.SetParseErrorHandler(_session, null);
283+
Config.SetPreProcessHandler(_session, null);
284+
}
285+
}
286+
}
287+
}

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ behaviour: a breaking change to either means a new major version.
1212

1313
### Added
1414

15+
- `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.
1516
- `ServiceBinder.BindInterface` registers interface trees atomically, with contract naming, filtering, defaults and ownership-aware disposal (`RpcBinding`).
1617
- `ServiceBinder.BindMethod` registers any delegate as a method without attributes or a service class.
1718
- `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.
6162

6263
### Security
6364

65+
- Documents and batches are bounded in the core by default (`JsonRpcLimits`), independent of the transport.
6466
- 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.
6567
- The session registry no longer grows from untrusted session ids on the request path (see Changed).
6668

‎Json-Rpc/Config.cs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ public static JsonRpcVersionPolicy VersionPolicy
6767
set { _versionPolicy = value; }
6868
}
6969

70+
private static volatile JsonRpcLimits _limits = JsonRpcLimits.Default;
71+
72+
/// <summary>The process-wide document and batch limits, used when a session has no override.</summary>
73+
public static JsonRpcLimits Limits => _limits;
74+
75+
/// <summary>Sets the process-wide document and batch limits. Null is not allowed.</summary>
76+
public static void SetLimits(JsonRpcLimits limits)
77+
{
78+
_limits = limits ?? throw new ArgumentNullException(nameof(limits));
79+
}
80+
81+
/// <summary>Sets one session's limits; null makes it inherit <see cref="Limits"/>. Creates the session if needed.</summary>
82+
public static void SetLimits(string sessionId, JsonRpcLimits limits)
83+
{
84+
Handler.GetSessionHandler(sessionId).Limits = limits;
85+
}
86+
7087
/// <summary>Sets the version policy for one session; null makes the session follow <see cref="VersionPolicy"/>.</summary>
7188
public static void SetVersionPolicy(string sessionId, JsonRpcVersionPolicy? policy)
7289
{

0 commit comments

Comments
 (0)