Skip to content

Commit 3e60c5b

Browse files
authored
Merge pull request #10 from github/copilot/fix-9
Security audit: Fix critical authentication bypass and class injection vulnerabilities
2 parents f844a8a + 9c0cc49 commit 3e60c5b

File tree

13 files changed

+727
-148
lines changed

13 files changed

+727
-148
lines changed

lib/hooks/app/auth/auth.rb

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,14 @@ module Auth
1818
# @note This method will halt execution with an error if authentication fails.
1919
def validate_auth!(payload, headers, endpoint_config)
2020
auth_config = endpoint_config[:auth]
21-
auth_plugin_type = auth_config[:type].downcase
22-
secret_env_key = auth_config[:secret_env_key]
23-
24-
return unless secret_env_key
2521

26-
secret = ENV[secret_env_key]
27-
unless secret
28-
error!("secret '#{secret_env_key}' not found in environment", 500)
22+
# Security: Ensure auth type is present and valid
23+
unless auth_config&.dig(:type)&.is_a?(String)
24+
error!("authentication configuration missing or invalid", 500)
2925
end
3026

27+
auth_plugin_type = auth_config[:type].downcase
28+
3129
auth_class = nil
3230

3331
case auth_plugin_type
@@ -42,7 +40,6 @@ def validate_auth!(payload, headers, endpoint_config)
4240
unless auth_class.valid?(
4341
payload:,
4442
headers:,
45-
secret:,
4643
config: endpoint_config
4744
)
4845
error!("authentication failed", 401)

lib/hooks/app/helpers.rb

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,68 @@ def parse_payload(raw_body, headers, symbolize: true)
6868
# @raise [LoadError] If the handler file or class cannot be found
6969
# @raise [StandardError] Halts with error if handler cannot be loaded
7070
def load_handler(handler_class_name, handler_dir)
71+
# Security: Validate handler class name to prevent arbitrary class loading
72+
unless valid_handler_class_name?(handler_class_name)
73+
error!("invalid handler class name: #{handler_class_name}", 400)
74+
end
75+
7176
# Convert class name to file name (e.g., Team1Handler -> team1_handler.rb)
7277
# E.g.2: GithubHandler -> github_handler.rb
7378
# E.g.3: GitHubHandler -> git_hub_handler.rb
7479
file_name = handler_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
7580
file_path = File.join(handler_dir, file_name)
7681

82+
# Security: Ensure the file path doesn't escape the handler directory
83+
normalized_handler_dir = Pathname.new(File.expand_path(handler_dir))
84+
normalized_file_path = Pathname.new(File.expand_path(file_path))
85+
unless normalized_file_path.descend.any? { |path| path == normalized_handler_dir }
86+
error!("handler path outside of handler directory", 400)
87+
end
88+
7789
if File.exist?(file_path)
7890
require file_path
79-
Object.const_get(handler_class_name).new
91+
handler_class = Object.const_get(handler_class_name)
92+
93+
# Security: Ensure the loaded class inherits from the expected base class
94+
unless handler_class < Hooks::Handlers::Base
95+
error!("handler class must inherit from Hooks::Handlers::Base", 400)
96+
end
97+
98+
handler_class.new
8099
else
81100
raise LoadError, "Handler #{handler_class_name} not found at #{file_path}"
82101
end
83102
rescue => e
84-
error!("failed to load handler #{handler_class_name}: #{e.message}", 500)
103+
error!("failed to load handler: #{e.message}", 500)
104+
end
105+
106+
private
107+
108+
# Validate that a handler class name is safe to load
109+
#
110+
# @param class_name [String] The class name to validate
111+
# @return [Boolean] true if the class name is safe, false otherwise
112+
def valid_handler_class_name?(class_name)
113+
# Must be a string
114+
return false unless class_name.is_a?(String)
115+
116+
# Must not be empty or only whitespace
117+
return false if class_name.strip.empty?
118+
119+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
120+
# Examples: MyHandler, GitHubHandler, Team1Handler
121+
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
122+
123+
# Must not be a system/built-in class name
124+
dangerous_classes = %w[
125+
File Dir Kernel Object Class Module Proc Method
126+
IO Socket TCPSocket UDPSocket BasicSocket
127+
Process Thread Fiber Mutex ConditionVariable
128+
Marshal YAML JSON Pathname
129+
]
130+
return false if dangerous_classes.include?(class_name)
131+
132+
true
85133
end
86134

87135
# Determine HTTP error code from exception

lib/hooks/core/config_validator.rb

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def self.validate_global_config(config)
6060
result.to_h
6161
end
6262

63-
# Validate endpoint configuration
63+
# Validate endpoint configuration with additional security checks
6464
#
6565
# @param config [Hash] Endpoint configuration to validate
6666
# @return [Hash] Validated configuration
@@ -72,7 +72,15 @@ def self.validate_endpoint_config(config)
7272
raise ValidationError, "Invalid endpoint configuration: #{result.errors.to_h}"
7373
end
7474

75-
result.to_h
75+
validated_config = result.to_h
76+
77+
# Security: Additional validation for handler name
78+
handler_name = validated_config[:handler]
79+
unless valid_handler_name?(handler_name)
80+
raise ValidationError, "Invalid handler name: #{handler_name}"
81+
end
82+
83+
validated_config
7684
end
7785

