Skip to content

Commit 1e2c96b

Browse files
authored
Merge pull request #34 from github/copilot/fix-31
task: Refactor HMAC authentication plugin for better maintainability and cleaner tests
2 parents ee345ce + 0308af3 commit 1e2c96b

File tree

6 files changed

+330
-282
lines changed

6 files changed

+330
-282
lines changed

lib/hooks/app/api.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class << self
2727

2828
# Create a new configured API class
2929
def self.create(config:, endpoints:, log:)
30+
# :nocov:
3031
@server_start_time = Time.now
3132

3233
api_class = Class.new(Grape::API) do
@@ -152,6 +153,7 @@ def self.create(config:, endpoints:, log:)
152153
end
153154

154155
api_class
156+
# :nocov:
155157
end
156158
end
157159
end

lib/hooks/plugins/auth/hmac.rb

Lines changed: 6 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "openssl"
44
require "time"
55
require_relative "base"
6+
require_relative "timestamp_validator"
67

78
module Hooks
89
module Plugins
@@ -190,7 +191,6 @@ def self.normalize_headers(headers)
190191
# @return [Boolean] true if timestamp is valid or not required, false otherwise
191192
# @note Returns false if timestamp header is missing when required
192193
# @note Tolerance is applied as absolute difference (past or future)
193-
# @note Tries ISO 8601 UTC format first, then falls back to Unix timestamp
194194
# @api private
195195
def self.valid_timestamp?(headers, config)
196196
timestamp_header = config[:timestamp_header]
@@ -199,87 +199,16 @@ def self.valid_timestamp?(headers, config)
199199

200200
timestamp_value = headers[timestamp_header.downcase]
201201
return false unless timestamp_value
202-
return false if timestamp_value.strip.empty?
203202

204-
parsed_timestamp = parse_timestamp(timestamp_value.strip)
205-
return false unless parsed_timestamp.is_a?(Integer)
206-
207-
now = Time.now.utc.to_i
208-
(now - parsed_timestamp).abs <= tolerance
209-
end
210-
211-
# Parse timestamp value supporting both ISO 8601 UTC and Unix formats
212-
#
213-
# @param timestamp_value [String] The timestamp string to parse
214-
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
215-
# @note Security: Strict validation prevents various injection attacks
216-
# @api private
217-
def self.parse_timestamp(timestamp_value)
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
223-
ts = parse_iso8601_timestamp(timestamp_value)
224-
return ts if ts
225-
ts = parse_unix_timestamp(timestamp_value)
226-
return ts if ts
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
231-
end
232-
233-
# Check if timestamp string looks like ISO 8601 UTC format (must have UTC indicator)
234-
#
235-
# @param timestamp_value [String] The timestamp string to check
236-
# @return [Boolean] true if it appears to be ISO 8601 format (with or without UTC indicator)
237-
# @api private
238-
def self.iso8601_timestamp?(timestamp_value)
239-
!!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)?\z/)
240-
end
241-
242-
# Parse ISO 8601 UTC timestamp string (must have UTC indicator)
243-
#
244-
# @param timestamp_value [String] ISO 8601 timestamp string
245-
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
246-
# @note Only accepts UTC timestamps (ending with 'Z', '+00:00', '+0000')
247-
# @api private
248-
def self.parse_iso8601_timestamp(timestamp_value)
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-
# Ensure the timestamp explicitly includes a UTC indicator
253-
return nil unless timestamp_value =~ /(Z|\+00:00|\+0000)\z/
254-
return nil unless iso8601_timestamp?(timestamp_value)
255-
t = Time.parse(timestamp_value) rescue nil
256-
return nil unless t
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
260-
end
261-
262-
# Parse Unix timestamp string (must be positive integer, no leading zeros except for "0")
263-
#
264-
# @param timestamp_value [String] Unix timestamp string
265-
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
266-
# @note Only accepts positive integer values, no leading zeros except for "0"
267-
# @api private
268-
def self.parse_unix_timestamp(timestamp_value)
269-
return nil unless unix_timestamp?(timestamp_value)
270-
ts = timestamp_value.to_i
271-
return nil if ts <= 0
272-
ts
203+
timestamp_validator.valid?(timestamp_value, tolerance)
273204
end
274205

275-
# Check if timestamp string looks like Unix timestamp format (no leading zeros except "0")
206+
# Get timestamp validator instance
276207
#
277-
# @param timestamp_value [String] The timestamp string to check
278-
# @return [Boolean] true if it appears to be Unix timestamp format
208+
# @return [TimestampValidator] Singleton timestamp validator instance
279209
# @api private
280-
def self.unix_timestamp?(timestamp_value)
281-
return true if timestamp_value == "0"
282-
!!(timestamp_value =~ /\A[1-9]\d*\z/)
210+
def self.timestamp_validator
211+
@timestamp_validator ||= TimestampValidator.new
283212
end
284213

