Skip to content

Implement boot-time plugin loading for improved performance and reliability #18

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 8 commits into from
Jun 11, 2025
2 changes: 1 addition & 1 deletion lib/hooks/app/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def self.create(config:, endpoints:, log:)
end

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

response = handler.call(
Expand Down
37 changes: 8 additions & 29 deletions lib/hooks/app/auth/auth.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require_relative "../../core/plugin_loader"

module Hooks
module App
# Provides authentication helpers for verifying incoming requests.
Expand All @@ -13,7 +15,7 @@ module Auth
# @param payload [String, Hash] The request payload to authenticate.
# @param headers [Hash] The request headers.
# @param endpoint_config [Hash] The endpoint configuration, must include :auth key.
# @param global_config [Hash] The global configuration (optional, needed for custom auth plugins).
# @param global_config [Hash] The global configuration (optional, for compatibility).
# @raise [StandardError] Raises error if authentication fails or is misconfigured.
# @return [void]
# @note This method will halt execution with an error if authentication fails.
Expand All @@ -26,34 +28,11 @@ def validate_auth!(payload, headers, endpoint_config, global_config = {})
error!("authentication configuration missing or invalid", 500)
end

auth_plugin_type = auth_type.downcase

auth_class = nil

case auth_plugin_type
when "hmac"
auth_class = Plugins::Auth::HMAC
when "shared_secret"
auth_class = Plugins::Auth::SharedSecret
else
# Try to load custom auth plugin if auth_plugin_dir is configured
if global_config[:auth_plugin_dir]
# Convert auth_type to CamelCase class name
auth_plugin_class_name = auth_type.split("_").map(&:capitalize).join("")

# Validate the converted class name before attempting to load
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
error!("invalid auth plugin type '#{auth_type}'", 400)
end

begin
auth_class = load_auth_plugin(auth_plugin_class_name, global_config[:auth_plugin_dir])
rescue => e
error!("failed to load custom auth plugin '#{auth_type}': #{e.message}", 500)
end
else
error!("unsupported auth type '#{auth_type}' due to auth_plugin_dir not being set", 400)
end
# Get auth plugin from loaded plugins registry (boot-time loaded only)
begin
auth_class = Core::PluginLoader.get_auth_plugin(auth_type)
rescue => e
error!("unsupported auth type '#{auth_type}'", 400)
end

unless auth_class.valid?(
Expand Down
128 changes: 9 additions & 119 deletions lib/hooks/app/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "securerandom"
require_relative "../security"
require_relative "../core/plugin_loader"

module Hooks
module App
Expand Down Expand Up @@ -64,131 +65,20 @@ def parse_payload(raw_body, headers, symbolize: true)
# Load handler class
#
# @param handler_class_name [String] The name of the handler class to load
# @param handler_dir [String] The directory containing handler files
# @return [Object] An instance of the loaded handler class
# @raise [LoadError] If the handler file or class cannot be found
# @raise [StandardError] Halts with error if handler cannot be loaded
def load_handler(handler_class_name, handler_dir)
# Security: Validate handler class name to prevent arbitrary class loading
unless valid_handler_class_name?(handler_class_name)
error!("invalid handler class name: #{handler_class_name}", 400)
# @raise [StandardError] If handler cannot be found
def load_handler(handler_class_name)
# Get handler class from loaded plugins registry (boot-time loaded only)
begin
handler_class = Core::PluginLoader.get_handler_plugin(handler_class_name)
return handler_class.new
rescue => e
error!("failed to get handler '#{handler_class_name}': #{e.message}", 500)
end

# Convert class name to file name (e.g., Team1Handler -> team1_handler.rb)
# E.g.2: GithubHandler -> github_handler.rb
# E.g.3: GitHubHandler -> git_hub_handler.rb
file_name = handler_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
file_path = File.join(handler_dir, file_name)

# Security: Ensure the file path doesn't escape the handler directory
normalized_handler_dir = Pathname.new(File.expand_path(handler_dir))
normalized_file_path = Pathname.new(File.expand_path(file_path))
unless normalized_file_path.descend.any? { |path| path == normalized_handler_dir }
error!("handler path outside of handler directory", 400)
end

if File.exist?(file_path)
require file_path
handler_class = Object.const_get(handler_class_name)

# Security: Ensure the loaded class inherits from the expected base class
unless handler_class < Hooks::Plugins::Handlers::Base
error!("handler class must inherit from Hooks::Plugins::Handlers::Base", 400)
end

handler_class.new
else
raise LoadError, "Handler #{handler_class_name} not found at #{file_path}"
end
rescue => e
error!("failed to load handler: #{e.message}", 500)
end

# Load auth plugin class
#
# @param auth_plugin_class_name [String] The name of the auth plugin class to load
# @param auth_plugin_dir [String] The directory containing auth plugin files
# @return [Class] The loaded auth plugin class
# @raise [LoadError] If the auth plugin file or class cannot be found
# @raise [StandardError] Halts with error if auth plugin cannot be loaded
def load_auth_plugin(auth_plugin_class_name, auth_plugin_dir)
# Security: Validate auth plugin class name to prevent arbitrary class loading
unless valid_auth_plugin_class_name?(auth_plugin_class_name)
error!("invalid auth plugin class name: #{auth_plugin_class_name}", 400)
end

# Convert class name to file name (e.g., SomeCoolAuthPlugin -> some_cool_auth_plugin.rb)
file_name = auth_plugin_class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "") + ".rb"
file_path = File.join(auth_plugin_dir, file_name)

# Security: Ensure the file path doesn't escape the auth plugin directory
normalized_auth_plugin_dir = Pathname.new(File.expand_path(auth_plugin_dir))
normalized_file_path = Pathname.new(File.expand_path(file_path))
unless normalized_file_path.descend.any? { |path| path == normalized_auth_plugin_dir }
error!("auth plugin path outside of auth plugin directory", 400)
end

if File.exist?(file_path)
require file_path
auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{auth_plugin_class_name}")

# Security: Ensure the loaded class inherits from the expected base class
unless auth_plugin_class < Hooks::Plugins::Auth::Base
error!("auth plugin class must inherit from Hooks::Plugins::Auth::Base", 400)
end

auth_plugin_class
else
error!("Auth plugin #{auth_plugin_class_name} not found at #{file_path}", 500)
end
rescue => e
error!("failed to load auth plugin: #{e.message}", 500)
end

private

# Validate that a handler class name is safe to load
#
# @param class_name [String] The class name to validate
# @return [Boolean] true if the class name is safe, false otherwise
def valid_handler_class_name?(class_name)
# Must be a string
return false unless class_name.is_a?(String)

# Must not be empty or only whitespace
return false if class_name.strip.empty?

# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
# Examples: MyHandler, GitHubHandler, Team1Handler
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)

