Skip to content

Commit f8eaf03

Browse files
authored
Merge pull request #33 from github/hmac-improvements
Enhance HMAC timestamp validation: improve parsing logic and add test…
2 parents 5eef086 + d599cc2 commit f8eaf03

File tree

3 files changed

+218
-29
lines changed

3 files changed

+218
-29
lines changed

lib/hooks/plugins/auth/hmac.rb

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -202,80 +202,84 @@ def self.valid_timestamp?(headers, config)
202202
return false if timestamp_value.strip.empty?
203203

204204
parsed_timestamp = parse_timestamp(timestamp_value.strip)
205-
return false unless parsed_timestamp
205+
return false unless parsed_timestamp.is_a?(Integer)
206206

207-
# parsed_timestamp is a Time object
208-
now = Time.now.utc
207+
now = Time.now.utc.to_i
209208
(now - parsed_timestamp).abs <= tolerance
210209
end
211210

212211
# Parse timestamp value supporting both ISO 8601 UTC and Unix formats
213212
#
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-
#
218213
# @param timestamp_value [String] The timestamp string to parse
219-
# @return [Time, nil] Time object if parsing succeeds, nil otherwise
214+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
220215
# @note Security: Strict validation prevents various injection attacks
221216
# @api private
222217
def self.parse_timestamp(timestamp_value)
223-
# Try ISO 8601 first, then Unix
218+
# Reject if contains any control characters, whitespace, or null bytes
219+
if timestamp_value =~ /[\u0000-\u001F\u007F-\u009F]/
220+
log.warn("Auth::HMAC validation failed: Timestamp contains invalid characters")
221+
return nil
222+
end
224223
ts = parse_iso8601_timestamp(timestamp_value)
225224
return ts if ts
226225
ts = parse_unix_timestamp(timestamp_value)
227226
return ts if ts
228-
nil
227+
228+
# If neither format matches, return nil
229+
log.warn("Auth::HMAC validation failed: Timestamp (#{timestamp_value}) is not valid ISO 8601 UTC or Unix format")
230+
return nil
229231
end
230232

231-
# Check if timestamp string looks like ISO 8601 UTC format
233+
# Check if timestamp string looks like ISO 8601 UTC format (must have UTC indicator)
232234
#
233235
# @param timestamp_value [String] The timestamp string to check
234-
# @return [Boolean] true if it appears to be ISO 8601 format
236+
# @return [Boolean] true if it appears to be ISO 8601 format (with or without UTC indicator)
235237
# @api private
236238
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+
!!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)?\z/)
239240
end
240241

241-
# Parse ISO 8601 UTC timestamp string
242+
# Parse ISO 8601 UTC timestamp string (must have UTC indicator)
242243
#
243244
# @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)
245+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
246+
# @note Only accepts UTC timestamps (ending with 'Z', '+00:00', '+0000')
246247
# @api private
247248
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'
249249
if timestamp_value =~ /\A(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?: )\+0000\z/
250250
timestamp_value = "#{$1}T#{$2}+00:00"
251251
end
252+
# Ensure the timestamp explicitly includes a UTC indicator
253+
return nil unless timestamp_value =~ /(Z|\+00:00|\+0000)\z/
252254
return nil unless iso8601_timestamp?(timestamp_value)
253-
t = Time.iso8601(timestamp_value) rescue nil
255+
t = Time.parse(timestamp_value) rescue nil
254256
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
257+
# The check for UTC indicator in regex makes this t.utc? or t.utc_offset == 0 redundant
258+
# but kept for safety, though it should always be true now if Time.parse succeeds.
259+
(t.utc? || t.utc_offset == 0) ? t.to_i : nil
258260
end
259261

260-
# Parse Unix timestamp string
262+
# Parse Unix timestamp string (must be positive integer, no leading zeros except for "0")
261263
#
262264
# @param timestamp_value [String] Unix timestamp string
263-
# @return [Time, nil] Time object if parsing succeeds, nil otherwise
265+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
266+
# @note Only accepts positive integer values, no leading zeros except for "0"
264267
# @api private
265268
def self.parse_unix_timestamp(timestamp_value)
266269
return nil unless unix_timestamp?(timestamp_value)
267270
ts = timestamp_value.to_i
268271
return nil if ts <= 0
269-
Time.at(ts).utc
272+
ts
270273
end
271274

272-
# Check if timestamp string looks like Unix timestamp format
275+
# Check if timestamp string looks like Unix timestamp format (no leading zeros except "0")
273276
#
274277
# @param timestamp_value [String] The timestamp string to check
275278
# @return [Boolean] true if it appears to be Unix timestamp format
276279
# @api private
277280
def self.unix_timestamp?(timestamp_value)
278-
!!(timestamp_value =~ /\A\d+\z/) || timestamp_value == "0"
281+
return true if timestamp_value == "0"
282+
!!(timestamp_value =~ /\A[1-9]\d*\z/)
279283
end
280284

