Skip to content

Commit 30b3836

Browse files
authored
feat: enhanced ws server validating logic on json rpc message object and supported methods (#2470) (#2471)
* feat: added new helper methods Signed-off-by: Logan Nguyen <[email protected]> * feat: added validation for both the JSON-RPC message object and its corresponding methods Signed-off-by: Logan Nguyen <[email protected]> * test: added unit and acceptance tests Signed-off-by: Logan Nguyen <[email protected]> --------- Signed-off-by: Logan Nguyen <[email protected]>
1 parent acec0d9 commit 30b3836

File tree

6 files changed

+320
-20
lines changed

6 files changed

+320
-20
lines changed

packages/ws-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"compile": "tsc -b tsconfig.json",
4848
"acceptancetest": "ts-mocha tests/acceptance/index.spec.ts",
4949
"start": "node dist/index.js",
50-
"test": "",
50+
"test:unit": "nyc ts-mocha --recursive './tests/unit/**/*.spec.ts' --exit",
5151
"integration:prerequisite": "ts-node ./tests/helpers/prerequisite.ts"
5252
},
5353
"nyc": {

packages/ws-server/src/utils/constants.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,22 @@ export const WS_CONSTANTS = {
8989
},
9090
METHODS: {
9191
ETH_CALL: 'eth_call',
92-
ETH_CHAIN_ID: 'eth_chainId',
93-
ETH_GET_LOGS: 'eth_getLogs',
94-
ETH_GET_CODE: 'eth_getCode',
95-
ETH_GAS_PRICE: 'eth_gasPrice',
92+
ETH_CHAINID: 'eth_chainId',
93+
ETH_GETLOGS: 'eth_getLogs',
94+
ETH_GETCODE: 'eth_getCode',
95+
ETH_GASPRICE: 'eth_gasPrice',
9696
ETH_SUBSCRIBE: 'eth_subscribe',
97-
ETH_GET_BALANCE: 'eth_getBalance',
97+
ETH_GETBALANCE: 'eth_getBalance',
9898
ETH_UNSUBSCRIBE: 'eth_unsubscribe',
99-
ETH_BLOCK_NUMBER: 'eth_blockNumber',
100-
ETH_ESTIMATE_GAS: 'eth_estimateGas',
101-
ETH_GET_STORAGE_AT: 'eth_getStorageAt',
102-
ETH_GET_BLOCK_BY_HASH: 'eth_getBlockByHash',
103-
ETH_GET_BLOCK_BY_NUMBER: 'eth_getBlockByNumber',
104-
ETH_SEND_RAW_TRANSACTION: 'eth_sendRawTransaction',
105-
ETH_GET_TRANSACTION_COUNT: 'eth_getTransactionCount',
106-
ETH_GET_TRANSACTION_BY_HASH: 'eth_getTransactionByHash',
107-
ETH_GET_TRANSACTION_RECEIPT: 'eth_getTransactionReceipt',
108-
ETH_MAX_PRIORITY_FEE_PER_GAS: 'eth_maxPriorityFeePerGas',
99+
ETH_BLOCKNUMBER: 'eth_blockNumber',
100+
ETH_ESTIMATEGAS: 'eth_estimateGas',
101+
ETH_GETSTORAGEAT: 'eth_getStorageAt',
102+
ETH_GETBLOCKBYHASH: 'eth_getBlockByHash',
103+
ETH_GETBLOCKBYNUMBER: 'eth_getBlockByNumber',
104+
ETH_SENDRAWTRANSACTION: 'eth_sendRawTransaction',
105+
ETH_GETTRANSACTIONCOUNT: 'eth_getTransactionCount',
106+
ETH_GETTRANSACTIONBYHASH: 'eth_getTransactionByHash',
107+
ETH_GETTRANSACTIONRECEIPT: 'eth_getTransactionReceipt',
108+
ETH_MAXPRIORITYFEEPERGAS: 'eth_maxPriorityFeePerGas',
109109
},
110110
};

packages/ws-server/src/utils/utils.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@
1818
*
1919
*/
2020

21-
import { predefined, Relay } from '@hashgraph/json-rpc-relay';
21+
import { WS_CONSTANTS } from './constants';
2222
import WsMetricRegistry from '../metrics/wsMetricRegistry';
2323
import ConnectionLimiter from '../metrics/connectionLimiter';
24-
import { WS_CONSTANTS } from './constants';
24+
import { predefined, Relay } from '@hashgraph/json-rpc-relay';
25+
26+
const hasOwnProperty = (obj: any, prop: any) => Object.prototype.hasOwnProperty.call(obj, prop);
27+
const getRequestIdIsOptional = () => {
28+
return process.env.REQUEST_ID_IS_OPTIONAL === 'true';
29+
};
2530

