Skip to content

Commit a9e1fc4

Browse files
CopilotGrantBirki
andcommitted
Security fixes: prevent auth bypass, handler injection, and add comprehensive security tests
Co-authored-by: GrantBirki <[email protected]>
1 parent ef89962 commit a9e1fc4

File tree

7 files changed

+893
-6
lines changed

7 files changed

+893
-6
lines changed

lib/hooks/app/auth/auth.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,23 @@ 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+
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)
25+
end
26+
2127
auth_plugin_type = auth_config[:type].downcase
2228
secret_env_key = auth_config[:secret_env_key]
2329

24-
return unless secret_env_key
30+
# Security: Require secret_env_key when auth is configured
31+
unless secret_env_key&.is_a?(String) && !secret_env_key.strip.empty?
32+
error!("authentication configuration incomplete", 500)
33+
end
2534

2635
secret = ENV[secret_env_key]
2736
unless secret
28-
error!("secret '#{secret_env_key}' not found in environment", 500)
37+
error!("authentication secret not configured", 500)
2938
end
3039

3140
auth_class = nil

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 = File.expand_path(handler_dir)
84+
normalized_file_path = File.expand_path(file_path)
85+
unless normalized_file_path.start_with?(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
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../../../../spec_helper"
4+
5+
describe Hooks::App::Auth do
6+
let(:test_class) do
7+
Class.new do
8+
include Hooks::App::Auth
9+
10+
def error!(message, code)
11+
raise StandardError, "#{message} (#{code})"
12+
end
13+
end
14+
end
15+
16+
let(:instance) { test_class.new }
17+
let(:payload) { '{"test": "data"}' }
18+
let(:headers) { { "Content-Type" => "application/json" } }
19+
20+
describe "#validate_auth!" do
21+
context "when testing security vulnerabilities" do
22+
context "with missing auth configuration" do
23+
it "rejects request with no auth config" do
24+
endpoint_config = {}
25+
26+
expect do
27+
instance.validate_auth!(payload, headers, endpoint_config)
28+
end.to raise_error(StandardError, /authentication configuration missing or invalid/)
29+
end
30+
31+
it "rejects request with nil auth config" do
32+
endpoint_config = { auth: nil }
33+
34+
expect do
35+
instance.validate_auth!(payload, headers, endpoint_config)
36+
end.to raise_error(StandardError, /authentication configuration missing or invalid/)
37+
end
38+
39+
it "rejects request with empty auth config" do
40+
endpoint_config = { auth: {} }
41+
42+
expect do
43+
instance.validate_auth!(payload, headers, endpoint_config)
44+
end.to raise_error(StandardError, /authentication configuration missing or invalid/)
45+
end
46+
end
47+
48+
context "with missing auth type" do
49+
it "rejects request with nil type" do
50+
endpoint_config = { auth: { type: nil } }
51+
52+
expect do
53+
instance.validate_auth!(payload, headers, endpoint_config)
54+
end.to raise_error(StandardError, /authentication configuration missing or invalid/)
55+
end
56+
57+
it "rejects request with non-string type" do
58+
endpoint_config = { auth: { type: 123 } }
59+
60+
expect do
61+
instance.validate_auth!(payload, headers, endpoint_config)
62+
end.to raise_error(StandardError, /authentication configuration missing or invalid/)
63+
end
64+
65+
it "rejects request with empty string type" do
66+
endpoint_config = { auth: { type: "" } }
67+
68+
expect do
69+
instance.validate_auth!(payload, headers, endpoint_config)
70+
end.to raise_error(StandardError, /authentication configuration incomplete/)
71+
end
72+
end
73+
74+
context "with missing secret configuration" do
75+
it "rejects request with missing secret_env_key" do
76+
endpoint_config = { auth: { type: "hmac" } }
77+
78+
expect do
79+
instance.validate_auth!(payload, headers, endpoint_config)
80+
end.to raise_error(StandardError, /authentication configuration incomplete/)
81+
end
82+
83+
it "rejects request with nil secret_env_key" do
84+
endpoint_config = { auth: { type: "hmac", secret_env_key: nil } }
85+
86+
expect do
87+
instance.validate_auth!(payload, headers, endpoint_config)
88+
end.to raise_error(StandardError, /authentication configuration incomplete/)
89+
end
90+
91+
it "rejects request with empty secret_env_key" do
92+
endpoint_config = { auth: { type: "hmac", secret_env_key: "" } }
93+
94+
expect do
95+
instance.validate_auth!(payload, headers, endpoint_config)
96+
end.to raise_error(StandardError, /authentication configuration incomplete/)
97+
end
98+
99+
it "rejects request with whitespace-only secret_env_key" do
100+
endpoint_config = { auth: { type: "hmac", secret_env_key: " " } }
101+
102+
expect do
103+
instance.validate_auth!(payload, headers, endpoint_config)
104+
end.to raise_error(StandardError, /authentication configuration incomplete/)
105+
end
106+
107+
it "rejects request with non-string secret_env_key" do
108+
endpoint_config = { auth: { type: "hmac", secret_env_key: 123 } }
109+
110+
expect do
111+
instance.validate_auth!(payload, headers, endpoint_config)
112+
end.to raise_error(StandardError, /authentication configuration incomplete/)
113+
end
114+
end
115+
116+
context "with missing environment variable" do
117+
it "uses generic error message for missing secrets" do
118+
endpoint_config = { auth: { type: "hmac", secret_env_key: "NONEXISTENT_SECRET" } }
119+
120+
expect do
121+
instance.validate_auth!(payload, headers, endpoint_config)
122+
end.to raise_error(StandardError, /authentication secret not configured/)
123+
end
124+
125+
it "does not leak the environment variable name in error" do
126+
endpoint_config = { auth: { type: "hmac", secret_env_key: "SUPER_SECRET_WEBHOOK_KEY" } }
127+
128+
expect do
129+
instance.validate_auth!(payload, headers, endpoint_config)
130+
end.to raise_error do |error|
131+
expect(error.message).not_to include("SUPER_SECRET_WEBHOOK_KEY")
132+
expect(error.message).to include("authentication secret not configured")
133+
end
134+
end
135+
end
136+
137+
context "with valid configuration but invalid secrets" do
138+
before do
139+
ENV["TEST_WEBHOOK_SECRET"] = "test-secret"
140+
end
141+
142+
after do
143+
ENV.delete("TEST_WEBHOOK_SECRET")
144+
end
145+
146+
it "properly validates HMAC authentication" do
147+
endpoint_config = {
148+
auth: {
149+
type: "hmac",
150+
secret_env_key: "TEST_WEBHOOK_SECRET",
151+
header: "X-Signature"
152+
}
153+
}
154+
invalid_headers = headers.merge("X-Signature" => "invalid-signature")
155+
156+
expect do
157+
instance.validate_auth!(payload, invalid_headers, endpoint_config)
158+
end.to raise_error(StandardError, /authentication failed/)
159+
end
160+
161+
it "properly validates shared_secret authentication" do
162+
endpoint_config = {
163+
auth: {
164+
type: "shared_secret",
165+
secret_env_key: "TEST_WEBHOOK_SECRET",
166+
header: "Authorization"
167+
}
168+
}
169+
invalid_headers = headers.merge("Authorization" => "wrong-secret")
170+
171+
expect do
172+
instance.validate_auth!(payload, headers, endpoint_config)
173+
end.to raise_error(StandardError, /authentication failed/)
174+
end
175+
end
176+
177+
context "with edge case auth types" do
178+
before do
179+
ENV["TEST_WEBHOOK_SECRET"] = "test-secret"
180+
end
181+
182+
after do
183+
ENV.delete("TEST_WEBHOOK_SECRET")
184+
end
185+
186+
it "handles case-insensitive auth types" do
187+
endpoint_config = {
188+
auth: {
189+
type: "HMAC",
190+
secret_env_key: "TEST_WEBHOOK_SECRET",
191+
header: "X-Signature"
192+
}
193+
}
194+
invalid_headers = headers.merge("X-Signature" => "invalid")
195+
196+
expect do
197+
instance.validate_auth!(payload, invalid_headers, endpoint_config)
198+
end.to raise_error(StandardError, /authentication failed/)
199+
end
200+
201+
it "rejects unknown auth types" do
202+
endpoint_config = {
203+
auth: {
204+
type: "unknown_type",
205+
secret_env_key: "TEST_WEBHOOK_SECRET"
206+
}
207+
}
208+
209+
expect do
210+
instance.validate_auth!(payload, headers, endpoint_config)
211+
end.to raise_error(StandardError, /Custom validators not implemented/)
212+
end
213+
end
214+
end
215+
end
216+
end

0 commit comments

Comments
 (0)