Skip to content

Commit b41c412

Browse files
Merge pull request #23 from Shopify/validate-request-id-formatting
ensure request ids contain alphanumerics,dashes,underscores
2 parents 1014fb0 + a5cf618 commit b41c412

File tree

3 files changed

+257
-23
lines changed

3 files changed

+257
-23
lines changed

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,54 @@ method returned `nil`, or all method calls of a batch request returned `nil`. It
8282
is up to the integration to apply the appropriate transport-layer semantics
8383
(e.g. returning a 204 No Content).
8484

85+
### ID Validation
86+
87+
By default, string request IDs are validated to contain only alphanumeric
88+
characters, dashes, and underscores.
89+
90+
**Note:** The JSON-RPC 2.0 specification does not specify a default ID validation pattern, but this default validation
91+
is recommended to protect against XSS vulnerabilities when IDs are reflected in responses.
92+
93+
```rb
94+
# Default behavior - accepts alphanumerics, dashes, underscores
95+
request = { jsonrpc: '2.0', id: 'request-123_abc', method: 'add', params: {a: 1, b: 2} }
96+
JsonRpcHandler.handle(request) { |method_name| ... }
97+
# => {"jsonrpc":"2.0","id":"request-123_abc","result":3}
98+
99+
# Rejects potentially dangerous characters
100+
request = { jsonrpc: '2.0', id: '<script>alert("xss")</script>', method: 'add', params: {a: 1, b: 2} }
101+
JsonRpcHandler.handle(request) { |method_name| ... }
102+
# => {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request","data":"Request ID must match validation pattern, or be an integer or null"}}
103+
```
104+
105+
You can customize the validation pattern by passing the `id_validation_pattern`
106+
parameter:
107+
108+
```rb
109+
# Allow email-like IDs with a custom pattern
110+
custom_pattern = /\A[a-zA-Z0-9_.\-@]+\z/
111+
request = { jsonrpc: '2.0', id: 'user@example.com', method: 'add', params: {a: 1, b: 2} }
112+
113+
JsonRpcHandler.handle(request, id_validation_pattern: custom_pattern) do |method_name|
114+
# ...
115+
end
116+
117+
# Also works with handle_json
118+
JsonRpcHandler.handle_json(request_json, id_validation_pattern: custom_pattern) do |method_name|
119+
# ...
120+
end
121+
```
122+
123+
To disable ID validation entirely (not recommended), pass
124+
`nil` as the pattern:
125+
126+
```rb
127+
# Accepts any string
128+
JsonRpcHandler.handle(request, id_validation_pattern: nil) do |method_name|
129+
# ...
130+
end
131+
```
132+
85133
## Development
86134

87135
After checking out the repo:

lib/json_rpc_handler.rb

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,21 @@ class ErrorCode
1717
ParseError = -32700
1818
end
1919

20+
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/.freeze
21+
2022
module_function
2123

22-
def handle(request, &method_finder)
24+
def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
25+
2326
if request.is_a? Array
24-
return error_response id: :unknown_id, error: {
27+
return error_response id: :unknown_id, id_validation_pattern:, error: {
2528
code: ErrorCode::InvalidRequest,
2629
message: 'Invalid Request',
2730
data: 'Request is an empty array',
2831
} if request.empty?
2932

3033
# Handle batch requests
31-
responses = request.map { |req| process_request req, &method_finder }.compact
34+
responses = request.map { |req| process_request req, id_validation_pattern:, &method_finder }.compact
3235

3336
# A single item is hoisted out of the array
3437
return responses.first if responses.one?
@@ -37,22 +40,22 @@ def handle(request, &method_finder)
3740
responses if responses.any?
3841
elsif request.is_a? Hash
3942
# Handle single request
40-
process_request request, &method_finder
43+
process_request request, id_validation_pattern:, &method_finder
4144
else
42-
error_response id: :unknown_id, error: {
45+
error_response id: :unknown_id, id_validation_pattern:, error: {
4346
code: ErrorCode::InvalidRequest,
4447
message: 'Invalid Request',
4548
data: 'Request must be an array or a hash',
4649
}
4750
end
4851
end
4952

50-
def handle_json(request_json, &method_finder)
53+
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
5154
begin
5255
request = JSON.parse request_json, symbolize_names: true
53-
response = handle request, &method_finder
56+
response = handle request, id_validation_pattern:, &method_finder
5457
rescue JSON::ParserError
55-
response =error_response id: :unknown_id, error: {
58+
response = error_response id: :unknown_id, id_validation_pattern:, error: {
5659
code: ErrorCode::ParseError,
5760
message: 'Parse error',
5861
data: 'Invalid JSON',
@@ -62,16 +65,16 @@ def handle_json(request_json, &method_finder)
6265
response.to_json if response
6366
end
6467

65-
def process_request(request, &method_finder)
68+
def process_request(request, id_validation_pattern:, &method_finder)
6669
id = request[:id]
6770

6871
error = case
6972
when !valid_version?(request[:jsonrpc]) then 'JSON-RPC version must be 2.0'
70-
when !valid_id?(request[:id]) then 'Request ID must be a string or an integer or null'
73+
when !valid_id?(request[:id], id_validation_pattern) then 'Request ID must match validation pattern, or be an integer or null'
7174
when !valid_method_name?(request[:method]) then 'Method name must be a string and not start with "rpc."'
7275
end
7376

74-
return error_response id: :unknown_id, error: {
77+
return error_response id: :unknown_id, id_validation_pattern:, error: {
7578
code: ErrorCode::InvalidRequest,
7679
message: 'Invalid Request',
7780
data: error,
@@ -81,7 +84,7 @@ def process_request(request, &method_finder)
8184
params = request[:params]
8285

8386
unless valid_params? params
84-
return error_response id:, error: {
87+
return error_response id:, id_validation_pattern:, error: {
8588
code: ErrorCode::InvalidParams,
8689
message: 'Invalid params',
8790
data: 'Method parameters must be an array or an object or null',
@@ -92,7 +95,7 @@ def process_request(request, &method_finder)
9295
method = method_finder.call method_name
9396

9497
if method.nil?
95-
return error_response id:, error: {
98+
return error_response id:, id_validation_pattern:, error: {
9699
code: ErrorCode::MethodNotFound,
97100
message: 'Method not found',
98101
data: method_name,
@@ -103,7 +106,7 @@ def process_request(request, &method_finder)
103106

104107
success_response id:, result:
105108
rescue StandardError => e
106-
error_response id:, error: {
109+
error_response id:, id_validation_pattern:, error: {
107110
code: ErrorCode::InternalError,
108111
message: 'Internal error',
109112
data: e.message,
@@ -115,8 +118,12 @@ def valid_version?(version)
115118
version == Version::V2_0
116119
end
117120

118-
def valid_id?(id)
119-
id.is_a?(String) || id.is_a?(Integer) || id.nil?
121+
122+
def valid_id?(id, pattern = nil)
123+
return true if id.nil? || id.is_a?(Integer)
124+
return false unless id.is_a?(String)
125+
126+
pattern ? id.match?(pattern) : true
120127
end
121128

122129
def valid_method_name?(method)
@@ -135,10 +142,10 @@ def success_response(id:, result:)
135142
} unless id.nil?
136143
end
137144

138-
def error_response(id:, error:)
145+
def error_response(id:, id_validation_pattern:, error:)
139146
{
140147
jsonrpc: Version::V2_0,
141-
id: valid_id?(id) ? id : nil,
148+
id: valid_id?(id, id_validation_pattern) ? id : nil,
142149
error: error.compact,
143150
} unless id.nil?
144151
end

0 commit comments

Comments
 (0)