7886
# Validate array of endpoint configurations
@@ -93,6 +101,34 @@ def self.validate_endpoints(endpoints)
93101

94102
validated_endpoints
95103
end
104+
105+
private
106+
107+
# Validate that a handler name is safe
108+
#
109+
# @param handler_name [String] The handler name to validate
110+
# @return [Boolean] true if the handler name is safe, false otherwise
111+
def self.valid_handler_name?(handler_name)
112+
# Must be a string
113+
return false unless handler_name.is_a?(String)
114+
115+
# Must not be empty or only whitespace
116+
return false if handler_name.strip.empty?
117+
118+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
119+
return false unless handler_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
120+
121+
# Must not be a system/built-in class name
122+
dangerous_classes = %w[
123+
File Dir Kernel Object Class Module Proc Method
124+
IO Socket TCPSocket UDPSocket BasicSocket
125+
Process Thread Fiber Mutex ConditionVariable
126+
Marshal YAML JSON Pathname
127+
]
128+
return false if dangerous_classes.include?(handler_name)
129+
130+
true
131+
end
96132
end
97133
end
98134
end

lib/hooks/plugins/auth/base.rb

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@ class Base
1414
#
1515
# @param payload [String] Raw request body
1616
# @param headers [Hash<String, String>] HTTP headers
17-
# @param secret [String] Secret key for validation
1817
# @param config [Hash] Endpoint configuration
1918
# @return [Boolean] true if request is valid
2019
# @raise [NotImplementedError] if not implemented by subclass
21-
def self.valid?(payload:, headers:, secret:, config:)
20+
def self.valid?(payload:, headers:, config:)
2221
raise NotImplementedError, "Validator must implement .valid? class method"
2322
end
2423

@@ -33,6 +32,30 @@ def self.valid?(payload:, headers:, secret:, config:)
3332
def self.log
3433
Hooks::Log.instance
3534
end
35+
36+
# Retrieve the secret from the environment variable based on the key set in the configuration
37+
#
38+
# Note: This method is intended to be used by subclasses
39+
# It is a helper method and may not work with all authentication types
40+
#
41+
# @param config [Hash] Configuration hash containing :auth key
42+
# @param secret_env_key [Symbol] The key to look up in the config for the environment variable name
43+
# @return [String] The secret
44+
# @raise [StandardError] if secret_env_key is missing or empty
45+
def self.fetch_secret(config, secret_env_key_name: :secret_env_key)
46+
secret_env_key = config.dig(:auth, secret_env_key_name)
47+
if secret_env_key.nil? || !secret_env_key.is_a?(String) || secret_env_key.strip.empty?
48+
raise StandardError, "authentication configuration incomplete: missing secret_env_key"
49+
end
50+
51+
secret = ENV[secret_env_key]
52+
53+
if secret.nil? || !secret.is_a?(String) || secret.strip.empty?
54+
raise StandardError, "authentication configuration incomplete: missing secret value bound to #{secret_env_key_name}"
55+
end
56+
57+
return secret.strip
58+
end
3659
end
3760
end
3861
end

lib/hooks/plugins/auth/hmac.rb

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ class HMAC < Base
6464
#
6565
# @param payload [String] Raw request body to validate
6666
# @param headers [Hash<String, String>] HTTP headers from the request
67-
# @param secret [String] Secret key for HMAC computation
6867
# @param config [Hash] Endpoint configuration containing validator settings
6968
# @option config [Hash] :auth Validator-specific configuration
7069
# @option config [String] :header ('X-Signature') Header containing the signature
@@ -82,11 +81,11 @@ class HMAC < Base
8281
# HMAC.valid?(
8382
# payload: request_body,
8483
# headers: request.headers,
85-
# secret: ENV['WEBHOOK_SECRET'],
8684
# config: { auth: { header: 'X-Signature' } }
8785
# )
88-
def self.valid?(payload:, headers:, secret:, config:)
89-
return false if secret.nil? || secret.empty?
86+
def self.valid?(payload:, headers:, config:)
87+
# fetch the required secret from environment variable as specified in the config
88+
secret = fetch_secret(config)
9089

9190
validator_config = build_config(config)
9291

lib/hooks/plugins/auth/shared_secret.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ class SharedSecret < Base
4343
#
4444
# @param payload [String] Raw request body (unused but required by interface)
4545
# @param headers [Hash<String, String>] HTTP headers from the request
46-
# @param secret [String] Expected secret value for comparison
4746
# @param config [Hash] Endpoint configuration containing validator settings
4847
# @option config [Hash] :auth Validator-specific configuration
4948
# @option config [String] :header ('Authorization') Header containing the secret
@@ -55,11 +54,10 @@ class SharedSecret < Base
5554
# SharedSecret.valid?(
5655
# payload: request_body,
5756
# headers: request.headers,
58-
# secret: ENV['WEBHOOK_SECRET'],
5957
# config: { auth: { header: 'Authorization' } }
6058
# )
61-
def self.valid?(payload:, headers:, secret:, config:)
62-
return false if secret.nil? || secret.empty?
59+
def self.valid?(payload:, headers:, config:)
60+
secret = fetch_secret(config)
6361

6462
validator_config = build_config(config)
6563

0 commit comments

Comments
 (0)