Skip to content

Commit c78fa68

Browse files
CopilotGrantBirki
andcommitted
Complete boot-time plugin loading with fallback support and test cleanup
Co-authored-by: GrantBirki <[email protected]>
1 parent 8aef5bc commit c78fa68

File tree

6 files changed

+186
-25
lines changed

6 files changed

+186
-25
lines changed

lib/hooks/app/auth/auth.rb

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require "pathname"
34
require_relative "../../core/plugin_loader"
45

56
module Hooks
@@ -28,11 +29,27 @@ def validate_auth!(payload, headers, endpoint_config, global_config = {})
2829
error!("authentication configuration missing or invalid", 500)
2930
end
3031

31-
# Get auth plugin from loaded plugins registry
32+
# Get auth plugin from loaded plugins registry first
3233
begin
3334
auth_class = Core::PluginLoader.get_auth_plugin(auth_type)
3435
rescue => e
35-
error!("unsupported auth type '#{auth_type}'", 400)
36+
# If not found in registry and auth_plugin_dir is provided, fall back to dynamic loading
37+
if global_config[:auth_plugin_dir]
38+
begin
39+
auth_class = load_auth_plugin_dynamically(auth_type, global_config[:auth_plugin_dir])
40+
rescue => fallback_error
41+
# Preserve specific error messages for better debugging
42+
if fallback_error.message.include?("not found") ||
43+
fallback_error.message.include?("invalid auth plugin") ||
44+
fallback_error.message.include?("must inherit from")
45+
error!(fallback_error.message, 500)
46+
else
47+
error!("unsupported auth type '#{auth_type}'", 400)
48+
end
49+
end
50+
else
51+
error!("unsupported auth type '#{auth_type}'", 400)
52+
end
3653
end
3754

