Skip to content

Commit ecee4c5

Browse files
CopilotGrantBirki
andcommitted
Security improvements: fix info disclosure, add logging, refactor header matching
Co-authored-by: GrantBirki <[email protected]>
1 parent 0bc06e0 commit ecee4c5

File tree

6 files changed

+204
-29
lines changed

6 files changed

+204
-29
lines changed

lib/hooks/plugins/auth/base.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,30 @@ def self.fetch_secret(config, secret_env_key_name: :secret_env_key)
4343
secret = ENV[secret_env_key]
4444

4545
if secret.nil? || !secret.is_a?(String) || secret.strip.empty?
46-
raise StandardError, "authentication configuration incomplete: missing secret value bound to #{secret_env_key_name}"
46+
raise StandardError, "authentication configuration incomplete: missing secret value for environment variable"
4747
end
4848

4949
return secret.strip
5050
end
51+
52+
# Find a header value by name with case-insensitive matching
53+
#
54+
# @param headers [Hash] HTTP headers from the request
55+
# @param header_name [String] Name of the header to find
56+
# @return [String, nil] The header value if found, nil otherwise
57+
# @note This method performs case-insensitive header matching
58+
def self.find_header_value(headers, header_name)
59+
return nil unless headers.respond_to?(:each)
60+
return nil if header_name.nil? || header_name.strip.empty?
61+
62+
target_header = header_name.downcase
63+
headers.each do |key, value|
64+
if key.to_s.downcase == target_header
65+
return value.to_s
66+
end
67+
end
68+
nil
69+
end
5170
end
5271
end
5372
end

lib/hooks/plugins/auth/hmac.rb

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,26 +92,32 @@ def self.valid?(payload:, headers:, config:)
9292
validator_config = build_config(config)
9393

9494
# Security: Check raw headers BEFORE normalization to detect tampering
95-
return false unless headers.respond_to?(:each)
95+
unless headers.respond_to?(:each)
96+
log.warn("Auth::HMAC validation failed: Invalid headers object")
97+
return false
98+
end
9699

97100
signature_header = validator_config[:header]
98101

99-
# Find the signature header with case-insensitive matching but preserve original value
100-
raw_signature = nil
101-
headers.each do |key, value|
102-
if key.to_s.downcase == signature_header.downcase
103-
raw_signature = value.to_s
104-
break
105-
end
106-
end
102+
# Find the signature header with case-insensitive matching
103+
raw_signature = find_header_value(headers, signature_header)
107104

108-
return false if raw_signature.nil? || raw_signature.empty?
105+
if raw_signature.nil? || raw_signature.empty?
106+
log.warn("Auth::HMAC validation failed: Missing or empty signature header '#{signature_header}'")
107+
return false
108+
end
109109

110110
# Security: Reject signatures with leading/trailing whitespace
111-
return false if raw_signature != raw_signature.strip
111+
if raw_signature != raw_signature.strip
112+
log.warn("Auth::HMAC validation failed: Signature contains leading/trailing whitespace")
113+
return false
114+
end
112115

113116
# Security: Reject signatures containing null bytes or other control characters
114-
return false if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/)
117+
if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/)
118+
log.warn("Auth::HMAC validation failed: Signature contains control characters")
119+
return false
120+
end
115121

116122
# Now we can safely normalize headers for the rest of the validation
117123
normalized_headers = normalize_headers(headers)
@@ -134,7 +140,13 @@ def self.valid?(payload:, headers:, config:)
134140
)
135141

136142
# Use secure comparison to prevent timing attacks
137-
Rack::Utils.secure_compare(computed_signature, provided_signature)
143+
result = Rack::Utils.secure_compare(computed_signature, provided_signature)
144+
if result
145+
log.debug("Auth::HMAC validation successful for header '#{signature_header}'")
146+
else
147+
log.warn("Auth::HMAC validation failed: Signature mismatch")
148+
end
149+
result
138150
rescue StandardError => e
139151
log.error("Auth::HMAC validation failed: #{e.message}")
140152
false

lib/hooks/plugins/auth/shared_secret.rb

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,37 +62,56 @@ def self.valid?(payload:, headers:, config:)
6262
validator_config = build_config(config)
6363