281285
# Compute HMAC signature based on configuration requirements
@@ -325,7 +329,7 @@ def self.compute_signature(payload:, headers:, secret:, config:)
325329
# - {body}: Replaced with the raw payload
326330
# @example Template usage
327331
# template: "{version}:{timestamp}:{body}"
328-
# result: "v0:1609459200:{"event":"push"}"
332+
# result: "v0:1609459200:{\"event\":\"push\"}"
329333
# @api private
330334
def self.build_signing_payload(payload:, headers:, config:)
331335
template = config[:payload_template]
@@ -355,7 +359,7 @@ def self.build_signing_payload(payload:, headers:, config:)
355359
# - :algorithm_prefixed: "sha256=abc123..." (GitHub style)
356360
# - :hash_only: "abc123..." (Shopify style)
357361
# - :version_prefixed: "v0=abc123..." (Slack style)
358-
# @note Defaults to algorithm_prefixed format for unknown format styles
362+
# @note Defaults to algorithm-prefixed format for unknown format styles
359363
# @api private
360364
def self.format_signature(hash, config)
361365
format_style = FORMATS[config[:format]]

spec/acceptance/acceptance_tests.rb

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,162 @@
116116
end
117117
end
118118

119+
describe "hmac_with_timestamp" do
120+
it "successfully processes a valid POST request with HMAC signature and timestamp" do
121+
payload = { text: "Hello, World!" }
122+
timestamp = Time.now.utc.iso8601
123+
body = payload.to_json
124+
signing_payload = "#{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+
"X-HMAC-Signature" => "sha256=#{digest}",
129+
"X-HMAC-Timestamp" => timestamp
130+
}
131+
response = http.post("/webhooks/hmac_with_timestamp", 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 "successfully processes a valid POST request with HMAC signature and timestamp and an empty payload" do
138+
payload = {}
139+
timestamp = Time.now.utc.iso8601
140+
body = payload.to_json
141+
signing_payload = "#{timestamp}:#{body}"
142+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
143+
headers = {
144+
"Content-Type" => "application/json",
145+
"X-HMAC-Signature" => "sha256=#{digest}",
146+
"X-HMAC-Timestamp" => timestamp
147+
}
148+
response = http.post("/webhooks/hmac_with_timestamp", body, headers)
149+
expect(response).to be_a(Net::HTTPSuccess)
150+
body = JSON.parse(response.body)
151+
expect(body["status"]).to eq("success")
152+
end
153+
154+
it "successfully processes a valid POST request with HMAC signature and the POST has no body" do
155+
timestamp = Time.now.utc.iso8601
156+
signing_payload = "#{timestamp}:" # Empty body
157+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
158+
headers = {
159+
"Content-Type" => "application/json",
160+
"X-HMAC-Signature" => "sha256=#{digest}",
161+
"X-HMAC-Timestamp" => timestamp
162+
}
163+
response = http.post("/webhooks/hmac_with_timestamp", nil, headers)
164+
expect(response).to be_a(Net::HTTPSuccess)
165+
body = JSON.parse(response.body)
166+
expect(body["status"]).to eq("success")
167+
end
168+
169+
it "fails due to using the wrong HMAC secret" do
170+
payload = { text: "Hello, World!" }
171+
timestamp = Time.now.utc.iso8601
172+
body = payload.to_json
173+
signing_payload = "#{timestamp}:#{body}"
174+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), "bad-hmac-secret", signing_payload)
175+
headers = {
176+
"Content-Type" => "application/json",
177+
"X-HMAC-Signature" => "sha256=#{digest}",
178+
"X-HMAC-Timestamp" => timestamp
179+
}
180+
response = http.post("/webhooks/hmac_with_timestamp", body, headers)
181+
expect(response).to be_a(Net::HTTPUnauthorized)
182+
expect(response.body).to include("authentication failed")
183+
end
184+
185+
it "fails due to missing timestamp header" do
186+
payload = { text: "Hello, World!" }
187+
body = payload.to_json
188+
signing_payload = "#{Time.now.utc.iso8601}:#{body}"
189+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
190+
headers = {
191+
"Content-Type" => "application/json",
192+
"X-HMAC-Signature" => "sha256=#{digest}"
193+
# Missing X-HMAC-Timestamp header
194+
}
195+
response = http.post("/webhooks/hmac_with_timestamp", body, headers)
196+
expect(response).to be_a(Net::HTTPUnauthorized)
197+
expect(response.body).to include("authentication failed")
198+
end
199+
200+
it "fails due to invalid timestamp format" do
201+
payload = { text: "Hello, World!" }
202+
invalid_timestamp = "not-a-timestamp"
203+
body = payload.to_json
204+
signing_payload = "#{invalid_timestamp}:#{body}"
205+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
206+
headers = {
207+
"Content-Type" => "application/json",
208+
"X-HMAC-Signature" => "sha256=#{digest}",
209+
"X-HMAC-Timestamp" => invalid_timestamp
210+
}
211+
response = http.post("/webhooks/hmac_with_timestamp", body, headers)
212+
expect(response).to be_a(Net::HTTPUnauthorized)
213+
expect(response.body).to include("authentication failed")
214+
end
215+
216+
it "rejects request with timestamp manipulation attack" do
217+
payload = { text: "Hello, World!" }
218+
original_timestamp = Time.now.utc.iso8601
219+
manipulated_timestamp = (Time.now.utc + 100).iso8601 # Future timestamp
220+
221+
# Create signature with original timestamp
222+
signing_payload = "#{original_timestamp}:#{payload.to_json}"
223+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
224+
225+
# But send manipulated timestamp in header
226+
headers = {
227+
"Content-Type" => "application/json",
228+
"X-HMAC-Signature" => "sha256=#{digest}",
229+
"X-HMAC-Timestamp" => manipulated_timestamp
230+
}
231+
232+
response = http.post("/webhooks/hmac_with_timestamp", payload.to_json, headers)
233+
expect(response).to be_a(Net::HTTPUnauthorized)
234+
expect(response.body).to include("authentication failed")
235+
end
236+
237+
it "fails because the timestamp is too old" do
238+
payload = { text: "Hello, World!" }
239+
# Use timestamp that's 10 minutes old (beyond the tolerance)
240+
expired_timestamp = (Time.now.utc - 600).iso8601
241+
242+
signing_payload = "#{expired_timestamp}:#{payload.to_json}"
243+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
244+
245+
headers = {
246+
"Content-Type" => "application/json",
247+
"X-HMAC-Signature" => "sha256=#{digest}",
248+
"X-HMAC-Timestamp" => expired_timestamp
249+
}
250+
251+
response = http.post("/webhooks/hmac_with_timestamp", payload.to_json, headers)
252+
expect(response).to be_a(Net::HTTPUnauthorized)
253+
expect(response.body).to include("authentication failed")
254+
end
255+
256+
it "fails because the wrong HMAC algorithm is used" do
257+
payload = { text: "Hello, World!" }
258+
timestamp = Time.now.utc.iso8601
259+
body = payload.to_json
260+
signing_payload = "#{timestamp}:#{body}"
261+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha512"), FAKE_ALT_HMAC_SECRET, signing_payload)
262+
263+
headers = {
264+
"Content-Type" => "application/json",
265+
"X-HMAC-Signature" => "sha512=#{digest}",
266+
"X-HMAC-Timestamp" => timestamp
267+
}
268+
269+
response = http.post("/webhooks/hmac_with_timestamp", body, headers)
270+
expect(response).to be_a(Net::HTTPUnauthorized)
271+
expect(response.body).to include("authentication failed")
272+
end
273+
end
274+
119275
describe "slack" do
120276
it "successfully processes a valid POST request with HMAC signature and timestamp" do
121277
payload = { text: "Hello, Slack!" }
@@ -207,6 +363,23 @@
207363
expect(body["status"]).to eq("success")
208364
end
209365

