Skip to content

Commit 3e3a9e7

Browse files
authored
Merge pull request #22 from github/copilot/fix-21
Production readiness and performance audit: Security and optimization improvements
2 parents a337efc + 3a2e0d1 commit 3e3a9e7

File tree

3 files changed

+104
-24
lines changed

3 files changed

+104
-24
lines changed

lib/hooks.rb

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,28 @@
22

33
require_relative "hooks/version"
44
require_relative "hooks/core/builder"
5-
6-
# Load all core components
7-
Dir[File.join(__dir__, "hooks/core/**/*.rb")].sort.each do |file|
8-
require file
9-
end
10-
11-
# Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.)
12-
Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file|
13-
require file
14-
end
15-
16-
# Load all utils
17-
Dir[File.join(__dir__, "hooks/utils/**/*.rb")].sort.each do |file|
18-
require file
19-
end
5+
require_relative "hooks/core/config_loader"
6+
require_relative "hooks/core/config_validator"
7+
require_relative "hooks/core/logger_factory"
8+
require_relative "hooks/core/plugin_loader"
9+
require_relative "hooks/core/global_components"
10+
require_relative "hooks/core/log"
11+
require_relative "hooks/core/failbot"
12+
require_relative "hooks/core/stats"
13+
require_relative "hooks/plugins/auth/base"
14+
require_relative "hooks/plugins/auth/hmac"
15+
require_relative "hooks/plugins/auth/shared_secret"
16+
require_relative "hooks/plugins/handlers/base"
17+
require_relative "hooks/plugins/handlers/default"
18+
require_relative "hooks/plugins/lifecycle"
19+
require_relative "hooks/plugins/instruments/stats_base"
20+
require_relative "hooks/plugins/instruments/failbot_base"
21+
require_relative "hooks/plugins/instruments/stats"
22+
require_relative "hooks/plugins/instruments/failbot"
23+
require_relative "hooks/utils/normalize"
24+
require_relative "hooks/utils/retry"
25+
require_relative "hooks/security"
26+
require_relative "hooks/version"
2027

2128
# Main module for the Hooks webhook server framework
2229
module Hooks

lib/hooks/app/helpers.rb

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ def uuid
2121
# @return [void]
2222
# @note Timeout enforcement should be handled at the server level (e.g., Puma)
2323
def enforce_request_limits(config)
24-
# Check content length (handle different header formats and sources)
25-
content_length = headers["Content-Length"] || headers["CONTENT_LENGTH"] ||
26-
headers["content-length"] || headers["HTTP_CONTENT_LENGTH"] ||
27-
env["CONTENT_LENGTH"] || env["HTTP_CONTENT_LENGTH"]
24+
# Optimized content length check - check most common sources first
25+
content_length = request.content_length if respond_to?(:request) && request.respond_to?(:content_length)
2826

29-
# Also try to get from request object directly
30-
content_length ||= request.content_length if respond_to?(:request) && request.respond_to?(:content_length)
27+
content_length ||= headers["Content-Length"] ||
28+
headers["CONTENT_LENGTH"] ||
29+
headers["content-length"] ||
30+
headers["HTTP_CONTENT_LENGTH"] ||
31+
env["CONTENT_LENGTH"] ||
32+
env["HTTP_CONTENT_LENGTH"]
3133

3234
content_length = content_length&.to_i
3335

@@ -45,16 +47,21 @@ def enforce_request_limits(config)
4547
# @param symbolize [Boolean] Whether to symbolize keys in parsed JSON (default: true)
4648
# @return [Hash, String] Parsed JSON as Hash (optionally symbolized), or raw body if not JSON
4749
def parse_payload(raw_body, headers, symbolize: true)
50+
# Optimized content type check - check most common header first
4851
content_type = headers["Content-Type"] || headers["CONTENT_TYPE"] || headers["content-type"] || headers["HTTP_CONTENT_TYPE"]
4952

