Skip to content

Commit 160897a

Browse files
authored
Merge pull request #151 from Astn/maintainer-decisions
Implement the six maintainer decisions for 2.0
2 parents cc280db + 66198e2 commit 160897a

23 files changed

Lines changed: 729 additions & 117 deletions

‎AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<Product>Json-Rpc.Net ASP.NET Core host</Product>
77
<Description>ASP.NET Core / Kestrel hosting for JSON-RPC.Net: MapJsonRpc endpoint (PipeReader in, BodyWriter out, no strings), a raw Kestrel ConnectionHandler for JSON-RPC over TCP, and DI registration of services.</Description>
88
<VersionPrefix>2.0.0</VersionPrefix>
9+
<PackageReleaseNotes>https://github.com/Astn/JSON-RPC.NET/blob/master/CHANGELOG.md</PackageReleaseNotes>
910
<VersionSuffix>$(VersionSuffix)</VersionSuffix>
1011
<Copyright>Austin Harris</Copyright>
1112
<PackageProjectUrl>https://github.com/Astn/JSON-RPC.NET</PackageProjectUrl>

‎AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,14 @@ public JsonRpcBinderHostedService(IServiceProvider provider, IEnumerable<JsonRpc
8888

8989
public Task StartAsync(CancellationToken cancellationToken)
9090
{
91-
string defaultSession = Handler.DefaultSessionId();
9291
foreach (var r in _registrations)
9392
{
9493
var instance = _provider.GetRequiredService(r.Type);
9594
// The effective session: the registration's own, then JsonRpcOptions.SessionId, then the default.
96-
var session = r.SessionId ?? _options.SessionId ?? defaultSession;
97-
// A JsonRpcService subclass already bound itself in its constructor, to the default session
98-
// (parameterless base constructor). Bind it here whenever the effective session is a different
99-
// one, otherwise the configured session would answer -32601 for it; rebinding the same
100-
// instance to the same session is harmless (the method table entry is replaced).
101-
if (instance is JsonRpcService && session == defaultSession) continue;
95+
var session = r.SessionId ?? _options.SessionId ?? Handler.DefaultSessionId();
96+
// Always bind, whatever the type: attribute binding replaces entries by name, so binding a
97+
// JsonRpcService subclass that already bound itself to the default session is harmless, and a
98+
// subclass constructed with base(false) is bound nowhere else.
10299
ServiceBinder.BindService(session, instance);
103100
}
104101
return Task.CompletedTask;

‎AustinHarris.JsonRpc.AspNetCore/README.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ app.MapJsonRpc("/rpc").RequireAuthorization("api");
6060

6161
`MapJsonRpc` adds no authorization, TLS requirement, rate limit or request deadline by itself; apply those
6262
policies explicitly. `MaxRequestBytes` limits the HTTP body, but there is no batch-count or response-size limit.
63-
Keep `Config.IncludeExceptionDetails` off for untrusted clients; the default still sends an unhandled exception's
64-
CLR type name and message, see [Exception disclosure](https://github.com/Astn/JSON-RPC.NET#exception-disclosure)
63+
Keep `Config.IncludeExceptionDetails` off for untrusted clients: by default an unhandled exception is answered as
64+
`-32603` with `data: null`, see [Exception disclosure](https://github.com/Astn/JSON-RPC.NET#exception-disclosure)
6565
in the main README.
6666

6767
`MapJsonRpc(pattern = "/jsonrpc", options = null)` uses the options from `AddJsonRpc` unless you pass your own,
@@ -87,9 +87,10 @@ per-request data from the context instead.
8787
declares a `[JsonRpcMethod]`. Private methods count, so the attribute is the whole access list, and an MVC
8888
controller that carries it becomes a singleton too.
8989

90-
A class deriving from `JsonRpcService` binds itself to the default session in its constructor. Registering it here
91-
as well is harmless when the effective session is the default. With `SessionId` set, the host binds it to that
92-
session in addition, so it stays reachable on the default session too.
90+
The host binds every registered service to its effective session: the session given to `AddJsonRpcService`, else
91+
`JsonRpcOptions.SessionId`, else the default. A class deriving from `JsonRpcService` also binds itself to the
92+
default session in its parameterless constructor, so with `SessionId` set it is reachable in both; write
93+
`: base(false)` in the subclass to leave that to the host.
9394

9495
## Raw connection (TCP, Unix socket, named pipe)
9596

@@ -136,7 +137,7 @@ in the main README.
136137
|---|---|---|---|
137138
| `EnableAsyncMethods` | false | HTTP and raw | use `ProcessAsync` for `Task`/`ValueTask` methods, with host cancellation |
138139
| `SessionId` | default session | HTTP and raw | which session's methods answer |
139-
| `SessionSelector` | null | HTTP | pick the session per request from the `HttpContext`; it must map to a fixed set of ids, because an unknown id creates a session that persists |
140+
| `SessionSelector` | null | HTTP | pick the session per request from the `HttpContext`; an id that was never registered answers `-32601` and creates nothing |
140141
| `Serializer` | session, then `Config.Serializer` | HTTP and raw | serializer for this host |
141142
| `ContextFactory` | `HttpContext` | HTTP | what `JsonRpcContext.Current()` returns |
142143
| `MaxRequestBytes` | 4 MB | HTTP body, or one raw document | larger bodies get 413; a larger raw document aborts the connection |

‎AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<Product>Json-Rpc.Net Json.NET serializer</Product>
77
<Description>Json.NET (Newtonsoft.Json) serializer for JSON-RPC.Net. Lenient parsing and full Json.NET conversion semantics; pass JsonSerializerSettings to control it.</Description>
88
<VersionPrefix>2.0.0</VersionPrefix>
9+
<PackageReleaseNotes>https://github.com/Astn/JSON-RPC.NET/blob/master/CHANGELOG.md</PackageReleaseNotes>
910
<VersionSuffix>$(VersionSuffix)</VersionSuffix>
1011
<Copyright>Austin Harris</Copyright>
1112
<PackageProjectUrl>https://github.com/Astn/JSON-RPC.NET</PackageProjectUrl>

‎AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<Product>Json-Rpc.Net System.Text.Json serializer</Product>
77
<Description>System.Text.Json serializer for JSON-RPC.Net. Utf8JsonReader/Utf8JsonWriter end to end; pass JsonSerializerOptions to control it.</Description>
88
<VersionPrefix>2.0.0</VersionPrefix>
9+
<PackageReleaseNotes>https://github.com/Astn/JSON-RPC.NET/blob/master/CHANGELOG.md</PackageReleaseNotes>
910
<VersionSuffix>$(VersionSuffix)</VersionSuffix>
1011
<Copyright>Austin Harris</Copyright>
1112
<PackageProjectUrl>https://github.com/Astn/JSON-RPC.NET</PackageProjectUrl>

‎AustinHarris.JsonRpcTestN/AspNetCoreTests.cs‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ public async Task Tcp_DocumentSplitAcrossWrites_IsReassembled()
194194

195195
// ------------------------------------------------------------------ DI binding honours JsonRpcOptions.SessionId
196196

197-
/// <summary>A JsonRpcService subclass built by DI: its base constructor binds it to the default session.</summary>
197+
/// <summary>A JsonRpcService subclass built by DI: its base constructor binds it to the default session, and the host binds it to the configured one as well.</summary>
198198
public class TenantAutoService : JsonRpcService
199199
{
200200
[JsonRpcMethod("tenant.ping")]
@@ -251,6 +251,46 @@ public async Task Http_JsonRpcServiceSubclass_IsBoundToTheConfiguredSession()
251251
}
252252
}
253253

254+
/// <summary>A JsonRpcService subclass that does not bind itself: the host is its only binder.</summary>
255+
public class TenantUnboundService : JsonRpcService
256+
{
257+
public TenantUnboundService() : base(false) { }
258+
259+
[JsonRpcMethod("tenant.unbound")]
260+
public int Unbound() => 9;
261+
}
262+
263+
[Test]
264+
public async Task Http_JsonRpcServiceSubclass_WithoutAutoBind_IsBoundOnlyToTheConfiguredSession()
265+
{
266+
const string session = "aspnetcore-tenant-unbound";
267+
var builder = WebApplication.CreateBuilder();
268+
builder.Logging.ClearProviders();
269+
builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, 0));
270+
builder.Services.AddJsonRpc(o => o.SessionId = session);
271+
builder.Services.AddJsonRpcService<TenantUnboundService>();
272+
273+
var app = builder.Build();
274+
app.MapJsonRpc("/rpc");
275+
await app.StartAsync();
276+
try
277+
{
278+
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>().Addresses.First();
279+
using var http = new HttpClient { BaseAddress = new Uri(address) };
280+
var response = await http.PostAsync("/rpc", new StringContent(@"{""jsonrpc"":""2.0"",""method"":""tenant.unbound"",""id"":1}", Encoding.UTF8, "application/json"));
281+
Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":9,\"id\":1}", await response.Content.ReadAsStringAsync());
282+
283+
Assert.IsTrue(Handler.GetSessionHandler(session).MetaData.Services.ContainsKey("tenant.unbound"));
284+
Assert.IsFalse(Handler.DefaultHandler.MetaData.Services.ContainsKey("tenant.unbound"), "base(false) keeps it off the default session");
285+
}
286+
finally
287+
{
288+
await app.StopAsync();
289+
await app.DisposeAsync();
290+
Handler.DestroySession(session);
291+
}
292+
}
293+
254294
private static async Task<string> ReadUntilAsync(NetworkStream stream, string terminator)
255295
{
256296
var sb = new StringBuilder();

‎AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,20 @@ public async Task Cancellation_DiscardsDocument_AndWaitsForTerminalState(int pha
413413
public async Task MethodOwnedCancellation_IsAnOrdinaryError()
414414
{
415415
Bind("run", new Func<Task<int>>(async () => { await Task.Yield(); throw new OperationCanceledException("method-owned"); }));
416-
var result = await Run(Request("run"));
417-
Error(result, -32603);
418-
StringAssert.Contains("method-owned", result);
416+
Exception seen = null;
417+
Handler.GetSessionHandler(_session).SetErrorHandler((request, error) => { seen = error.data as Exception; return error; });
418+
try
419+
{
420+
var result = await Run(Request("run"));
421+
Error(result, -32603);
422+
StringAssert.Contains("\"data\":null", result, "an unhandled exception is redacted on the wire");
423+
Assert.IsInstanceOf<OperationCanceledException>(seen, "but it is an ordinary error, not a cancellation of the processing");
424+
Assert.AreEqual("method-owned", seen.Message);
425+
}
426+
finally
427+
{
428+
Handler.GetSessionHandler(_session).SetErrorHandler(null);
429+
}
419430
}
420431

421432
[TestCase(false)] [TestCase(true)]

‎AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs‎

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ private class HardeningService
3939
[JsonRpcMethod("echo")]
4040
public string Echo(string s) => s;
4141

42+
[JsonRpcMethod("unserializable")]
43+
public Unserializable Unserializable() => new Unserializable();
44+
4245
[JsonRpcMethod("accept")]
4346
public int Accept(object o) => 7;
4447

@@ -103,6 +106,12 @@ private class SessionProbe
103106
public string WhichSession() => _name;
104107
}
105108

109+
/// <summary>A result every serializer fails to write: the getter throws.</summary>
110+
public class Unserializable
111+
{
112+
public string Secret => throw new InvalidOperationException("private database /server/secret");
113+
}
114+
106115
private class TaskReturningService
107116
{
108117
[JsonRpcMethod("asyncTask")]
@@ -261,8 +270,18 @@ public void PreProcessHandler_Throwing_IsAnInternalError(string name)
261270
handler.SetPreProcessHandler((request, context) => throw new InvalidOperationException("hook failed"));
262271
var response = Parse(Run("{\"method\":\"ping\",\"id\":1}", null, serializer));
263272
Assert.AreEqual(-32603, (int)response["error"]["code"]);
264-
Assert.AreEqual("hook failed", (string)response["error"]["data"]["Message"]);
273+
Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type, "a hook failure is an unhandled exception: redacted like any other");
265274
Assert.AreEqual(1, (int)response["id"]);
275+
Config.IncludeExceptionDetails = true;
276+
try
277+
{
278+
response = Parse(Run("{\"method\":\"ping\",\"id\":1}", null, serializer));
279+
Assert.AreEqual("hook failed", (string)response["error"]["data"]["Message"]);
280+
}
281+
finally
282+
{
283+
Config.IncludeExceptionDetails = false;
284+
}
266285
}
267286
finally
268287
{
@@ -315,23 +334,45 @@ public void ExceptionDetails_AreRedactedByDefault(string name)
315334
var serializer = SerializerCatalog.Create(name);
316335
Assert.IsFalse(Config.IncludeExceptionDetails, "the default is off");
317336

318-
var response = Parse(Run("{\"method\":\"throws\",\"id\":1}", null, serializer));
319-
Assert.AreEqual(-32603, (int)response["error"]["code"]);
320-
var data = (JObject)response["error"]["data"];
321-
Assert.AreEqual("System.InvalidOperationException", (string)data["ClassName"]);
322-
Assert.AreEqual("private database /server/secret", (string)data["Message"]);
323-
Assert.AreEqual(JTokenType.Null, data["Source"].Type);
324-
Assert.AreEqual(JTokenType.Null, data["StackTraceString"].Type);
325-
Assert.AreEqual(0, (int)data["HResult"]);
326-
Assert.AreEqual(JTokenType.Null, data["InnerException"].Type);
327-
328-
response = Parse(Run("{\"method\":\"throwsInner\",\"id\":1}", null, serializer));
329-
data = (JObject)response["error"]["data"];
330-
Assert.AreEqual("System.ArgumentException", (string)data["ClassName"], "a wrapped exception is reported through its inner exception (unchanged legacy behaviour)");
331-
Assert.AreEqual("inner message", (string)data["Message"]);
332-
Assert.AreEqual(JTokenType.Null, data["StackTraceString"].Type);
333-
Assert.AreEqual(JTokenType.Null, data["InnerException"].Type, "the inner chain is not sent");
334-
StringAssert.DoesNotContain("innermost message", response.ToString());
337+
var raw = Run("{\"method\":\"throws\",\"id\":1}", null, serializer);
338+
Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal Error\",\"data\":null},\"id\":1}", raw,
339+
"nothing about the exception leaves the process: no type name, no message");
340+
341+
raw = Run("{\"method\":\"throwsInner\",\"id\":1}", null, serializer);
342+
Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal Error\",\"data\":null},\"id\":1}", raw);
343+
}
344+
345+
[TestCaseSource(nameof(Serializers))]
346+
public void ExceptionDetails_ErrorHandlerStillSeesTheException(string name)
347+
{
348+
var serializer = SerializerCatalog.Create(name);
349+
var handler = Handler.GetSessionHandler(Session);
350+
Exception seen = null;
351+
try
352+
{
353+
handler.SetErrorHandler((request, error) => { seen = error.data as Exception; return error; });
354+
var response = Parse(Run("{\"method\":\"throws\",\"id\":1}", null, serializer));
355+
Assert.IsInstanceOf<InvalidOperationException>(seen, "the handler gets the exception itself, redaction happens when the response is written");
356+
Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type);
357+
358+
// a handler that replaces the error with its own data is not redacted: that data is authored
359+
handler.SetErrorHandler((request, error) => new JsonRpcException(-32000, "Server error", "ticket 42"));
360+
response = Parse(Run("{\"method\":\"throws\",\"id\":1}", null, serializer));
361+
Assert.AreEqual("ticket 42", (string)response["error"]["data"]);
362+
}
363+
finally
364+
{
365+
handler.SetErrorHandler(null);
366+
}
367+
}
368+
369+
[TestCaseSource(nameof(Serializers))]
370+
public void ExceptionDetails_ResultSerializationFailure_IsRedactedToo(string name)
371+
{
372+
var serializer = SerializerCatalog.Create(name);
373+
var raw = Run("{\"method\":\"unserializable\",\"id\":1}", null, serializer);
374+
Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal Error\",\"data\":null},\"id\":1}", raw,
375+
"an exception thrown while writing the result is an unhandled exception as well");
335376
}
336377

