Skip to content

Commit 472c649

Browse files
committed
Enhance HMAC authentication: improve timestamp validation and add tests for various timestamp formats
1 parent 70b1e5b commit 472c649

File tree

5 files changed

+458
-62
lines changed

5 files changed

+458
-62
lines changed

lib/hooks/app/auth/auth.rb

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,8 @@ def validate_auth!(payload, headers, endpoint_config, global_config = {})
3737
end
3838

3939
log.debug("validating auth for request with auth_class: #{auth_class.name}")
40-
41-
unless auth_class.valid?(
42-
payload:,
43-
headers:,
44-
config: endpoint_config
45-
)
40+
unless auth_class.valid?(payload:, headers:, config: endpoint_config)
41+
log.warn("authentication failed for request with auth_class: #{auth_class.name}")
4642
error!("authentication failed", 401)
4743
end
4844
end

lib/hooks/plugins/auth/hmac.rb

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ def self.valid?(payload:, headers:, config:)
117117

118118
# Validate timestamp if required (for services that include timestamp validation)
119119
if validator_config[:timestamp_header]
120-
return false unless valid_timestamp?(normalized_headers, validator_config)
120+
unless valid_timestamp?(normalized_headers, validator_config)
121+
log.warn("Auth::HMAC validation failed: Invalid timestamp")
122+
return false
123+
end
121124
end
122125

123126
# Compute expected signature
@@ -180,34 +183,99 @@ def self.normalize_headers(headers)
180183
#
181184
# Checks if the provided timestamp is within the configured tolerance
182185
# of the current time. This prevents replay attacks using old requests.
186+
# Supports both ISO 8601 UTC timestamps and Unix timestamps.
183187
#
184188
# @param headers [Hash<String, String>] Normalized HTTP headers
185189
# @param config [Hash<Symbol, Object>] Validator configuration
186190
# @return [Boolean] true if timestamp is valid or not required, false otherwise
187191
# @note Returns false if timestamp header is missing when required
188192
# @note Tolerance is applied as absolute difference (past or future)
193+
# @note Tries ISO 8601 UTC format first, then falls back to Unix timestamp
189194
# @api private
190195
def self.valid_timestamp?(headers, config)
191196
timestamp_header = config[:timestamp_header]
197+
tolerance = config[:timestamp_tolerance] || 300
192198
return false unless timestamp_header
193199

194-
timestamp_header = timestamp_header.downcase
195-
timestamp_value = headers[timestamp_header]
196-
200+
timestamp_value = headers[timestamp_header.downcase]
197201
return false unless timestamp_value
202+
return false if timestamp_value.strip.empty?
198203

199-
# Security: Strict timestamp validation - must be only digits with no leading zeros
200-
return false unless timestamp_value.match?(/\A[1-9]\d*\z/) || timestamp_value == "0"
204+
parsed_timestamp = parse_timestamp(timestamp_value.strip)
205+
return false unless parsed_timestamp
201206

202-
timestamp = timestamp_value.to_i
207+
# parsed_timestamp is a Time object
208+
now = Time.now.utc
209+
(now - parsed_timestamp).abs <= tolerance
210+
end
203211

204-
# Ensure timestamp is a positive integer (reject zero and negative)
205-
return false unless timestamp > 0
212+
# Parse timestamp value supporting both ISO 8601 UTC and Unix formats
213+
#
214+
# Attempts to parse the timestamp in the following order:
215+
# 1. ISO 8601 UTC format (e.g., "2025-06-12T10:30:00Z")
216+
# 2. Unix timestamp (e.g., "1609459200")
217+
#
218+
# @param timestamp_value [String] The timestamp string to parse
219+
# @return [Time, nil] Time object if parsing succeeds, nil otherwise
220+
# @note Security: Strict validation prevents various injection attacks
221+
# @api private
222+
def self.parse_timestamp(timestamp_value)
223+
# Try ISO 8601 first, then Unix
224+
ts = parse_iso8601_timestamp(timestamp_value)
225+
return ts if ts
226+
ts = parse_unix_timestamp(timestamp_value)
227+
return ts if ts
228+
nil
229+
end
206230

207-
current_time = Time.now.to_i
208-
tolerance = config[:timestamp_tolerance]
231+
# Check if timestamp string looks like ISO 8601 UTC format
232+
#
233+
# @param timestamp_value [String] The timestamp string to check
234+
# @return [Boolean] true if it appears to be ISO 8601 format
235+
# @api private
236+
def self.iso8601_timestamp?(timestamp_value)
237+
# Accepts Z, +00:00, or +0000, and T or space as separator
238+
!!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)\z/)
239+
end
209240