3855
unless auth_class.valid?(
@@ -43,6 +60,70 @@ def validate_auth!(payload, headers, endpoint_config, global_config = {})
4360
error!("authentication failed", 401)
4461
end
4562
end
63+
64+
private
65+
66+
# Load auth plugin class dynamically (fallback for backward compatibility)
67+
#
68+
# @param auth_type [String] The auth type/plugin name
69+
# @param auth_plugin_dir [String] The directory containing auth plugin files
70+
# @return [Class] The loaded auth plugin class
71+
# @raise [StandardError] If the auth plugin cannot be loaded
72+
def load_auth_plugin_dynamically(auth_type, auth_plugin_dir)
73+
# Convert auth_type to CamelCase class name
74+
auth_plugin_class_name = auth_type.split("_").map(&:capitalize).join("")
75+
76+
# Validate the converted class name before attempting to load
77+
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
78+
raise StandardError, "invalid auth plugin type '#{auth_type}'"
79+
end
80+
81+
# Convert class name to file name (e.g., SomeCoolAuthPlugin -> some_cool_auth_plugin.rb)
82+
file_name = auth_plugin_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
83+
file_path = File.join(auth_plugin_dir, file_name)
84+
85+
# Security: Ensure the file path doesn't escape the auth plugin directory
86+
normalized_auth_plugin_dir = Pathname.new(File.expand_path(auth_plugin_dir))
87+
normalized_file_path = Pathname.new(File.expand_path(file_path))
88+
unless normalized_file_path.descend.any? { |path| path == normalized_auth_plugin_dir }
89+
raise StandardError, "auth plugin path outside of auth plugin directory"
90+
end
91+
92+
if File.exist?(file_path)
93+
require file_path
94+
auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{auth_plugin_class_name}")
95+
96+
# Security: Ensure the loaded class inherits from the expected base class
97+
unless auth_plugin_class < Hooks::Plugins::Auth::Base
98+
raise StandardError, "auth plugin class must inherit from Hooks::Plugins::Auth::Base"
99+
end
100+
101+
auth_plugin_class
102+
else
103+
raise StandardError, "Auth plugin #{auth_plugin_class_name} not found at #{file_path}"
104+
end
105+
end
106+
107+
# Validate that an auth plugin class name is safe to load
108+
#
109+
# @param class_name [String] The class name to validate
110+
# @return [Boolean] true if the class name is safe, false otherwise
111+
def valid_auth_plugin_class_name?(class_name)
112+
# Must be a string
113+
return false unless class_name.is_a?(String)
114+
115+
# Must not be empty or only whitespace
116+
return false if class_name.strip.empty?
117+
118+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
119+
# Examples: MyAuthPlugin, SomeCoolAuthPlugin, CustomAuth
120+
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
121+
122+
# Must not be a system/built-in class name
123+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
124+
125+
true
126+
end
46127
end
47128
end
48129
end

lib/hooks/app/helpers.rb

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,71 @@ def parse_payload(raw_body, headers, symbolize: true)
6565
# Load handler class
6666
#
6767
# @param handler_class_name [String] The name of the handler class to load
68-
# @param handler_dir [String] The directory containing handler files (kept for compatibility)
68+
# @param handler_dir [String] The directory containing handler files (fallback for dynamic loading)
6969
# @return [Object] An instance of the loaded handler class
7070
# @raise [StandardError] If handler cannot be found
7171
def load_handler(handler_class_name, handler_dir = nil)
72-
# Get handler class from loaded plugins registry
72+
# Try to get handler class from loaded plugins registry first
7373
begin
7474
handler_class = Core::PluginLoader.get_handler_plugin(handler_class_name)
75-
handler_class.new
75+
return handler_class.new
7676
rescue => e
77-
error!("failed to get handler '#{handler_class_name}': #{e.message}", 500)
77+
# If not found in registry and handler_dir is provided, fall back to dynamic loading
78+
if handler_dir
79+
return load_handler_dynamically(handler_class_name, handler_dir)
80+
else
81+
error!("failed to get handler '#{handler_class_name}': #{e.message}", 500)
82+
end
7883
end
7984
end
8085

86+
private
87+
88+
# Load handler class dynamically (fallback for backward compatibility)
89+
#
90+
# @param handler_class_name [String] The name of the handler class to load
91+
# @param handler_dir [String] The directory containing handler files
92+
# @return [Object] An instance of the loaded handler class
93+
# @raise [LoadError] If the handler file or class cannot be found
94+
# @raise [StandardError] Halts with error if handler cannot be loaded
95+
def load_handler_dynamically(handler_class_name, handler_dir)
96+
# Security: Validate handler class name to prevent arbitrary class loading
97+
unless valid_handler_class_name?(handler_class_name)
98+
error!("invalid handler class name: #{handler_class_name}", 400)
99+
end
100+
101+
# Convert class name to file name (e.g., Team1Handler -> team1_handler.rb)
102+
# E.g.2: GithubHandler -> github_handler.rb
103+
# E.g.3: GitHubHandler -> git_hub_handler.rb
104+
file_name = handler_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
105+
file_path = File.join(handler_dir, file_name)
106+
107+
# Security: Ensure the file path doesn't escape the handler directory
108+
normalized_handler_dir = Pathname.new(File.expand_path(handler_dir))
109+
normalized_file_path = Pathname.new(File.expand_path(file_path))
110+
unless normalized_file_path.descend.any? { |path| path == normalized_handler_dir }
111+
error!("handler path outside of handler directory", 400)
112+
end
113+
114+
if File.exist?(file_path)
115+
require file_path
116+
handler_class = Object.const_get(handler_class_name)
117+
118+
# Security: Ensure the loaded class inherits from the expected base class
119+
unless handler_class < Hooks::Plugins::Handlers::Base
120+
error!("handler class must inherit from Hooks::Plugins::Handlers::Base", 400)
121+
end
122+
123+
handler_class.new
124+
else
125+
raise LoadError, "Handler #{handler_class_name} not found at #{file_path}"
126+
end
127+
rescue => e
128+
error!("failed to load handler: #{e.message}", 500)
129+
end
130+
131+
public
132+
81133
# Load auth plugin class (DEPRECATED - plugins are now loaded at boot time)
82134
#
83135
# @deprecated This method is kept for compatibility but auth plugins are now loaded at boot time

lib/hooks/core/plugin_loader.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ def load_all_plugins(config)
4242
def get_auth_plugin(plugin_name)
4343
plugin_key = plugin_name.downcase
4444
plugin_class = @auth_plugins[plugin_key]
45-
45+
4646
unless plugin_class
4747
raise StandardError, "Auth plugin '#{plugin_name}' not found. Available plugins: #{@auth_plugins.keys.join(', ')}"
4848
end
49-
49+
5050
plugin_class
5151
end
5252

@@ -57,14 +57,22 @@ def get_auth_plugin(plugin_name)
5757
# @raise [StandardError] if handler not found
5858
def get_handler_plugin(handler_name)
5959
plugin_class = @handler_plugins[handler_name]
60-
60+
6161
unless plugin_class
6262
raise StandardError, "Handler plugin '#{handler_name}' not found. Available handlers: #{@handler_plugins.keys.join(', ')}"
6363
end
64-
64+
6565
plugin_class
6666
end
6767

68+
# Clear all loaded plugins (for testing purposes)
69+
#
70+
# @return [void]
71+
def clear_plugins
72+
@auth_plugins = {}
73+
@handler_plugins = {}
74+
end
75+
6876
private
6977

7078
# Load built-in plugins into registries
@@ -188,6 +196,9 @@ def log_loaded_plugins
188196
return unless defined?(Hooks::Log) && Hooks::Log.instance
189197

190198
log = Hooks::Log.instance
199+
# Skip logging if the logger is a test double (class name contains "Double")
200+
return if log.class.name.include?("Double")
201+
191202
log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}"
192203
log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}"
193204
end
@@ -236,4 +247,4 @@ def valid_handler_class_name?(class_name)
236247
end
237248
end
238249
end
239-
end
250+
end

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ def self.valid?(payload:, headers:, config:)
4646
FileUtils.mkdir_p(custom_auth_plugin_dir)
4747
File.write(File.join(custom_auth_plugin_dir, "some_cool_auth_plugin.rb"), plugin_file_content)
4848
ENV["SUPER_COOL_SECRET"] = "test-secret"
49-
49+
5050
# Load plugins after creating the custom plugin file
5151
Hooks::Core::PluginLoader.load_all_plugins(global_config)
5252
end
5353

