Skip to content

Commit b325221

Browse files
authored
Merge pull request #18 from github/copilot/fix-17
Implement boot-time plugin loading for improved performance and reliability
2 parents 274a930 + fa48c3a commit b325221

File tree

13 files changed

+543
-499
lines changed

13 files changed

+543
-499
lines changed

lib/hooks/app/api.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def self.create(config:, endpoints:, log:)
6969
end
7070

7171
payload = parse_payload(raw_body, headers, symbolize: config[:symbolize_payload])
72-
handler = load_handler(handler_class_name, config[:handler_plugin_dir])
72+
handler = load_handler(handler_class_name)
7373
normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
7474

7575
response = handler.call(

lib/hooks/app/auth/auth.rb

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require_relative "../../core/plugin_loader"
4+
35
module Hooks
46
module App
57
# Provides authentication helpers for verifying incoming requests.
@@ -13,7 +15,7 @@ module Auth
1315
# @param payload [String, Hash] The request payload to authenticate.
1416
# @param headers [Hash] The request headers.
1517
# @param endpoint_config [Hash] The endpoint configuration, must include :auth key.
16-
# @param global_config [Hash] The global configuration (optional, needed for custom auth plugins).
18+
# @param global_config [Hash] The global configuration (optional, for compatibility).
1719
# @raise [StandardError] Raises error if authentication fails or is misconfigured.
1820
# @return [void]
1921
# @note This method will halt execution with an error if authentication fails.
@@ -26,34 +28,11 @@ def validate_auth!(payload, headers, endpoint_config, global_config = {})
2628
error!("authentication configuration missing or invalid", 500)
2729
end
2830

29-
auth_plugin_type = auth_type.downcase
30-
31-
auth_class = nil
32-
33-
case auth_plugin_type
34-
when "hmac"
35-
auth_class = Plugins::Auth::HMAC
36-
when "shared_secret"
37-
auth_class = Plugins::Auth::SharedSecret
38-
else
39-
# Try to load custom auth plugin if auth_plugin_dir is configured
40-
if global_config[:auth_plugin_dir]
41-
# Convert auth_type to CamelCase class name
42-
auth_plugin_class_name = auth_type.split("_").map(&:capitalize).join("")
43-
44-
# Validate the converted class name before attempting to load
45-
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
46-
error!("invalid auth plugin type '#{auth_type}'", 400)
47-
end
48-
49-
begin
50-
auth_class = load_auth_plugin(auth_plugin_class_name, global_config[:auth_plugin_dir])
51-
rescue => e
52-
error!("failed to load custom auth plugin '#{auth_type}': #{e.message}", 500)
53-
end
54-
else
55-
error!("unsupported auth type '#{auth_type}' due to auth_plugin_dir not being set", 400)
56-
end
31+
# Get auth plugin from loaded plugins registry (boot-time loaded only)
32+
begin
33+
auth_class = Core::PluginLoader.get_auth_plugin(auth_type)
34+
rescue => e
35+
error!("unsupported auth type '#{auth_type}'", 400)
5736
end
5837

5938
unless auth_class.valid?(

lib/hooks/app/helpers.rb

Lines changed: 9 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "securerandom"
44
require_relative "../security"
5+
require_relative "../core/plugin_loader"
56

67
module Hooks
78
module App
@@ -64,131 +65,20 @@ def parse_payload(raw_body, headers, symbolize: true)
6465
# Load handler class
6566
#
6667
# @param handler_class_name [String] The name of the handler class to load
67-
# @param handler_dir [String] The directory containing handler files
6868
# @return [Object] An instance of the loaded handler class
69-
# @raise [LoadError] If the handler file or class cannot be found
70-
# @raise [StandardError] Halts with error if handler cannot be loaded
71-
def load_handler(handler_class_name, handler_dir)
72-
# Security: Validate handler class name to prevent arbitrary class loading
73-
unless valid_handler_class_name?(handler_class_name)
74-
error!("invalid handler class name: #{handler_class_name}", 400)
69+
# @raise [StandardError] If handler cannot be found
70+
def load_handler(handler_class_name)
71+
# Get handler class from loaded plugins registry (boot-time loaded only)
72+
begin
73+
handler_class = Core::PluginLoader.get_handler_plugin(handler_class_name)
74+
return handler_class.new
75+
rescue => e
76+
error!("failed to get handler '#{handler_class_name}': #{e.message}", 500)
7577
end
76-
77-
# Convert class name to file name (e.g., Team1Handler -> team1_handler.rb)
78-
# E.g.2: GithubHandler -> github_handler.rb
79-
# E.g.3: GitHubHandler -> git_hub_handler.rb
80-
file_name = handler_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
81-
file_path = File.join(handler_dir, file_name)
82-
83-
# Security: Ensure the file path doesn't escape the handler directory
84-
normalized_handler_dir = Pathname.new(File.expand_path(handler_dir))
85-
normalized_file_path = Pathname.new(File.expand_path(file_path))
86-
unless normalized_file_path.descend.any? { |path| path == normalized_handler_dir }
87-
error!("handler path outside of handler directory", 400)
88-
end
89-
90-
if File.exist?(file_path)
91-
require file_path
92-
handler_class = Object.const_get(handler_class_name)
93-
94-
# Security: Ensure the loaded class inherits from the expected base class
95-
unless handler_class < Hooks::Plugins::Handlers::Base
96-
error!("handler class must inherit from Hooks::Plugins::Handlers::Base", 400)
97-
end
98-
99-
handler_class.new
100-
else
101-
raise LoadError, "Handler #{handler_class_name} not found at #{file_path}"
102-
end
103-
rescue => e
104-
error!("failed to load handler: #{e.message}", 500)
105-
end
106-
107-
# Load auth plugin class
108-
#
109-
# @param auth_plugin_class_name [String] The name of the auth plugin class to load
110-
# @param auth_plugin_dir [String] The directory containing auth plugin files
111-
# @return [Class] The loaded auth plugin class
112-
# @raise [LoadError] If the auth plugin file or class cannot be found
113-
# @raise [StandardError] Halts with error if auth plugin cannot be loaded
114-
def load_auth_plugin(auth_plugin_class_name, auth_plugin_dir)
115-
# Security: Validate auth plugin class name to prevent arbitrary class loading
116-
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
117-
error!("invalid auth plugin class name: #{auth_plugin_class_name}", 400)
118-
end
119-
120-
# Convert class name to file name (e.g., SomeCoolAuthPlugin -> some_cool_auth_plugin.rb)
121-
file_name = auth_plugin_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
122-
file_path = File.join(auth_plugin_dir, file_name)
123-
124-
# Security: Ensure the file path doesn't escape the auth plugin directory
125-
normalized_auth_plugin_dir = Pathname.new(File.expand_path(auth_plugin_dir))
126-
normalized_file_path = Pathname.new(File.expand_path(file_path))
127-
unless normalized_file_path.descend.any? { |path| path == normalized_auth_plugin_dir }
128-
error!("auth plugin path outside of auth plugin directory", 400)
129-
end
130-
131-
if File.exist?(file_path)
132-
require file_path
133-
auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{auth_plugin_class_name}")
134-
135-
# Security: Ensure the loaded class inherits from the expected base class
136-
unless auth_plugin_class < Hooks::Plugins::Auth::Base
137-
error!("auth plugin class must inherit from Hooks::Plugins::Auth::Base", 400)
138-
end
139-
140-
auth_plugin_class
141-
else
142-
error!("Auth plugin #{auth_plugin_class_name} not found at #{file_path}", 500)
143-
end
144-
rescue => e
145-
error!("failed to load auth plugin: #{e.message}", 500)
14678
end
14779

14880
private
14981

150-
# Validate that a handler class name is safe to load
151-
#
152-
# @param class_name [String] The class name to validate
153-
# @return [Boolean] true if the class name is safe, false otherwise
154-
def valid_handler_class_name?(class_name)
155-
# Must be a string
156-
return false unless class_name.is_a?(String)
157-
158-
# Must not be empty or only whitespace
159-
return false if class_name.strip.empty?
160-
161-
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
162-
# Examples: MyHandler, GitHubHandler, Team1Handler
163-
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
164-
165-
# Must not be a system/built-in class name
166-
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
167-
168-
true
169-
end
170-
171-
# Validate that an auth plugin class name is safe to load
172-
#
173-
# @param class_name [String] The class name to validate
174-
# @return [Boolean] true if the class name is safe, false otherwise
175-
def valid_auth_plugin_class_name?(class_name)
176-
# Must be a string
177-
return false unless class_name.is_a?(String)
178-
179-
# Must not be empty or only whitespace
180-
return false if class_name.strip.empty?
181-
182-
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
183-
# Examples: MyAuthPlugin, SomeCoolAuthPlugin, CustomAuth
184-
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
185-
186-
# Must not be a system/built-in class name
187-
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
188-
189-
true
190-
end
191-
19282
# Determine HTTP error code from exception
19383
#
19484
# @param exception [Exception] The exception to map to an HTTP status code

lib/hooks/core/builder.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require_relative "config_loader"
44
require_relative "config_validator"
55
require_relative "logger_factory"
6+
require_relative "plugin_loader"
67
require_relative "../app/api"
78

89
module Hooks
@@ -33,6 +34,9 @@ def build
3334
)
3435
end
3536

37+
# Load all plugins at boot time
38+
load_plugins(config)
39+
3640
# Load endpoints
3741
endpoints = load_endpoints(config)
3842

@@ -75,6 +79,16 @@ def load_endpoints(config)
7579
rescue ConfigValidator::ValidationError => e
7680
raise ConfigurationError, "Endpoint validation failed: #{e.message}"
7781
end
82+
83+
# Load all plugins at boot time
84+
#
85+
# @param config [Hash] Global configuration
86+
# @return [void]
87+
def load_plugins(config)
88+
PluginLoader.load_all_plugins(config)
89+
rescue => e
90+
raise ConfigurationError, "Plugin loading failed: #{e.message}"
91+
end
7892
end
7993

8094
# Configuration error

0 commit comments

Comments
 (0)