Skip to content
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