337378
[TestCaseSource(nameof(Serializers))]

‎AustinHarris.JsonRpcTestN/ErrorDataTests.cs‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,25 @@ public void TheSameExceptionFromInsideTheMethod_StaysInternal(string name)
154154
var s = SerializerCatalog.Create(name);
155155
var response = Run("{\"method\":\"ed.parses\",\"params\":[\"abc\"],\"id\":1}", s);
156156
Assert.AreEqual(-32603, (int)response["error"]["code"], "a FormatException thrown by the method is not a binding failure");
157-
Assert.AreEqual("System.FormatException", (string)response["error"]["data"]["ClassName"]);
157+
Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type, "with details off an unhandled exception is data:null");
158158

159159
response = Run("{\"method\":\"ed.throwsFormat\",\"id\":1}", s);
160160
Assert.AreEqual(-32603, (int)response["error"]["code"]);
161-
Assert.AreEqual("from the method", (string)response["error"]["data"]["Message"]);
161+
Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type);
162+
StringAssert.DoesNotContain("from the method", response.ToString());
163+
StringAssert.DoesNotContain("FormatException", response.ToString());
164+
165+
try
166+
{
167+
Config.IncludeExceptionDetails = true;
168+
response = Run("{\"method\":\"ed.throwsFormat\",\"id\":1}", s);
169+
Assert.AreEqual("System.FormatException", (string)response["error"]["data"]["ClassName"]);
170+
Assert.AreEqual("from the method", (string)response["error"]["data"]["Message"]);
171+
}
172+
finally
173+
{
174+
Config.IncludeExceptionDetails = false;
175+
}
162176
}
163177

164178
[TestCaseSource(nameof(Serializers))]
@@ -271,9 +285,12 @@ public void UnsupportedType_StaysInternal()
271285
try
272286
{
273287
ServiceBinder.BindMethod(session, "takesDelegate", new Func<Action, int>(a => 1));
288+
Exception seen = null;
289+
Config.SetErrorHandler(session, (request, error) => { seen = error.data as Exception; return error; });
274290
var response = JObject.Parse(JsonRpcProcessor.ProcessSync(session, "{\"method\":\"takesDelegate\",\"params\":[{}],\"id\":1}", null, SerializerCatalog.Create("jsmn")));
275291
Assert.AreEqual(-32603, (int)response["error"]["code"], response.ToString());
276-
Assert.AreEqual("System.NotSupportedException", (string)response["error"]["data"]["ClassName"]);
292+
Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type, "redacted on the wire");
293+
Assert.IsInstanceOf<NotSupportedException>(seen, "the handler sees why");
277294
}
278295
finally
279296
{

0 commit comments

Comments
 (0)