Skip to content

Production readiness and performance audit: Security and optimization improvements #22

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 12, 2025
Merged
37 changes: 22 additions & 15 deletions lib/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@

require_relative "hooks/version"
require_relative "hooks/core/builder"

# Load all core components
Dir[File.join(__dir__, "hooks/core/**/*.rb")].sort.each do |file|
require file
end

# Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.)
Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file|
require file
end

# Load all utils
Dir[File.join(__dir__, "hooks/utils/**/*.rb")].sort.each do |file|
require file
end
require_relative "hooks/core/config_loader"
require_relative "hooks/core/config_validator"
require_relative "hooks/core/logger_factory"
require_relative "hooks/core/plugin_loader"
require_relative "hooks/core/global_components"
require_relative "hooks/core/log"
require_relative "hooks/core/failbot"
require_relative "hooks/core/stats"
require_relative "hooks/plugins/auth/base"
require_relative "hooks/plugins/auth/hmac"
require_relative "hooks/plugins/auth/shared_secret"
require_relative "hooks/plugins/handlers/base"
require_relative "hooks/plugins/handlers/default"
require_relative "hooks/plugins/lifecycle"
require_relative "hooks/plugins/instruments/stats_base"
require_relative "hooks/plugins/instruments/failbot_base"
require_relative "hooks/plugins/instruments/stats"
require_relative "hooks/plugins/instruments/failbot"
require_relative "hooks/utils/normalize"
require_relative "hooks/utils/retry"
require_relative "hooks/security"
require_relative "hooks/version"

# Main module for the Hooks webhook server framework
module Hooks
Expand Down
48 changes: 39 additions & 9 deletions lib/hooks/app/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ def uuid
# @return [void]
# @note Timeout enforcement should be handled at the server level (e.g., Puma)
def enforce_request_limits(config)
# Check content length (handle different header formats and sources)
content_length = headers["Content-Length"] || headers["CONTENT_LENGTH"] ||
headers["content-length"] || headers["HTTP_CONTENT_LENGTH"] ||
env["CONTENT_LENGTH"] || env["HTTP_CONTENT_LENGTH"]
# Optimized content length check - check most common sources first
content_length = request.content_length if respond_to?(:request) && request.respond_to?(:content_length)

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

content_length = content_length&.to_i

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

# Try to parse as JSON if content type suggests it or if it looks like JSON
if content_type&.include?("application/json") || (raw_body.strip.start_with?("{", "[") rescue false)
begin
parsed_payload = JSON.parse(raw_body)
# Security: Limit JSON parsing depth and complexity to prevent JSON bombs
parsed_payload = safe_json_parse(raw_body)
parsed_payload = parsed_payload.transform_keys(&:to_sym) if symbolize && parsed_payload.is_a?(Hash)
return parsed_payload
rescue JSON::ParserError
# If JSON parsing fails, return raw body
rescue JSON::ParserError, ArgumentError => e
# If JSON parsing fails or security limits exceeded, return raw body
if e.message.include?("nesting") || e.message.include?("depth")
log.warn("JSON parsing limit exceeded: #{e.message}")
end
end
end

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

private

# Safely parse JSON
#
# @param json_string [String] The JSON string to parse
# @return [Hash, Array] Parsed JSON object
# @raise [JSON::ParserError] If JSON is invalid
# @raise [ArgumentError] If security limits are exceeded
def safe_json_parse(json_string)
# Security limits for JSON parsing
max_nesting = ENV.fetch("JSON_MAX_NESTING", "20").to_i

# Additional size check before parsing
if json_string.length > ENV.fetch("JSON_MAX_SIZE", "10485760").to_i # 10MB default
raise ArgumentError, "JSON payload too large for parsing"
end

JSON.parse(json_string, {
max_nesting: max_nesting,
create_additions: false, # Security: Disable object creation from JSON
object_class: Hash, # Use plain Hash instead of custom classes
array_class: Array # Use plain Array instead of custom classes
})
end

# Determine HTTP error code from exception
#
# @param exception [Exception] The exception to map to an HTTP status code
Expand Down
43 changes: 43 additions & 0 deletions spec/unit/lib/hooks/app/helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,49 @@ def error!(message, code)
end
end

context "with JSON security limits" do
it "handles deeply nested JSON within limits" do
headers = { "Content-Type" => "application/json" }
# Create a nested JSON structure within reasonable limits
nested_json = '{"level1": {"level2": {"level3": {"value": "test"}}}}'

result = helper.parse_payload(nested_json, headers)

expect(result).to eq({ level1: { "level2" => { "level3" => { "value" => "test" } } } })
end

it "returns raw body when JSON exceeds size limits" do
headers = { "Content-Type" => "application/json" }

# Mock the safe_json_parse method to test the size limit behavior
allow(helper).to receive(:safe_json_parse).and_raise(ArgumentError, "JSON payload too large for parsing")

# Create a JSON string
json_data = '{"data": "test"}'

result = helper.parse_payload(json_data, headers)

# Should return raw body when size limit exceeded
expect(result).to eq(json_data)
end

it "logs debug message when JSON security limits are exceeded" do
headers = { "Content-Type" => "application/json" }

# Mock logger to capture debug messages
logger = instance_double("Logger")
allow(helper).to receive(:log).and_return(logger)
expect(logger).to receive(:warn).with(/JSON parsing limit exceeded/)

# Mock the safe_json_parse method to simulate nesting limit exceeded
allow(helper).to receive(:safe_json_parse).and_raise(ArgumentError, "nesting exceeded")

json_data = '{"data": "test"}'
result = helper.parse_payload(json_data, headers)
expect(result).to eq(json_data)
end
end

context "with non-JSON content" do
it "returns raw body for plain text" do
headers = { "Content-Type" => "text/plain" }
Expand Down