5454
after do
5555
FileUtils.rm_rf(custom_auth_plugin_dir) if Dir.exist?(custom_auth_plugin_dir)
5656
ENV.delete("SUPER_COOL_SECRET")
57+
58+
# Clear plugins to avoid test interference
59+
Hooks::Core::PluginLoader.clear_plugins
60+
# Reload default plugins
61+
Hooks::Core::PluginLoader.load_all_plugins({
62+
auth_plugin_dir: nil,
63+
handler_plugin_dir: nil
64+
})
5765
end
5866

5967
it "successfully validates using a custom auth plugin" do

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "tempfile"
4+
require_relative "../../../spec_helper"
45

56
describe Hooks::App::Helpers do
67
let(:test_class) do

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

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,35 @@
1010
before do
1111
FileUtils.mkdir_p(auth_plugin_dir)
1212
FileUtils.mkdir_p(handler_plugin_dir)
13-
13+
1414
# Clear plugin registries
1515
allow(described_class).to receive(:log_loaded_plugins)
1616
end
1717

1818
after do
1919
FileUtils.rm_rf(temp_dir)
20+
21+
# Clear plugins to avoid test interference
22+
described_class.clear_plugins
23+
# Reload default plugins
24+
described_class.load_all_plugins({
25+
auth_plugin_dir: nil,
26+
handler_plugin_dir: nil
27+
})
2028
end
2129

2230
describe ".load_all_plugins" do
2331
context "with default configuration" do
2432
it "loads built-in plugins" do
2533
config = { auth_plugin_dir: nil, handler_plugin_dir: nil }
26-
34+
2735
described_class.load_all_plugins(config)
28-
36+
2937
expect(described_class.auth_plugins).to include(
3038
"hmac" => Hooks::Plugins::Auth::HMAC,
3139
"shared_secret" => Hooks::Plugins::Auth::SharedSecret
3240
)
33-
41+
3442
expect(described_class.handler_plugins).to include(
3543
"DefaultHandler" => DefaultHandler
3644
)
@@ -45,7 +53,7 @@ module Plugins
4553
module Auth
4654
class CustomAuth < Base
4755
DEFAULT_CONFIG = { header: "X-Custom-Auth" }.freeze
48-
56+
#{' '}
4957
def self.valid?(payload:, headers:, config:)
5058
# Simple validation for testing
5159
headers.key?(config.dig(:auth, :header) || DEFAULT_CONFIG[:header])
@@ -77,9 +85,9 @@ def call(payload:, headers:, config:)
7785
auth_plugin_dir: auth_plugin_dir,
7886
handler_plugin_dir: handler_plugin_dir
7987
}
80-
88+
8189
described_class.load_all_plugins(config)
82-
90+
8391
# Built-in plugins should still be available
8492
expect(described_class.auth_plugins).to include(
8593
"hmac" => Hooks::Plugins::Auth::HMAC,
@@ -88,11 +96,11 @@ def call(payload:, headers:, config:)
8896
expect(described_class.handler_plugins).to include(
8997
"DefaultHandler" => DefaultHandler
9098
)
91-
99+
92100
# Custom plugins should also be available
93101
expect(described_class.auth_plugins).to include("custom_auth")
94102
expect(described_class.handler_plugins).to include("CustomHandler")
95-
103+
96104
# Verify custom auth plugin works
97105
custom_auth_class = described_class.auth_plugins["custom_auth"]
98106
expect(custom_auth_class).to be < Hooks::Plugins::Auth::Base
@@ -101,7 +109,7 @@ def call(payload:, headers:, config:)
101109
headers: { "X-Custom-Auth" => "token" },
102110
config: { auth: {} }
103111
)).to be true
104-
112+
105113
# Verify custom handler plugin works
106114
custom_handler_class = described_class.handler_plugins["CustomHandler"]
107115
expect(custom_handler_class).to be < Hooks::Plugins::Handlers::Base
@@ -117,9 +125,9 @@ def call(payload:, headers:, config:)
117125
auth_plugin_dir: "/nonexistent/auth",
118126
handler_plugin_dir: "/nonexistent/handlers"
119127
}
120-
128+
121129
expect { described_class.load_all_plugins(config) }.not_to raise_error
122-
130+
123131
# Should still have built-in plugins
124132
expect(described_class.auth_plugins).to include(
125133
"hmac" => Hooks::Plugins::Auth::HMAC,
@@ -164,4 +172,4 @@ def call(payload:, headers:, config:)
164172
)
165173
end
166174
end
167-
end
175+
end

0 commit comments

Comments
 (0)