Skip to content

Commit f2754cf

Browse files
CopilotGrantBirki
andcommitted
Security fixes: timing attacks, plugin loading, config validation
Co-authored-by: GrantBirki <[email protected]>
1 parent e065571 commit f2754cf

File tree

9 files changed

+241
-16
lines changed

9 files changed

+241
-16
lines changed

lib/hooks/app/auth/auth.rb

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ def validate_auth!(payload, headers, endpoint_config, global_config = {}, reques
2424
auth_config = endpoint_config[:auth]
2525
request_id = request_context&.dig(:request_id)
2626

27-
# Ensure auth type is present and valid
28-
auth_type = auth_config&.dig(:type)
29-
unless auth_type&.is_a?(String) && !auth_type.strip.empty?
27+
# Validate auth configuration structure
28+
unless auth_config.is_a?(Hash) && !auth_config.empty?
3029
log.error("authentication configuration missing or invalid - request_id: #{request_id}")
3130
error!({
3231
error: "authentication_configuration_error",
@@ -35,6 +34,28 @@ def validate_auth!(payload, headers, endpoint_config, global_config = {}, reques
3534
}, 500)
3635
end
3736

37+
# Ensure auth type is present and valid
38+
auth_type = auth_config[:type]
39+
unless auth_type.is_a?(String) && !auth_type.strip.empty?
40+
log.error("authentication type missing or invalid - request_id: #{request_id}")
41+
error!({
42+
error: "authentication_configuration_error",
43+
message: "authentication configuration missing or invalid",
44+
request_id:
45+
}, 500)
46+
end
47+
48+
# Sanitize auth type to prevent potential exploits
49+
auth_type = auth_type.strip.downcase
50+
unless auth_type.match?(/\A[a-z0-9_]+\z/)
51+
log.error("authentication type contains invalid characters - request_id: #{request_id}")
52+
error!({
53+
error: "authentication_configuration_error",
54+
message: "authentication configuration contains invalid characters",
55+
request_id:
56+
}, 400)
57+
end
58+
3859
# Get auth plugin from loaded plugins registry (boot-time loaded only)
3960
begin
4061
auth_class = Core::PluginLoader.get_auth_plugin(auth_type)

lib/hooks/app/helpers.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,19 @@ def ip_filtering!(headers, endpoint_config, global_config, request_context, env)
122122
def safe_json_parse(json_string)
123123
# Security limits for JSON parsing
124124
max_nesting = ENV.fetch("JSON_MAX_NESTING", "20").to_i
125+
max_size = ENV.fetch("JSON_MAX_SIZE", "10485760").to_i # 10MB default
126+
127+
# Validate security limits
128+
if max_nesting <= 0 || max_nesting > 100
129+
raise ArgumentError, "Invalid JSON_MAX_NESTING value: must be between 1 and 100"
130+
end
131+
132+
if max_size <= 0 || max_size > 100 * 1024 * 1024 # 100MB max
133+
raise ArgumentError, "Invalid JSON_MAX_SIZE value: must be between 1 and 104857600 bytes"
134+
end
125135

126136
# Additional size check before parsing
127-
if json_string.length > ENV.fetch("JSON_MAX_SIZE", "10485760").to_i # 10MB default
137+
if json_string.length > max_size
128138
raise ArgumentError, "JSON payload too large for parsing"
129139
end
130140

lib/hooks/core/config_loader.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ def self.load_config_file(file_path)
107107
when ".json"
108108
JSON.parse(content)
109109
when ".yml", ".yaml"
110-
YAML.safe_load(content, permitted_classes: [Symbol])
110+
# Security: Use safe_load without permitting classes for maximum security
111+
YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false)
111112
else
112113
nil
113114
end

lib/hooks/core/plugin_loader.rb

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,13 @@ def load_custom_auth_plugin(file_path, auth_plugin_dir)
204204
# Load the file
205205
require file_path
206206

207-
# Get the class and validate it
208-
auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{class_name}")
207+
# Get the class and validate it - use scoped const_get to prevent potential exploits
208+
auth_plugin_class = begin
209+
Hooks::Plugins::Auth.const_get(class_name, false) # false = don't inherit from ancestors
210+
rescue NameError
211+
raise StandardError, "Auth plugin class not found in Hooks::Plugins::Auth namespace: #{class_name}"
212+
end
213+
209214
unless auth_plugin_class < Hooks::Plugins::Auth::Base
210215
raise StandardError, "Auth plugin class must inherit from Hooks::Plugins::Auth::Base: #{class_name}"
211216
end
@@ -239,8 +244,14 @@ def load_custom_handler_plugin(file_path, handler_plugin_dir)
239244
# Load the file
240245
require file_path
241246

242-
# Get the class and validate it
243-
handler_class = Object.const_get(class_name)
247+
# Get the class and validate it - use safe constant lookup
248+
handler_class = begin
249+
# Check if the constant exists in the global namespace for handlers
250+
Object.const_get(class_name, false) # false = don't inherit from ancestors
251+
rescue NameError
252+
raise StandardError, "Handler class not found: #{class_name}"
253+
end
254+
244255
unless handler_class < Hooks::Plugins::Handlers::Base
245256
raise StandardError, "Handler class must inherit from Hooks::Plugins::Handlers::Base: #{class_name}"
246257
end
@@ -274,8 +285,12 @@ def load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
274285
# Load the file
275286
require file_path
276287

277-
# Get the class and validate it
278-
lifecycle_class = Object.const_get(class_name)
288+
# Get the class and validate it - use safe constant lookup
289+
lifecycle_class = begin
290+
Object.const_get(class_name, false) # false = don't inherit from ancestors
291+
rescue NameError
292+
raise StandardError, "Lifecycle plugin class not found: #{class_name}"
293+
end
279294
unless lifecycle_class < Hooks::Plugins::Lifecycle
280295
raise StandardError, "Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle: #{class_name}"
281296
end
@@ -309,8 +324,12 @@ def load_custom_instrument_plugin(file_path, instruments_plugin_dir)
309324
# Load the file
310325
require file_path
311326

312-
# Get the class and validate it
313-
instrument_class = Object.const_get(class_name)
327+
# Get the class and validate it - use safe constant lookup
328+
instrument_class = begin
329+
Object.const_get(class_name, false) # false = don't inherit from ancestors
330+
rescue NameError
331+
raise StandardError, "Instrument plugin class not found: #{class_name}"
332+
end
314333

315334
# Determine instrument type based on inheritance
316335
if instrument_class < Hooks::Plugins::Instruments::StatsBase

lib/hooks/plugins/auth/shared_secret.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ def self.valid?(payload:, headers:, config:)
8181
return false
8282
end
8383

84-
stripped_secret = raw_secret.strip
85-
8684
# Use secure comparison to prevent timing attacks
87-
result = Rack::Utils.secure_compare(secret, stripped_secret)
85+
# Note: Since valid_header_value? already ensures no leading/trailing whitespace,
86+
# we can safely use the raw_secret directly to prevent any potential timing attacks
87+
result = Rack::Utils.secure_compare(secret, raw_secret)
8888
if result
8989
log.debug("Auth::SharedSecret validation successful for header '#{secret_header}'")
9090
else

spec/unit/lib/hooks/app/auth/auth_security_spec.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,55 @@ def error!(message, code)
7979
instance.validate_auth!(payload, headers, endpoint_config)
8080
end.to raise_error(StandardError, /authentication configuration missing or invalid/)
8181
end
82+
83+
it "rejects request with whitespace-only type" do
84+
endpoint_config = { auth: { type: " " } }
85+
86+
expect do
87+
instance.validate_auth!(payload, headers, endpoint_config)
88+
end.to raise_error(StandardError, /authentication configuration missing or invalid/)
89+
end
90+
end
91+
92+
context "with invalid auth type characters" do
93+
it "rejects request with auth type containing invalid characters" do
94+
endpoint_config = { auth: { type: "hmac-test!" } }
95+
96+
expect do
97+
instance.validate_auth!(payload, headers, endpoint_config)
98+
end.to raise_error(StandardError, /authentication configuration contains invalid characters/)
99+
end
100+
101+
it "rejects request with auth type containing spaces" do
102+
endpoint_config = { auth: { type: "hmac test" } }
103+
104+
expect do
105+
instance.validate_auth!(payload, headers, endpoint_config)
106+
end.to raise_error(StandardError, /authentication configuration contains invalid characters/)
107+
end
108+
109+
it "accepts valid auth type with uppercase (converts to lowercase)" do
110+
endpoint_config = { auth: { type: "HMAC", secret_env_key: "TEST_SECRET" } }
111+
ENV["TEST_SECRET"] = "test-secret"
112+
113+
# Should convert HMAC to hmac and then fail authentication (not configuration error)
114+
expect do
115+
instance.validate_auth!(payload, headers, endpoint_config)
116+
end.to raise_error(StandardError, /authentication failed/)
117+
118+
ENV.delete("TEST_SECRET")
119+
end
120+
121+
it "accepts valid auth type with underscores and numbers" do
122+
endpoint_config = { auth: { type: "hmac_v2_test", secret_env_key: "TEST_SECRET" } }
123+
ENV["TEST_SECRET"] = "test-secret"
124+
125+
expect do
126+
instance.validate_auth!(payload, headers, endpoint_config)
127+
end.to raise_error(StandardError, /unsupported auth type 'hmac_v2_test'/)
128+
129+
ENV.delete("TEST_SECRET")
130+
end
82131
end
83132

84133
context "with missing secret configuration" do

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,45 @@ def error!(message, code)
324324
helper.send(:safe_json_parse, large_json)
325325
}.to raise_error(ArgumentError, "JSON payload too large for parsing")
326326
end
327+
328+
it "raises ArgumentError when JSON_MAX_NESTING is invalid (too low)" do
329+
stub_const("ENV", ENV.to_h.merge("JSON_MAX_NESTING" => "0"))
330+
331+
expect {
332+
helper.send(:safe_json_parse, '{"test": "data"}')
333+
}.to raise_error(ArgumentError, "Invalid JSON_MAX_NESTING value: must be between 1 and 100")
334+
end
335+
336+
it "raises ArgumentError when JSON_MAX_NESTING is invalid (too high)" do
337+
stub_const("ENV", ENV.to_h.merge("JSON_MAX_NESTING" => "101"))
338+
339+
expect {
340+
helper.send(:safe_json_parse, '{"test": "data"}')
341+
}.to raise_error(ArgumentError, "Invalid JSON_MAX_NESTING value: must be between 1 and 100")
342+
end
343+
344+
it "raises ArgumentError when JSON_MAX_SIZE is invalid (too low)" do
345+
stub_const("ENV", ENV.to_h.merge("JSON_MAX_SIZE" => "0"))
346+
347+
expect {
348+
helper.send(:safe_json_parse, '{"test": "data"}')
349+
}.to raise_error(ArgumentError, "Invalid JSON_MAX_SIZE value: must be between 1 and 104857600 bytes")
350+
end
351+
352+
it "raises ArgumentError when JSON_MAX_SIZE is invalid (too high)" do
353+
stub_const("ENV", ENV.to_h.merge("JSON_MAX_SIZE" => "104857601"))
354+
355+
expect {
356+
helper.send(:safe_json_parse, '{"test": "data"}')
357+
}.to raise_error(ArgumentError, "Invalid JSON_MAX_SIZE value: must be between 1 and 104857600 bytes")
358+
end
359+
360+
it "parses valid JSON with valid limits" do
361+
stub_const("ENV", ENV.to_h.merge("JSON_MAX_NESTING" => "5", "JSON_MAX_SIZE" => "100"))
362+
363+
result = helper.send(:safe_json_parse, '{"test": "data"}')
364+
expect(result).to eq({ "test" => "data" })
365+
end
327366
end
328367