6464
# Security: Check raw headers BEFORE normalization to detect tampering
65-
return false unless headers.respond_to?(:each)
65+
unless headers.respond_to?(:each)
66+
log.warn("Auth::SharedSecret validation failed: Invalid headers object")
67+
return false
68+
end
6669

6770
secret_header = validator_config[:header]
6871

69-
# Find the secret header with case-insensitive matching but preserve original value
70-
raw_secret = nil
71-
headers.each do |key, value|
72-
if key.to_s.downcase == secret_header.downcase
73-
raw_secret = value.to_s
74-
break
75-
end
76-
end
72+
# Find the secret header with case-insensitive matching
73+
raw_secret = find_header_value(headers, secret_header)
7774

78-
return false if raw_secret.nil? || raw_secret.empty?
75+
if raw_secret.nil? || raw_secret.empty?
76+
log.warn("Auth::SharedSecret validation failed: Missing or empty secret header '#{secret_header}'")
77+
return false
78+
end
7979

8080
stripped_secret = raw_secret.strip
8181

8282
# Security: Reject secrets with leading/trailing whitespace
83-
return false if raw_secret != stripped_secret
83+
if raw_secret != stripped_secret
84+
log.warn("Auth::SharedSecret validation failed: Secret contains leading/trailing whitespace")
85+
return false
86+
end
8487

8588
# Security: Reject secrets containing null bytes or other control characters
86-
return false if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/)
89+
if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/)
90+
log.warn("Auth::SharedSecret validation failed: Secret contains control characters")
91+
return false
92+
end
8793

8894
# Use secure comparison to prevent timing attacks
89-
Rack::Utils.secure_compare(secret, stripped_secret)
90-
rescue StandardError => _e
95+
result = Rack::Utils.secure_compare(secret, stripped_secret)
96+
if result
97+
log.debug("Auth::SharedSecret validation successful for header '#{secret_header}'")
98+
else
99+
log.warn("Auth::SharedSecret validation failed: Signature mismatch")
100+
end
101+
result
102+
rescue StandardError => e
103+
log.error("Auth::SharedSecret validation failed: #{e.message}")
91104
false
92105
end
93106

94107
private
95108

109+
# Short logger accessor for auth module
110+
# @return [Hooks::Log] Logger instance
111+
def self.log
112+
Hooks::Log.instance
113+
end
114+
96115
# Build final configuration by merging defaults with provided config
97116
#
98117
# Combines default configuration values with user-provided settings,

spec/unit/lib/hooks/plugins/auth/base_spec.rb

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

3+
require_relative "../../../../spec_helper"
4+
35
describe Hooks::Plugins::Auth::Base do
46
describe ".valid?" do
57
let(:payload) { '{"test": "data"}' }
@@ -206,7 +208,7 @@ def self.valid?(payload:, headers:, config:)
206208

207209
describe "documentation compliance" do
208210
it "has the expected public interface" do
209-
expect(described_class.methods).to include(:valid?, :log, :stats, :failbot, :fetch_secret)
211+
expect(described_class.methods).to include(:valid?, :log, :stats, :failbot, :fetch_secret, :find_header_value)
210212
end
211213

212214
it "valid? method accepts the documented parameters" do
@@ -217,6 +219,45 @@ def self.valid?(payload:, headers:, config:)
217219
end
218220
end
219221

222+
describe ".find_header_value" do
223+
it "finds header value with case-insensitive matching" do
224+
headers = { "Content-Type" => "application/json", "X-Test" => "value" }
225+
226+
expect(described_class.find_header_value(headers, "content-type")).to eq("application/json")
227+
expect(described_class.find_header_value(headers, "CONTENT-TYPE")).to eq("application/json")
228+
expect(described_class.find_header_value(headers, "x-test")).to eq("value")
229+
expect(described_class.find_header_value(headers, "X-TEST")).to eq("value")
230+
end
231+
232+
it "returns nil for missing headers" do
233+
headers = { "Content-Type" => "application/json" }
234+
235+
expect(described_class.find_header_value(headers, "Missing-Header")).to be_nil
236+
expect(described_class.find_header_value(headers, "")).to be_nil
237+
expect(described_class.find_header_value(headers, nil)).to be_nil
238+
end
239+
240+
it "handles invalid headers object" do
241+
expect(described_class.find_header_value(nil, "Content-Type")).to be_nil
242+
expect(described_class.find_header_value("not a hash", "Content-Type")).to be_nil
243+
expect(described_class.find_header_value(123, "Content-Type")).to be_nil
244+
end
245+
246+
it "converts non-string values to strings" do
247+
headers = { "X-Count" => 42, "X-Boolean" => true }
248+
249+
expect(described_class.find_header_value(headers, "X-Count")).to eq("42")
250+
expect(described_class.find_header_value(headers, "x-boolean")).to eq("true")
251+
end
252+
253+
it "handles headers with symbol keys" do
254+
headers = { :content_type => "application/json", "X-Test" => "value" }
255+
256+
expect(described_class.find_header_value(headers, "content_type")).to eq("application/json")
257+
expect(described_class.find_header_value(headers, "x-test")).to eq("value")
258+
end
259+
end
260+
220261
describe "global component access" do
221262
describe ".log" do
222263
it "provides access to global log" do