2631
/**
2732
* Handles the closure of a WebSocket connection.
@@ -130,6 +135,34 @@ export const handleSendingRequestsToRelay = async (
130135
throw predefined.INTERNAL_ERROR(JSON.stringify(error.message || error));
131136
}
132137
};
138+
/**
139+
* Validates a JSON-RPC request to ensure it has the correct JSON-RPC version, method, and id.
140+
* @param {any} request - The JSON-RPC request object.
141+
* @param {any} logger - The logger instance used for logging.
142+
* @param {string} requestIdPrefix - The prefix to use for the request ID.
143+
* @param {string} connectionIdPrefix - The prefix to use for the connection ID.
144+
* @returns {boolean} A boolean indicating whether the request is valid.
145+
*/
146+
export const validateJsonRpcRequest = (
147+
request: any,
148+
logger: any,
149+
requestIdPrefix: string,
150+
connectionIdPrefix: string,
151+
): boolean => {
152+
if (
153+
request.jsonrpc !== '2.0' ||
154+
!hasOwnProperty(request, 'method') ||
155+
hasInvalidReqestId(request, logger, requestIdPrefix, connectionIdPrefix) ||
156+
!hasOwnProperty(request, 'id')
157+
) {
158+
logger.warn(
159+
`${connectionIdPrefix} ${requestIdPrefix} Invalid request, body.jsonrpc: ${request.jsonrpc}, body[method]: ${request.method}, body[id]: ${request.id}, ctx.request.method: ${request.method}`,
160+
);
161+
return false;
162+
} else {
163+
return true;
164+
}
165+
};
133166

