Skip to content

Commit e7ee80d

Browse files
committed
rpc: JSON-RPC 2.0 should not respond to "notifications"
For JSON-RPC 2.0 requests we need to distinguish between a missing "id" field and "id":null. This is accomplished by making the JSONRPCRequest id property a std::optional<UniValue> with a default value of UniValue::VNULL. A side-effect of this change for non-2.0 requests is that request which do not specify an "id" field will no longer return "id": null in the response.
1 parent bf1a1f1 commit e7ee80d

File tree

6 files changed

+67
-17
lines changed

6 files changed

+67
-17
lines changed

src/httprpc.cpp

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ static bool HTTPReq_JSONRPC(const std::any& context, HTTPRequest* req)
211211
const bool catch_errors{jreq.m_json_version == JSONRPCVersion::V2};
212212
reply = JSONRPCExec(jreq, catch_errors);
213213

214+
if (jreq.IsNotification()) {
215+
// Even though we do execute notifications, we do not respond to them
216+
req->WriteReply(HTTP_NO_CONTENT);
217+
return true;
218+
}
219+
214220
// array of requests
215221
} else if (valRequest.isArray()) {
216222
// Check authorization for each request's method
@@ -235,15 +241,32 @@ static bool HTTPReq_JSONRPC(const std::any& context, HTTPRequest* req)
235241
reply = UniValue::VARR;
236242
for (size_t i{0}; i < valRequest.size(); ++i) {
237243
// Batches never throw HTTP errors, they are always just included
238-
// in "HTTP OK" responses.
244+
// in "HTTP OK" responses. Notifications never get any response.
245+
UniValue response;
239246
try {
240247
jreq.parse(valRequest[i]);
241-
reply.push_back(JSONRPCExec(jreq, /*catch_errors=*/true));
248+
response = JSONRPCExec(jreq, /*catch_errors=*/true);
242249
} catch (UniValue& e) {
243-
reply.push_back(JSONRPCReplyObj(NullUniValue, std::move(e), jreq.id, jreq.m_json_version));
250+
response = JSONRPCReplyObj(NullUniValue, std::move(e), jreq.id, jreq.m_json_version);
244251
} catch (const std::exception& e) {
245-
reply.push_back(JSONRPCReplyObj(NullUniValue, JSONRPCError(RPC_PARSE_ERROR, e.what()), jreq.id, jreq.m_json_version));
252+
response = JSONRPCReplyObj(NullUniValue, JSONRPCError(RPC_PARSE_ERROR, e.what()), jreq.id, jreq.m_json_version);
246253
}
254+
if (!jreq.IsNotification()) {
255+
reply.push_back(std::move(response));
256+
}
257+
}
258+
// Return no response for an all-notification batch, but only if the
259+
// batch request is non-empty. Technically according to the JSON-RPC
260+
// 2.0 spec, an empty batch request should also return no response,
261+
// However, if the batch request is empty, it means the request did
262+
// not contain any JSON-RPC version numbers, so returning an empty
263+
// response could break backwards compatibility with old RPC clients
264+
// relying on previous behavior. Return an empty array instead of an
265+
// empty response in this case to favor being backwards compatible
266+
// over complying with the JSON-RPC 2.0 spec in this case.
267+
if (reply.size() == 0 && valRequest.size() > 0) {
268+
req->WriteReply(HTTP_NO_CONTENT);
269+
return true;
247270
}
248271
}
249272
else