285214
# Compute HMAC signature based on configuration requirements
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# frozen_string_literal: true
2+
3+
require "time"
4+
5+
module Hooks
6+
module Plugins
7+
module Auth
8+
# Validates and parses timestamps for webhook authentication
9+
#
10+
# This class provides secure timestamp validation supporting both
11+
# ISO 8601 UTC format and Unix timestamp format. It includes
12+
# strict validation to prevent various injection attacks.
13+
#
14+
# @example Basic usage
15+
# validator = TimestampValidator.new
16+
# validator.valid?("1609459200", 300) # => true/false
17+
# validator.parse("2021-01-01T00:00:00Z") # => 1609459200
18+
#
19+
# @api private
20+
class TimestampValidator
21+
# Validate timestamp against current time with tolerance
22+
#
23+
# @param timestamp_value [String] The timestamp string to validate
24+
# @param tolerance [Integer] Maximum age in seconds (default: 300)
25+
# @return [Boolean] true if timestamp is valid and within tolerance
26+
def valid?(timestamp_value, tolerance = 300)
27+
return false if timestamp_value.nil? || timestamp_value.strip.empty?
28+
29+
parsed_timestamp = parse(timestamp_value.strip)
30+
return false unless parsed_timestamp.is_a?(Integer)
31+
32+
now = Time.now.utc.to_i
33+
(now - parsed_timestamp).abs <= tolerance
34+
end
35+
36+
# Parse timestamp value supporting both ISO 8601 UTC and Unix formats
37+
#
38+
# @param timestamp_value [String] The timestamp string to parse
39+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
40+
# @note Security: Strict validation prevents various injection attacks
41+
def parse(timestamp_value)
42+
return nil if invalid_characters?(timestamp_value)
43+
44+
parse_iso8601_timestamp(timestamp_value) || parse_unix_timestamp(timestamp_value)
45+
end
46+
47+
private
48+
49+
# Check for control characters, whitespace, or null bytes
50+
#
51+
# @param timestamp_value [String] The timestamp to check
52+
# @return [Boolean] true if contains invalid characters
53+
def invalid_characters?(timestamp_value)
54+
if timestamp_value =~ /[\u0000-\u001F\u007F-\u009F]/
55+
log_warning("Timestamp contains invalid characters")
56+
true
57+
else
58+
false
59+
end
60+
end
61+
62+
# Parse ISO 8601 UTC timestamp string (must have UTC indicator)
63+
#
64+
# @param timestamp_value [String] ISO 8601 timestamp string
65+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
66+
def parse_iso8601_timestamp(timestamp_value)
67+
# Handle space-separated format and convert to standard ISO format
68+
if timestamp_value =~ /\A(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?: )\+0000\z/
69+
timestamp_value = "#{$1}T#{$2}+00:00"
70+
end
71+
72+
# Ensure the timestamp explicitly includes a UTC indicator
73+
return nil unless timestamp_value =~ /(Z|\+00:00|\+0000)\z/
74+
return nil unless iso8601_format?(timestamp_value)
75+
76+
parsed_time = parse_time_safely(timestamp_value)
77+
return nil unless parsed_time&.utc_offset&.zero?
78+
79+
parsed_time.to_i
80+
end
81+
82+
# Parse Unix timestamp string (must be positive integer, no leading zeros except for "0")
83+
#
84+
# @param timestamp_value [String] Unix timestamp string
85+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
86+
def parse_unix_timestamp(timestamp_value)
87+
return nil unless unix_format?(timestamp_value)
88+
89+
ts = timestamp_value.to_i
90+
return nil if ts <= 0
91+
92+
ts
93+
end
94+
95+
# Check if timestamp string looks like ISO 8601 format
96+
#
97+
# @param timestamp_value [String] The timestamp string to check
98+
# @return [Boolean] true if it appears to be ISO 8601 format
99+
def iso8601_format?(timestamp_value)
100+
!!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)?\z/)
101+
end
102+
103+
# Check if timestamp string looks like Unix timestamp format
104+
#
105+
# @param timestamp_value [String] The timestamp string to check
106+
# @return [Boolean] true if it appears to be Unix timestamp format
107+
def unix_format?(timestamp_value)
108+
return true if timestamp_value == "0"
109+
!!(timestamp_value =~ /\A[1-9]\d*\z/)
110+
end
111+
112+
# Safely parse time string with error handling
113+
#
114+
# @param timestamp_value [String] The timestamp string to parse
115+
# @return [Time, nil] Parsed time object or nil if parsing fails
116+
def parse_time_safely(timestamp_value)
117+
Time.parse(timestamp_value)
118+
rescue ArgumentError
119+
nil
120+
end
121+
122+
# Log warning message
123+
#
124+
# @param message [String] Warning message to log
125+
def log_warning(message)
126+
return unless defined?(Hooks::Log) && Hooks::Log.instance
127+
128+
Hooks::Log.instance.warn("Auth::TimestampValidator validation failed: #{message}")
129+
end
130+
end
131+
end
132+
end
133+
end

0 commit comments

Comments
 (0)