spec/unit/lib/hooks/plugins/auth/hmac_spec.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,4 +650,43 @@ def test_iso_timestamp(iso_timestamp, should_be_valid)
650650
expect(described_class.send(:valid_timestamp?, headers, config)).to be true
651651
end
652652
end
653+
654+
describe "debug and warning logging" do
655+
let(:secret) { "test-secret-123" }
656+
let(:config) do
657+
{
658+
auth: {
659+
header: "X-Signature",
660+
secret_env_key: "TEST_SECRET"
661+
}
662+
}
663+
end
664+
665+
before do
666+
allow(ENV).to receive(:[]).with("TEST_SECRET").and_return(secret)
667+
allow(Hooks::Log.instance).to receive(:debug)
668+
allow(Hooks::Log.instance).to receive(:warn)
669+
end
670+
671+
it "logs debug message on successful validation" do
672+
payload = '{"data": "test"}'
673+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload)
674+
headers = { "X-Signature" => "sha256=#{signature}" }
675+
676+
result = described_class.valid?(payload: payload, headers: headers, config: config)
677+
678+
expect(result).to be true
679+
expect(Hooks::Log.instance).to have_received(:debug).with("Auth::HMAC validation successful for header 'X-Signature'")
680+
end
681+
682+
it "logs warning message on signature mismatch" do
683+
payload = '{"data": "test"}'
684+
headers = { "X-Signature" => "sha256=wrong_signature" }
685+
686+
result = described_class.valid?(payload: payload, headers: headers, config: config)
687+
688+
expect(result).to be false
689+
expect(Hooks::Log.instance).to have_received(:warn).with("Auth::HMAC validation failed: Signature mismatch")
690+
end
691+
end
653692
end

spec/unit/lib/hooks/plugins/auth/shared_secret_spec.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,51 @@ def valid_with(args = {})
345345
)
346346
expect(result).to be true
347347
end
348+
349+
it "logs debug message on successful validation" do
350+
headers = { "X-API-Key" => api_key }
351+
allow(Hooks::Log.instance).to receive(:debug)
352+
353+
described_class.valid?(
354+
payload: '{"data":"value"}',
355+
headers:,
356+
config: api_config
357+
)
358+
359+
expect(Hooks::Log.instance).to have_received(:debug).with("Auth::SharedSecret validation successful for header 'X-API-Key'")
360+
end
361+
end
362+
363+
context "Debug and warning logging coverage" do
364+
let(:test_secret) { "test-secret-123" }
365+
let(:test_config) do
366+
{
367+
auth: {
368+
header: "Authorization",
369+
secret_env_key: "TEST_SECRET"
370+
}
371+
}
372+
end
373+
374+
before do
375+
allow(ENV).to receive(:[]).with("TEST_SECRET").and_return(test_secret)
376+
allow(Hooks::Log.instance).to receive(:debug)
377+
allow(Hooks::Log.instance).to receive(:warn)
378+
allow(Hooks::Log.instance).to receive(:error)
379+
end
380+
381+
it "logs warning when signature mismatch occurs" do
382+
headers = { "Authorization" => "wrong-secret" }
383+
384+
result = described_class.valid?(
385+
payload: '{"data":"value"}',
386+
headers:,
387+
config: test_config
388+
)
389+
390+
expect(result).to be false
391+
expect(Hooks::Log.instance).to have_received(:warn).with("Auth::SharedSecret validation failed: Signature mismatch")
392+
end
348393
end
349394
end
350395
end

0 commit comments

Comments
 (0)