5053
# Try to parse as JSON if content type suggests it or if it looks like JSON
5154
if content_type&.include?("application/json") || (raw_body.strip.start_with?("{", "[") rescue false)
5255
begin
53-
parsed_payload = JSON.parse(raw_body)
56+
# Security: Limit JSON parsing depth and complexity to prevent JSON bombs
57+
parsed_payload = safe_json_parse(raw_body)
5458
parsed_payload = parsed_payload.transform_keys(&:to_sym) if symbolize && parsed_payload.is_a?(Hash)
5559
return parsed_payload
56-
rescue JSON::ParserError
57-
# If JSON parsing fails, return raw body
60+
rescue JSON::ParserError, ArgumentError => e
61+
# If JSON parsing fails or security limits exceeded, return raw body
62+
if e.message.include?("nesting") || e.message.include?("depth")
63+
log.warn("JSON parsing limit exceeded: #{e.message}")
64+
end
5865
end
5966
end
6067

@@ -79,6 +86,29 @@ def load_handler(handler_class_name)
7986

8087
private
8188

89+
# Safely parse JSON
90+
#
91+
# @param json_string [String] The JSON string to parse
92+
# @return [Hash, Array] Parsed JSON object
93+
# @raise [JSON::ParserError] If JSON is invalid
94+
# @raise [ArgumentError] If security limits are exceeded
95+
def safe_json_parse(json_string)
96+
# Security limits for JSON parsing
97+
max_nesting = ENV.fetch("JSON_MAX_NESTING", "20").to_i
98+
99+
# Additional size check before parsing
100+
if json_string.length > ENV.fetch("JSON_MAX_SIZE", "10485760").to_i # 10MB default
101+
raise ArgumentError, "JSON payload too large for parsing"
102+
end
103+
104+
JSON.parse(json_string, {
105+
max_nesting: max_nesting,
106+
create_additions: false, # Security: Disable object creation from JSON
107+
object_class: Hash, # Use plain Hash instead of custom classes
108+
array_class: Array # Use plain Array instead of custom classes
109+
})
110+
end
111+
82112
# Determine HTTP error code from exception
83113
#
84114
# @param exception [Exception] The exception to map to an HTTP status code

spec/unit/lib/hooks/app/helpers_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,49 @@ def error!(message, code)
204204
end
205205
end
206206

207+
context "with JSON security limits" do
208+
it "handles deeply nested JSON within limits" do
209+
headers = { "Content-Type" => "application/json" }
210+
# Create a nested JSON structure within reasonable limits
211+
nested_json = '{"level1": {"level2": {"level3": {"value": "test"}}}}'
212+
213+
result = helper.parse_payload(nested_json, headers)
214+
215+
expect(result).to eq({ level1: { "level2" => { "level3" => { "value" => "test" } } } })
216+
end
217+
218+
it "returns raw body when JSON exceeds size limits" do
219+
headers = { "Content-Type" => "application/json" }
220+
221+
# Mock the safe_json_parse method to test the size limit behavior
222+
allow(helper).to receive(:safe_json_parse).and_raise(ArgumentError, "JSON payload too large for parsing")
223+
224+
# Create a JSON string
225+
json_data = '{"data": "test"}'
226+
227+
result = helper.parse_payload(json_data, headers)
228+
229+
# Should return raw body when size limit exceeded
230+
expect(result).to eq(json_data)
231+
end
232+
233+
it "logs debug message when JSON security limits are exceeded" do
234+
headers = { "Content-Type" => "application/json" }
235+
236+
# Mock logger to capture debug messages
237+
logger = instance_double("Logger")
238+
allow(helper).to receive(:log).and_return(logger)
239+
expect(logger).to receive(:warn).with(/JSON parsing limit exceeded/)
240+
241+
# Mock the safe_json_parse method to simulate nesting limit exceeded
242+
allow(helper).to receive(:safe_json_parse).and_raise(ArgumentError, "nesting exceeded")
243+
244+
json_data = '{"data": "test"}'
245+
result = helper.parse_payload(json_data, headers)
246+
expect(result).to eq(json_data)
247+
end
248+
end
249+
207250
context "with non-JSON content" do
208251
it "returns raw body for plain text" do
209252
headers = { "Content-Type" => "text/plain" }

0 commit comments

Comments
 (0)