210-
(current_time - timestamp).abs <= tolerance
241+
# Parse ISO 8601 UTC timestamp string
242+
#
243+
# @param timestamp_value [String] ISO 8601 timestamp string
244+
# @return [Time, nil] Time object if parsing succeeds, nil otherwise
245+
# @note Only accepts UTC timestamps (ending with 'Z' or explicit UTC)
246+
# @api private
247+
def self.parse_iso8601_timestamp(timestamp_value)
248+
# Normalize 'YYYY-MM-DD HH:MM:SS(.fraction)? +0000' to 'YYYY-MM-DDTHH:MM:SS(.fraction)?+00:00'
249+
if timestamp_value =~ /\A(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?: )\+0000\z/
250+
timestamp_value = "#{$1}T#{$2}+00:00"
251+
end
252+
return nil unless iso8601_timestamp?(timestamp_value)
253+
t = Time.iso8601(timestamp_value) rescue nil
254+
return nil unless t
255+
# Only accept UTC (Z, +00:00, or +0000)
256+
return t if t.utc? || t.utc_offset == 0
257+
nil
258+
end
259+
260+
# Parse Unix timestamp string
261+
#
262+
# @param timestamp_value [String] Unix timestamp string
263+
# @return [Time, nil] Time object if parsing succeeds, nil otherwise
264+
# @api private
265+
def self.parse_unix_timestamp(timestamp_value)
266+
return nil unless unix_timestamp?(timestamp_value)
267+
ts = timestamp_value.to_i
268+
return nil if ts <= 0
269+
Time.at(ts).utc
270+
end
271+
272+
# Check if timestamp string looks like Unix timestamp format
273+
#
274+
# @param timestamp_value [String] The timestamp string to check
275+
# @return [Boolean] true if it appears to be Unix timestamp format
276+
# @api private
277+
def self.unix_timestamp?(timestamp_value)
278+
!!(timestamp_value =~ /\A\d+\z/) || timestamp_value == "0"
211279
end
212280

213281
# Compute HMAC signature based on configuration requirements

spec/acceptance/acceptance_tests.rb

Lines changed: 140 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,149 @@
117117
end
118118

119119
describe "slack" do
120-
it "receives a POST request but contains an invalid HMAC signature" do
120+
it "successfully processes a valid POST request with HMAC signature and timestamp" do
121121
payload = { text: "Hello, Slack!" }
122-
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, payload.to_json)
123-
headers = { "Content-Type" => "application/json", "Signature-256" => "sha256=#{digest}" }
122+
timestamp = Time.now.to_i.to_s
123+
body = payload.to_json
124+
signing_payload = "v0:#{timestamp}:#{body}"
125+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
126+
headers = {
127+
"Content-Type" => "application/json",
128+
"Signature-256" => "v0=#{digest}",
129+
"X-Timestamp" => timestamp
130+
}
131+
response = http.post("/webhooks/slack", body, headers)
132+
expect(response).to be_a(Net::HTTPSuccess)
133+
body = JSON.parse(response.body)
134+
expect(body["status"]).to eq("success")
135+
end
136+
137+
it "rejects request with expired timestamp" do
138+
payload = { text: "Hello, Slack!" }
139+
# Use timestamp that's 10 minutes old (beyond the 5 minute tolerance)
140+
expired_timestamp = (Time.now.to_i - 600).to_s
141+
142+
signing_payload = "v0:#{expired_timestamp}:#{payload.to_json}"
143+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
144+
145+
headers = {
146+
"Content-Type" => "application/json",
147+
"Signature-256" => "v0=#{digest}",
148+
"X-Timestamp" => expired_timestamp
149+
}
150+
124151
response = http.post("/webhooks/slack", payload.to_json, headers)
152+
expect(response).to be_a(Net::HTTPUnauthorized)
153+
expect(response.body).to include("authentication failed")
154+
end
155+
156+
it "rejects request with missing timestamp header" do
157+
payload = { text: "Hello, Slack!" }
158+
timestamp = Time.now.to_i.to_s
125159