# Must not be a system/built-in class name
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)

true
end

# Validate that an auth plugin class name is safe to load
#
# @param class_name [String] The class name to validate
# @return [Boolean] true if the class name is safe, false otherwise
def valid_auth_plugin_class_name?(class_name)
# Must be a string
return false unless class_name.is_a?(String)

# Must not be empty or only whitespace
return false if class_name.strip.empty?

# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
# Examples: MyAuthPlugin, SomeCoolAuthPlugin, CustomAuth
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)

# Must not be a system/built-in class name
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)

true
end

# Determine HTTP error code from exception
#
# @param exception [Exception] The exception to map to an HTTP status code
Expand Down
14 changes: 14 additions & 0 deletions lib/hooks/core/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require_relative "config_loader"
require_relative "config_validator"
require_relative "logger_factory"
require_relative "plugin_loader"
require_relative "../app/api"

module Hooks
Expand Down Expand Up @@ -33,6 +34,9 @@ def build
)
end

# Load all plugins at boot time
load_plugins(config)

# Load endpoints
endpoints = load_endpoints(config)

Expand Down Expand Up @@ -75,6 +79,16 @@ def load_endpoints(config)
rescue ConfigValidator::ValidationError => e
raise ConfigurationError, "Endpoint validation failed: #{e.message}"
end

# Load all plugins at boot time
#
# @param config [Hash] Global configuration
# @return [void]
def load_plugins(config)
PluginLoader.load_all_plugins(config)
rescue => e
raise ConfigurationError, "Plugin loading failed: #{e.message}"
end
end

# Configuration error
Expand Down
Loading