Skip to content

Commit f057fdd

Browse files
committed
Enhance HMAC timestamp validation: improve parsing logic and add tests for ISO 8601 UTC format
1 parent 5eef086 commit f057fdd

File tree

2 files changed

+42
-44
lines changed

2 files changed

+42
-44
lines changed

lib/hooks/plugins/auth/hmac.rb

Lines changed: 25 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -202,80 +202,61 @@ 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
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
222212
def self.parse_timestamp(timestamp_value)
223-
# Try ISO 8601 first, then Unix
213+
# Reject if contains any control characters, whitespace, or null bytes
214+
if timestamp_value =~ /[\u0000-\u001F\u007F-\u009F]/
215+
log.warn("Auth::HMAC validation failed: Timestamp contains invalid characters")
216+
return nil
217+
end
218+
if timestamp_value != timestamp_value.strip
219+
log.warn("Auth::HMAC validation failed: Timestamp contains leading/trailing whitespace")
220+
return nil
221+
end
224222
ts = parse_iso8601_timestamp(timestamp_value)
225223
return ts if ts
226224
ts = parse_unix_timestamp(timestamp_value)
227225
return ts if ts
228-
nil
226+
227+
# If neither format matches, return nil
228+
log.warn("Auth::HMAC validation failed: Timestamp (#{timestamp_value}) is not valid ISO 8601 UTC or Unix format")
229+
return nil
229230
end
230231

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
232+
# Check if timestamp string looks like ISO 8601 UTC format (must have UTC indicator)
236233
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/)
234+
!!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)?\z/)
239235
end
240236

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
237+
# Parse ISO 8601 UTC timestamp string (must have UTC indicator)
247238
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'
249239
if timestamp_value =~ /\A(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?: )\+0000\z/
250240
timestamp_value = "#{$1}T#{$2}+00:00"
251241
end
252242
return nil unless iso8601_timestamp?(timestamp_value)
253-
t = Time.iso8601(timestamp_value) rescue nil
243+
t = Time.parse(timestamp_value) rescue nil
254244
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
245+
(t.utc? || t.utc_offset == 0) ? t.to_i : nil
258246
end
259247

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
248+
# Parse Unix timestamp string (must be positive integer, no leading zeros except for "0")
265249
def self.parse_unix_timestamp(timestamp_value)
266250
return nil unless unix_timestamp?(timestamp_value)
267251
ts = timestamp_value.to_i
268252
return nil if ts <= 0
269-
Time.at(ts).utc
253+
ts
270254
end
271255

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
256+
# Check if timestamp string looks like Unix timestamp format (no leading zeros except "0")
277257
def self.unix_timestamp?(timestamp_value)
278-
!!(timestamp_value =~ /\A\d+\z/) || timestamp_value == "0"
258+
return true if timestamp_value == "0"
259+
!!(timestamp_value =~ /\A[1-9]\d*\z/)
279260
end
280261

281262
# Compute HMAC signature based on configuration requirements

spec/acceptance/acceptance_tests.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,23 @@
207207
expect(body["status"]).to eq("success")
208208
end
209209

210+
it "successfully processes request with ISO 8601 UTC timestamp (ruby default method)" do
211+
payload = { text: "Hello, Slack!" }
212+
iso_timestamp = Time.now.utc.iso8601
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+
210227
it "successfully processes request with ISO 8601 UTC timestamp using +00:00 format" do
211228
payload = { text: "Hello, Slack!" }
212229
iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S+00:00")

0 commit comments

Comments
 (0)