src/rpc/protocol.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
enum HTTPStatusCode
1111
{
1212
HTTP_OK = 200,
13+
HTTP_NO_CONTENT = 204,
1314
HTTP_BAD_REQUEST = 400,
1415
HTTP_UNAUTHORIZED = 401,
1516
HTTP_FORBIDDEN = 403,

src/rpc/request.cpp

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ UniValue JSONRPCRequestObj(const std::string& strMethod, const UniValue& params,
3737
return request;
3838
}
3939

40-
UniValue JSONRPCReplyObj(UniValue result, UniValue error, UniValue id, JSONRPCVersion jsonrpc_version)
40+
UniValue JSONRPCReplyObj(UniValue result, UniValue error, std::optional<UniValue> id, JSONRPCVersion jsonrpc_version)
4141
{
4242
UniValue reply(UniValue::VOBJ);
4343
// Add JSON-RPC version number field in v2 only.
@@ -52,7 +52,7 @@ UniValue JSONRPCReplyObj(UniValue result, UniValue error, UniValue id, JSONRPCVe
5252
if (jsonrpc_version == JSONRPCVersion::V1_LEGACY) reply.pushKV("result", NullUniValue);
5353
reply.pushKV("error", std::move(error));
5454
}
55-
reply.pushKV("id", std::move(id));
55+
if (id.has_value()) reply.pushKV("id", std::move(id.value()));
5656
return reply;
5757
}
5858

@@ -172,7 +172,11 @@ void JSONRPCRequest::parse(const UniValue& valRequest)
172172
const UniValue& request = valRequest.get_obj();
173173

174174
// Parse id now so errors from here on will have the id
175-
id = request.find_value("id");
175+
if (request.exists("id")) {
176+
id = request.find_value("id");
177+
} else {
178+
id = std::nullopt;
179+
}
176180

177181
// Check for JSON-RPC 2.0 (default 1.1)
178182
m_json_version = JSONRPCVersion::V1_LEGACY;

src/rpc/request.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#define BITCOIN_RPC_REQUEST_H
88

99
#include <any>
10+
#include <optional>
1011
#include <string>
1112

1213
#include <univalue.h>
@@ -17,7 +18,7 @@ enum class JSONRPCVersion {
1718
};
1819

1920
UniValue JSONRPCRequestObj(const std::string& strMethod, const UniValue& params, const UniValue& id);
20-
UniValue JSONRPCReplyObj(UniValue result, UniValue error, UniValue id, JSONRPCVersion jsonrpc_version);
21+
UniValue JSONRPCReplyObj(UniValue result, UniValue error, std::optional<UniValue> id, JSONRPCVersion jsonrpc_version);
2122
UniValue JSONRPCError(int code, const std::string& message);
2223

2324
/** Generate a new RPC authentication cookie and write it to disk */
@@ -32,7 +33,7 @@ std::vector<UniValue> JSONRPCProcessBatchReply(const UniValue& in);
3233
class JSONRPCRequest
3334
{
3435
public:
35-
UniValue id;
36+
std::optional<UniValue> id = UniValue::VNULL;
3637
std::string strMethod;
3738
UniValue params;
3839
enum Mode { EXECUTE, GET_HELP, GET_ARGS } mode = EXECUTE;
@@ -43,6 +44,7 @@ class JSONRPCRequest
4344
JSONRPCVersion m_json_version = JSONRPCVersion::V1_LEGACY;
4445

4546
void parse(const UniValue& valRequest);
47+
[[nodiscard]] bool IsNotification() const { return !id.has_value() && m_json_version == JSONRPCVersion::V2; };
4648
};
4749

4850
#endif // BITCOIN_RPC_REQUEST_H

test/functional/interface_rpc.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ def format_request(options, idx, fields):
4646

4747

4848
def format_response(options, idx, fields):
49+
if options.version == 2 and options.notification:
50+
return None
4951
response = {}
50-
response.update(id=None if options.notification else idx)
52+
if not options.notification:
53+
response.update(id=idx)
5154
if options.version == 2:
5255
response.update(jsonrpc="2.0")
5356
else:
@@ -129,11 +132,17 @@ def test_batch_request(self, call_options):
129132
if options is None:
130133
continue
131134
request.append(format_request(options, idx, call))
132-
response.append(format_response(options, idx, result))
135+
r = format_response(options, idx, result)
136+
if r is not None:
137+
response.append(r)
133138

134139
rpc_response, http_status = send_json_rpc(self.nodes[0], request)
135-
assert_equal(http_status, 200)
136-
assert_equal(rpc_response, response)
140+
if len(response) == 0 and len(request) > 0:
141+
assert_equal(http_status, 204)
142+
assert_equal(rpc_response, None)
143+
else:
144+
assert_equal(http_status, 200)
145+
assert_equal(rpc_response, response)
137146

138147
def test_batch_requests(self):
139148
self.log.info("Testing empty batch request...")
@@ -193,10 +202,10 @@ def test_http_status_codes(self):
193202
expect_http_rpc_status(200, RPC_INVALID_PARAMETER, self.nodes[0], "getblockhash", [42], 2, False)
194203
# force-send invalidly formatted requests
195204
response, status = send_json_rpc(self.nodes[0], {"jsonrpc": 2, "method": "getblockcount"})
196-
assert_equal(response, {"id": None, "result": None, "error": {"code": RPC_INVALID_REQUEST, "message": "jsonrpc field must be a string"}})
205+
assert_equal(response, {"result": None, "error": {"code": RPC_INVALID_REQUEST, "message": "jsonrpc field must be a string"}})
197206
assert_equal(status, 400)
198207
response, status = send_json_rpc(self.nodes[0], {"jsonrpc": "3.0", "method": "getblockcount"})
199-
assert_equal(response, {"id": None, "result": None, "error": {"code": RPC_INVALID_REQUEST, "message": "JSON-RPC version not supported"}})
208+
assert_equal(response, {"result": None, "error": {"code": RPC_INVALID_REQUEST, "message": "JSON-RPC version not supported"}})
200209
assert_equal(status, 400)
201210

202211
self.log.info("Testing HTTP status codes for JSON-RPC 2.0 notifications...")
@@ -209,10 +218,12 @@ def test_http_status_codes(self):
209218
# Not notification: has "id" field
210219
expect_http_rpc_status(200, None, self.nodes[0], "getblockcount", [], 2, False)
211220
block_count = self.nodes[0].getblockcount()
212-
expect_http_rpc_status(200, None, self.nodes[0], "generatetoaddress", [1, "bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202"], 2, True)
221+
# Notification response status code: HTTP_NO_CONTENT
222+
expect_http_rpc_status(204, None, self.nodes[0], "generatetoaddress", [1, "bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202"], 2, True)
213223
# The command worked even though there was no response
214224
assert_equal(block_count + 1, self.nodes[0].getblockcount())
215-
expect_http_rpc_status(200, RPC_INVALID_ADDRESS_OR_KEY, self.nodes[0], "generatetoaddress", [1, "invalid_address"], 2, True)
225+
# No error response for notifications even if they are invalid
226+
expect_http_rpc_status(204, None, self.nodes[0], "generatetoaddress", [1, "invalid_address"], 2, True)
216227
# Sanity check: command was not executed
217228
assert_equal(block_count + 1, self.nodes[0].getblockcount())
218229

test/functional/test_framework/authproxy.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,15 @@ def _get_response(self):
160160
raise JSONRPCException({
161161
'code': -342, 'message': 'missing HTTP response from server'})
162162

163+
# Check for no-content HTTP status code, which can be returned when an
164+
# RPC client requests a JSON-RPC 2.0 "notification" with no response.
165+
# Currently this is only possible if clients call the _request() method
166+
# directly to send a raw request.
167+
if http_response.status == HTTPStatus.NO_CONTENT:
168+
if len(http_response.read()) != 0:
169+
raise JSONRPCException({'code': -342, 'message': 'Content received with NO CONTENT status code'})
170+
return None, http_response.status
171+
163172
content_type = http_response.getheader('Content-Type')
164173
if content_type != 'application/json':
165174
raise JSONRPCException(

0 commit comments

Comments
 (0)