Skip to content

Commit d906144

Browse files
CopilotGrantBirki
andcommitted
Implement shared secret request validator with comprehensive tests
Co-authored-by: GrantBirki <[email protected]>
1 parent b9285cb commit d906144

File tree

2 files changed

+441
-0
lines changed

2 files changed

+441
-0
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# frozen_string_literal: true
2+
3+
require "rack/utils"
4+
require_relative "base"
5+
6+
module Hooks
7+
module Plugins
8+
module RequestValidator
9+
# Generic shared secret validator for webhooks
10+
#
11+
# This validator provides simple shared secret authentication for webhook requests.
12+
# It compares a secret value sent in a configurable HTTP header against the expected
13+
# secret value. This is a common (though less secure than HMAC) authentication pattern
14+
# used by various webhook providers.
15+
#
16+
# @example Basic configuration
17+
# request_validator:
18+
# type: SharedSecret
19+
# header: Authorization
20+
#
21+
# @example Custom header configuration
22+
# request_validator:
23+
# type: SharedSecret
24+
# header: X-API-Key
25+
#
26+
# @note This validator performs direct string comparison of the shared secret.
27+
# While simpler than HMAC, it provides less security since the secret is
28+
# transmitted directly in the request header.
29+
class SharedSecret < Base
30+
# Default configuration values for shared secret validation
31+
#
32+
# @return [Hash<Symbol, String>] Default configuration settings
33+
DEFAULT_CONFIG = {
34+
header: "Authorization"
35+
}.freeze
36+
37+
# Validate shared secret from webhook requests
38+
#
39+
# Performs secure comparison of the shared secret value from the configured
40+
# header against the expected secret. Uses secure comparison to prevent
41+
# timing attacks.
42+
#
43+
# @param payload [String] Raw request body (unused but required by interface)
44+
# @param headers [Hash<String, String>] HTTP headers from the request
45+
# @param secret [String] Expected secret value for comparison
46+
# @param config [Hash] Endpoint configuration containing validator settings
47+
# @option config [Hash] :request_validator Validator-specific configuration
48+
# @option config [String] :header ('Authorization') Header containing the secret
49+
# @return [Boolean] true if secret is valid, false otherwise
50+
# @raise [StandardError] Rescued internally, returns false on any error
51+
# @note This method is designed to be safe and will never raise exceptions
52+
# @note Uses Rack::Utils.secure_compare to prevent timing attacks
53+
# @example Basic validation
54+
# SharedSecret.valid?(
55+
# payload: request_body,
56+
# headers: request.headers,
57+
# secret: ENV['WEBHOOK_SECRET'],
58+
# config: { request_validator: { header: 'Authorization' } }
59+
# )
60+
def self.valid?(payload:, headers:, secret:, config:)
61+
return false if secret.nil? || secret.empty?
62+
63+
validator_config = build_config(config)
64+
65+
# Security: Check raw headers BEFORE normalization to detect tampering
66+
return false unless headers.respond_to?(:each)
67+
68+
secret_header = validator_config[:header]
69+
70+
# Find the secret header with case-insensitive matching but preserve original value
71+
raw_secret = nil
72+
headers.each do |key, value|
73+
if key.to_s.downcase == secret_header.downcase
74+
raw_secret = value.to_s
75+
break
76+
end
77+
end
78+
79+
return false if raw_secret.nil? || raw_secret.empty?
80+
81+
# Security: Reject secrets with leading/trailing whitespace
82+
return false if raw_secret != raw_secret.strip
83+
84+
# Security: Reject secrets containing null bytes or other control characters
85+
return false if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/)
86+
87+
# Use secure comparison to prevent timing attacks
88+
Rack::Utils.secure_compare(secret, raw_secret.strip)
89+
rescue StandardError => _e
90+
# Log error in production - for now just return false
91+
false
92+
end
93+
94+
private
95+
96+
# Build final configuration by merging defaults with provided config
97+
#
98+
# Combines default configuration values with user-provided settings,
99+
# ensuring all required configuration keys are present with sensible defaults.
100+
#
101+
# @param config [Hash] Raw endpoint configuration
102+
# @return [Hash<Symbol, Object>] Merged configuration with defaults applied
103+
# @note Missing configuration values are filled with DEFAULT_CONFIG values
104+
# @api private
105+
def self.build_config(config)
106+
validator_config = config.dig(:request_validator) || {}
107+
108+
DEFAULT_CONFIG.merge({
109+
header: validator_config[:header] || DEFAULT_CONFIG[:header]
110+
})
111+
end
112+
end
113+
end
114+
end
115+
end

0 commit comments

Comments
 (0)