160+
# Create signature with timestamp but don't include timestamp header
161+
signing_payload = "v0:#{timestamp}:#{payload.to_json}"
162+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
163+
164+
headers = {
165+
"Content-Type" => "application/json",
166+
"Signature-256" => "v0=#{digest}"
167+
# Missing X-Timestamp header
168+
}
169+
170+
response = http.post("/webhooks/slack", payload.to_json, headers)
171+
expect(response).to be_a(Net::HTTPUnauthorized)
172+
expect(response.body).to include("authentication failed")
173+
end
174+
175+
it "rejects request with invalid timestamp format" do
176+
payload = { text: "Hello, Slack!" }
177+
invalid_timestamp = "not-a-timestamp"
178+
179+
signing_payload = "v0:#{invalid_timestamp}:#{payload.to_json}"
180+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
181+
182+
headers = {
183+
"Content-Type" => "application/json",
184+
"Signature-256" => "v0=#{digest}",
185+
"X-Timestamp" => invalid_timestamp
186+
}
187+
188+
response = http.post("/webhooks/slack", payload.to_json, headers)
189+
expect(response).to be_a(Net::HTTPUnauthorized)
190+
expect(response.body).to include("authentication failed")
191+
end
192+
193+
it "successfully processes request with ISO 8601 UTC timestamp" do
194+
payload = { text: "Hello, Slack!" }
195+
iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
196+
body = payload.to_json
197+
signing_payload = "v0:#{iso_timestamp}:#{body}"
198+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
199+
headers = {
200+
"Content-Type" => "application/json",
201+
"Signature-256" => "v0=#{digest}",
202+
"X-Timestamp" => iso_timestamp
203+
}
204+
response = http.post("/webhooks/slack", body, headers)
205+
expect(response).to be_a(Net::HTTPSuccess)
206+
body = JSON.parse(response.body)
207+
expect(body["status"]).to eq("success")
208+
end
209+
210+
it "successfully processes request with ISO 8601 UTC timestamp using +00:00 format" do
211+
payload = { text: "Hello, Slack!" }
212+
iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S+00:00")
213+
body = payload.to_json
214+
signing_payload = "v0:#{iso_timestamp}:#{body}"
215+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
216+
headers = {
217+
"Content-Type" => "application/json",
218+
"Signature-256" => "v0=#{digest}",
219+
"X-Timestamp" => iso_timestamp
220+
}
221+
response = http.post("/webhooks/slack", body, headers)
222+
expect(response).to be_a(Net::HTTPSuccess)
223+
body = JSON.parse(response.body)
224+
expect(body["status"]).to eq("success")
225+
end
226+
227+
it "rejects request with non-UTC ISO 8601 timestamp" do
228+
payload = { text: "Hello, Slack!" }
229+
# Use EST timezone (non-UTC)
230+
non_utc_timestamp = Time.now.strftime("%Y-%m-%dT%H:%M:%S-05:00")
231+
232+
signing_payload = "v0:#{non_utc_timestamp}:#{payload.to_json}"
233+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
234+
235+
headers = {
236+
"Content-Type" => "application/json",
237+
"Signature-256" => "v0=#{digest}",
238+
"X-Timestamp" => non_utc_timestamp
239+
}
240+
241+
response = http.post("/webhooks/slack", payload.to_json, headers)
242+
expect(response).to be_a(Net::HTTPUnauthorized)
243+
expect(response.body).to include("authentication failed")
244+
end
245+
246+
it "rejects request with timestamp manipulation attack" do
247+
payload = { text: "Hello, Slack!" }
248+
original_timestamp = Time.now.to_i.to_s
249+
manipulated_timestamp = (Time.now.to_i + 100).to_s # Future timestamp
250+
251+
# Create signature with original timestamp
252+
signing_payload = "v0:#{original_timestamp}:#{payload.to_json}"
253+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
254+
255+
# But send manipulated timestamp in header
256+
headers = {
257+
"Content-Type" => "application/json",
258+
"Signature-256" => "v0=#{digest}",
259+
"X-Timestamp" => manipulated_timestamp
260+
}
261+
262+
response = http.post("/webhooks/slack", payload.to_json, headers)
126263
expect(response).to be_a(Net::HTTPUnauthorized)
127264
expect(response.body).to include("authentication failed")
128265
end

spec/acceptance/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ services:
99
environment:
1010
LOG_LEVEL: DEBUG
1111
GITHUB_WEBHOOK_SECRET: "octoawesome-secret"
12-
ALT_WEBHOOK_SECRET: "octoawesome-too-secret"
12+
ALT_WEBHOOK_SECRET: "octoawesome-2-secret"
1313
SHARED_SECRET: "octoawesome-shared-secret"
1414
DEFAULT_RETRY_SLEEP: 0
1515
RETRY_LOG_RETRIES: "false"

0 commit comments

Comments
 (0)