Skip to content

Commit 2cf10f9

Browse files
authored
Merge pull request #6 from github/copilot/fix-5
feat: add "shared secret" request_validator
2 parents ec090bd + eb634bb commit 2cf10f9

File tree

9 files changed

+487
-1
lines changed

9 files changed

+487
-1
lines changed

lib/hooks/app/api.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ def validate_request(payload, headers, endpoint_config)
7474
case validator_type
7575
when "hmac"
7676
validator_class = Plugins::RequestValidator::HMAC
77+
when "shared_secret"
78+
validator_class = Plugins::RequestValidator::SharedSecret
7779
else
7880
error!("Custom validators not implemented in POC", 500)
7981
end

lib/hooks/plugins/request_validator/base.rb

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

3+
require "rack/utils"
4+
35
module Hooks
46
module Plugins
57
module RequestValidator

lib/hooks/plugins/request_validator/hmac.rb

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

33
require "openssl"
4-
require "rack/utils"
54
require "time"
65
require_relative "base"
76

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

spec/acceptance/acceptance_tests.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
FAKE_HMAC_SECRET = "octoawesome-secret"
44
FAKE_ALT_HMAC_SECRET = "octoawesome-2-secret"
5+
FAKE_SHARED_SECRET = "octoawesome-shared-secret"
56

67
require "rspec"
78
require "net/http"
@@ -126,5 +127,26 @@
126127
expect(response.body).to include("request validation failed")
127128
end
128129
end
130+
131+
describe "okta" do
132+
it "receives a POST request but contains an invalid shared secret" do
133+
payload = { event: "user.login", user: { id: "12345" } }
134+
headers = { "Content-Type" => "application/json", "Authorization" => "badvalue" }
135+
response = http.post("/webhooks/okta", payload.to_json, headers)
136+
137+
expect(response).to be_a(Net::HTTPUnauthorized)
138+
expect(response.body).to include("request validation failed")
139+
end
140+
141+
it "successfully processes a valid POST request with shared secret" do
142+
payload = { event: "user.login", user: { id: "12345" } }
143+
headers = { "Content-Type" => "application/json", "Authorization" => FAKE_SHARED_SECRET }
144+
response = http.post("/webhooks/okta", payload.to_json, headers)
145+
146+
expect(response).to be_a(Net::HTTPSuccess)
147+
body = JSON.parse(response.body)
148+
expect(body["status"]).to eq("success")
149+
end
150+
end
129151
end
130152
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
path: /okta
2+
handler: OktaHandler
3+
4+
request_validator:
5+
type: shared_secret
6+
secret_env_key: SHARED_SECRET # the name of the environment variable containing the shared secret
7+
header: Authorization

spec/acceptance/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ services:
1010
LOG_LEVEL: DEBUG
1111
GITHUB_WEBHOOK_SECRET: "octoawesome-secret"
1212
ALT_WEBHOOK_SECRET: "octoawesome-too-secret"
13+
SHARED_SECRET: "octoawesome-shared-secret"
1314
command: ["script/server"]
1415
healthcheck:
1516
test: ["CMD", "curl", "-f", "http://0.0.0.0:8080/health"]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
class OktaHandler < Hooks::Handlers::Base
4+
def call(payload:, headers:, config:)
5+
return {
6+
status: "success"
7+
}
8+
end
9+
end

0 commit comments

Comments
 (0)