366+
it "successfully processes request with ISO 8601 UTC timestamp (ruby default method)" do
367+
payload = { text: "Hello, Slack!" }
368+
iso_timestamp = Time.now.utc.iso8601
369+
body = payload.to_json
370+
signing_payload = "v0:#{iso_timestamp}:#{body}"
371+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload)
372+
headers = {
373+
"Content-Type" => "application/json",
374+
"Signature-256" => "v0=#{digest}",
375+
"X-Timestamp" => iso_timestamp
376+
}
377+
response = http.post("/webhooks/slack", body, headers)
378+
expect(response).to be_a(Net::HTTPSuccess)
379+
body = JSON.parse(response.body)
380+
expect(body["status"]).to eq("success")
381+
end
382+
210383
it "successfully processes request with ISO 8601 UTC timestamp using +00:00 format" do
211384
payload = { text: "Hello, Slack!" }
212385
iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S+00:00")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
path: /hmac_with_timestamp
2+
handler: Hello
3+
4+
auth:
5+
type: hmac
6+
secret_env_key: ALT_WEBHOOK_SECRET
7+
header: X-HMAC-Signature
8+
timestamp_header: X-HMAC-Timestamp
9+
timestamp_tolerance: 60 # 1 minute
10+
algorithm: sha256
11+
format: "algorithm=signature" # produces "sha256=abc123..."
12+
payload_template: "{timestamp}:{body}"

0 commit comments

Comments
 (0)