134167
/**
135168
* Resolves parameters based on the provided method.
@@ -139,7 +172,7 @@ export const handleSendingRequestsToRelay = async (
139172
*/
140173
export const resolveParams = (method: string, params: any): any[] => {
141174
switch (method) {
142-
case WS_CONSTANTS.METHODS.ETH_GET_LOGS:
175+
case WS_CONSTANTS.METHODS.ETH_GETLOGS:
143176
return [params[0].blockHash, params[0].fromBlock, params[0].toBlock, params[0].address, params[0].topics];
144177
default:
145178
return params;
@@ -155,3 +188,40 @@ export const resolveParams = (method: string, params: any): any[] => {
155188
export const constructRequestTag = (method: string, params: any): string => {
156189
return JSON.stringify({ method, params });
157190
};
191+
192+
/**
193+
* Verifies if the provided method is supported.
194+
* @param {string} method - The method to verify.
195+
* @returns {boolean} A boolean indicating whether the method is supported.
196+
*/
197+
export const verifySupportedMethod = (method: string): boolean => {
198+
return hasOwnProperty(WS_CONSTANTS.METHODS, method.toUpperCase());
199+
};
200+
201+
/**
202+
* Checks if the JSON-RPC request has an invalid ID.
203+
* @param {any} request - The JSON-RPC request object.
204+
* @param {any} logger - The logger instance used for logging.
205+
* @param {string} requestIdPrefix - The prefix to use for the request ID.
206+
* @param {string} connectionIdPrefix - The prefix to use for the connection ID.
207+
* @returns {boolean} A boolean indicating whether the request ID is invalid.
208+
*/
209+
const hasInvalidReqestId = (
210+
request: any,
211+
logger: any,
212+
requestIdPrefix: string,
213+
connectionIdPrefix: string,
214+
): boolean => {
215+
const hasId = hasOwnProperty(request, 'id');
216+
217+
if (getRequestIdIsOptional() && !hasId) {
218+
// If the request is invalid, we still want to return a valid JSON-RPC response, default id to 0
219+
request.id = '0';
220+
logger.warn(
221+
`${connectionIdPrefix} ${requestIdPrefix} Optional JSON-RPC 2.0 request id encountered. Will continue and default id to 0 in response`,
222+
);
223+
return false;
224+
}
225+
226+
return !hasId;
227+
};

packages/ws-server/src/webSocketServer.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,14 @@ import { Validator } from '@hashgraph/json-rpc-server/dist/validator';
3535
import { handleEthSubsribe, handleEthUnsubscribe } from './controllers';
3636
import jsonResp from '@hashgraph/json-rpc-server/dist/koaJsonRpc/lib/RpcResponse';
3737
import { type Relay, RelayImpl, predefined, JsonRpcError } from '@hashgraph/json-rpc-relay';
38-
import { sendToClient, handleConnectionClose, handleSendingRequestsToRelay } from './utils/utils';
38+
import { InvalidRequest, MethodNotFound } from '@hashgraph/json-rpc-server/dist/koaJsonRpc/lib/RpcError';
39+
import {
40+
sendToClient,
41+
validateJsonRpcRequest,
42+
handleConnectionClose,
43+
verifySupportedMethod,
44+
handleSendingRequestsToRelay,
45+
} from './utils/utils';
3946

4047
const mainLogger = pino({
4148
name: 'hedera-json-rpc-relay',
@@ -113,9 +120,22 @@ app.ws.use(async (ctx) => {
113120
return;
114121
}
115122

123+
// validate request's jsonrpc object
124+
if (!validateJsonRpcRequest(request, logger, requestIdPrefix, connectionIdPrefix)) {
125+
ctx.websocket.send(JSON.stringify(jsonResp(request.id || null, new InvalidRequest(), undefined)));
126+
return;
127+
}
128+
116129
// Extract the method and parameters from the received request
117130
const { method, params } = request;
118131

132+
// verify supported method
133+
if (!verifySupportedMethod(method)) {
134+
ctx.websocket.send(JSON.stringify(jsonResp(request.id || null, new MethodNotFound(method), undefined)));
135+
logger.warn(`${connectionIdPrefix} ${requestIdPrefix}: Method not found: ${method}`);
136+
return;
137+
}
138+
119139
logger.debug(`${connectionIdPrefix} ${requestIdPrefix}: Method: ${method}. Params: ${JSON.stringify(params)}`);
120140

121141
// Increment metrics for the received method
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*-
2+
*
3+
* Hedera JSON RPC Relay
4+
*
5+
* Copyright (C) 2024 Hedera Hashgraph, LLC
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*
19+
*/
20+
21+
// external resources
22+
import WebSocket from 'ws';
23+
import { expect } from 'chai';
24+
import { ethers, WebSocketProvider } from 'ethers';
25+
import { WsTestConstant, WsTestHelper } from '../helper';
26+
import { InvalidRequest, MethodNotFound } from '@hashgraph/json-rpc-server/dist/koaJsonRpc/lib/RpcError';
27+
28+
describe('@web-socket-batch-1 JSON-RPC requests validation', async function () {
29+
const METHOD_NAME = 'eth_blockNumber';
30+
const INVALID_REQUESTS = [
31+
{
32+
id: '1',
33+
method: METHOD_NAME,
34+
params: [],
35+
},
36+
{
37+
id: '1',
38+
jsonrpc: '2.0',
39+
params: [],
40+
},
41+
{
42+
id: '1',
43+
jsonrpc: 'hedera',
44+
method: METHOD_NAME,
45+
params: [],
46+
},
47+
];
48+
49+
const UNSUPPORTED_METHODS = ['eth_getChainId', 'getLogs', 'ethCall', 'blockNum', 'getGasPrice'];
50+
51+
let ethersWsProvider: WebSocketProvider;
52+
53+
beforeEach(async () => {
54+
ethersWsProvider = new ethers.WebSocketProvider(WsTestConstant.WS_RELAY_URL);
55+
});
56+
57+
afterEach(async () => {
58+
if (ethersWsProvider) await ethersWsProvider.destroy();
59+
});
60+
61+
after(async () => {
62+
// expect all the connections to the WS server to be closed after all
63+
expect(global.socketServer._connections).to.eq(0);
64+
});
65+
66+
describe(WsTestConstant.STANDARD_WEB_SOCKET, () => {
67+
beforeEach(() => {
68+
process.env.REQUEST_ID_IS_OPTIONAL = 'true';
69+
});
70+
afterEach(() => {
71+
delete process.env.REQUEST_ID_IS_OPTIONAL;
72+
});
73+
74+
for (const request of INVALID_REQUESTS) {
75+
it('Should reject the requests because of the invalid JSON-RPC requests', async () => {
76+
const webSocket = new WebSocket(WsTestConstant.WS_RELAY_URL);
77+
78+
let response: any;
79+
80+
webSocket.on('open', () => {
81+
webSocket.send(JSON.stringify(request));
82+
});
83+
84+
webSocket.on('message', (data: string) => {
85+
response = JSON.parse(data);
86+
});
87+
88+
while (!response) {
89+
await new Promise((resolve) => setTimeout(resolve, 500));
90+
}
91+
92+
const invalidRequest = new InvalidRequest();
93+
94+
expect(response.error).to.exist;
95+
expect(response.error.message).to.eq(invalidRequest.message);
96+
expect(response.error.code).to.eq(invalidRequest.code);
97+
98+
webSocket.close();
99+
});
100+
}
101+
102+
for (const method of UNSUPPORTED_METHODS) {
103+
it('Should reject the requests because of the invalid JSON-RPC methods', async () => {
104+
const response = await WsTestHelper.sendRequestToStandardWebSocket(method, []);
105+
106+
const methodNotFound = new MethodNotFound(method);
107+
expect(response.error).to.exist;
108+
expect(response.error.message).to.eq(methodNotFound.message);
109+
expect(response.error.code).to.eq(methodNotFound.code);
110+
});
111+
}
112+
});
113+
});

0 commit comments

Comments
 (0)