329368
describe "#determine_error_code" do

spec/unit/lib/hooks/core/plugin_loader_spec.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,28 @@ def self.valid?(payload:, headers:, config:)
238238
described_class.send(:load_custom_auth_plugin, wrong_file, temp_auth_dir)
239239
}.to raise_error(StandardError, /Auth plugin class must inherit from Hooks::Plugins::Auth::Base/)
240240
end
241+
242+
it "raises error when auth plugin class is not found after loading" do
243+
temp_auth_dir = File.join(temp_dir, "auth_missing_class")
244+
FileUtils.mkdir_p(temp_auth_dir)
245+
246+
# Create plugin file that doesn't define the expected class
247+
missing_file = File.join(temp_auth_dir, "missing_auth.rb")
248+
File.write(missing_file, <<~RUBY)
249+
# This file doesn't define MissingAuth class
250+
module Hooks
251+
module Plugins
252+
module Auth
253+
# Nothing here
254+
end
255+
end
256+
end
257+
RUBY
258+
259+
expect {
260+
described_class.send(:load_custom_auth_plugin, missing_file, temp_auth_dir)
261+
}.to raise_error(StandardError, /Auth plugin class not found in Hooks::Plugins::Auth namespace: MissingAuth/)
262+
end
241263
end
242264

243265
describe "handler plugin loading failures" do
@@ -298,6 +320,23 @@ def call(payload:, headers:, env:, config:)
298320
described_class.send(:load_custom_handler_plugin, wrong_file, temp_handler_dir)
299321
}.to raise_error(StandardError, /Handler class must inherit from Hooks::Plugins::Handlers::Base/)
300322
end
323+
324+
it "raises error when handler plugin class is not found after loading" do
325+
temp_handler_dir = File.join(temp_dir, "handler_missing_class")
326+
FileUtils.mkdir_p(temp_handler_dir)
327+
328+
# Create plugin file that doesn't define the expected class
329+
missing_file = File.join(temp_handler_dir, "missing_handler.rb")
330+
File.write(missing_file, <<~RUBY)
331+
# This file doesn't define MissingHandler class
332+
class SomeOtherClass
333+
end
334+
RUBY
335+
336+
expect {
337+
described_class.send(:load_custom_handler_plugin, missing_file, temp_handler_dir)
338+
}.to raise_error(StandardError, /Handler class not found: MissingHandler/)
339+
end
301340
end
302341

303342
describe "lifecycle plugin loading failures" do
@@ -358,6 +397,23 @@ def on_request(env)
358397
described_class.send(:load_custom_lifecycle_plugin, wrong_file, temp_lifecycle_dir)
359398
}.to raise_error(StandardError, /Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle/)
360399
end
400+
401+
it "raises error when lifecycle plugin class is not found after loading" do
402+
temp_lifecycle_dir = File.join(temp_dir, "lifecycle_missing_class")
403+
FileUtils.mkdir_p(temp_lifecycle_dir)
404+
405+
# Create plugin file that doesn't define the expected class
406+
missing_file = File.join(temp_lifecycle_dir, "missing_lifecycle.rb")
407+
File.write(missing_file, <<~RUBY)
408+
# This file doesn't define MissingLifecycle class
409+
class SomeOtherClass
410+
end
411+
RUBY
412+
413+
expect {
414+
described_class.send(:load_custom_lifecycle_plugin, missing_file, temp_lifecycle_dir)
415+
}.to raise_error(StandardError, /Lifecycle plugin class not found: MissingLifecycle/)
416+
end
361417
end
362418

363419
describe "instrument plugin loading failures" do
@@ -418,6 +474,23 @@ def record(metric_name, value, tags = {})
418474
described_class.send(:load_custom_instrument_plugin, wrong_file, temp_instrument_dir)
419475
}.to raise_error(StandardError, /Instrument plugin class must inherit from StatsBase or FailbotBase/)
420476
end
477+
478+
it "raises error when instrument plugin class is not found after loading" do
479+
temp_instrument_dir = File.join(temp_dir, "instrument_missing_class")
480+
FileUtils.mkdir_p(temp_instrument_dir)
481+
482+
# Create plugin file that doesn't define the expected class
483+
missing_file = File.join(temp_instrument_dir, "missing_instrument.rb")
484+
File.write(missing_file, <<~RUBY)
485+
# This file doesn't define MissingInstrument class
486+
class SomeOtherClass
487+
end
488+
RUBY
489+
490+
expect {
491+
described_class.send(:load_custom_instrument_plugin, missing_file, temp_instrument_dir)
492+
}.to raise_error(StandardError, /Instrument plugin class not found: MissingInstrument/)
493+
end
421494
end
422495
end
423496
end

spec/unit/lib/hooks/plugins/auth/shared_secret_spec.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,19 @@ def valid_with(args = {})
250250
expect(valid_with(headers:)).to be true
251251
end
252252

253+
it "prevents timing attacks via whitespace manipulation" do
254+
# Ensure that if somehow whitespace gets through validation,
255+
# we still don't expose timing information by comparing directly
256+
headers_with_raw_secret = { default_header => secret }
257+
258+
# Mock valid_header_value? to return true (simulating bypass)
259+
allow(described_class).to receive(:valid_header_value?).and_return(true)
260+
261+
# Verify secure_compare is called with the raw secret, not stripped
262+
expect(Rack::Utils).to receive(:secure_compare).with(secret, secret).and_return(true)
263+
expect(valid_with(headers: headers_with_raw_secret)).to be true
264+
end
265+
253266
it "handles exceptions gracefully" do
254267
# Mock an exception during validation
255268
allow(Rack::Utils).to receive(:secure_compare).and_raise(StandardError.new("test error"))

0 commit comments

Comments
 (0)