diff --git a/lib/hooks/app/api.rb b/lib/hooks/app/api.rb index acece6e..587180c 100644 --- a/lib/hooks/app/api.rb +++ b/lib/hooks/app/api.rb @@ -27,6 +27,7 @@ class << self # Create a new configured API class def self.create(config:, endpoints:, log:) + # :nocov: @server_start_time = Time.now api_class = Class.new(Grape::API) do @@ -152,6 +153,7 @@ def self.create(config:, endpoints:, log:) end api_class + # :nocov: end end end diff --git a/lib/hooks/plugins/auth/hmac.rb b/lib/hooks/plugins/auth/hmac.rb index ad3cbc0..c420bb1 100644 --- a/lib/hooks/plugins/auth/hmac.rb +++ b/lib/hooks/plugins/auth/hmac.rb @@ -3,6 +3,7 @@ require "openssl" require "time" require_relative "base" +require_relative "timestamp_validator" module Hooks module Plugins @@ -190,7 +191,6 @@ def self.normalize_headers(headers) # @return [Boolean] true if timestamp is valid or not required, false otherwise # @note Returns false if timestamp header is missing when required # @note Tolerance is applied as absolute difference (past or future) - # @note Tries ISO 8601 UTC format first, then falls back to Unix timestamp # @api private def self.valid_timestamp?(headers, config) timestamp_header = config[:timestamp_header] @@ -199,87 +199,16 @@ def self.valid_timestamp?(headers, config) timestamp_value = headers[timestamp_header.downcase] return false unless timestamp_value - return false if timestamp_value.strip.empty? - parsed_timestamp = parse_timestamp(timestamp_value.strip) - return false unless parsed_timestamp.is_a?(Integer) - - now = Time.now.utc.to_i - (now - parsed_timestamp).abs <= tolerance - end - - # Parse timestamp value supporting both ISO 8601 UTC and Unix formats - # - # @param timestamp_value [String] The timestamp string to parse - # @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise - # @note Security: Strict validation prevents various injection attacks - # @api private - def self.parse_timestamp(timestamp_value) - # Reject if contains any control characters, whitespace, or null bytes - if timestamp_value =~ /[\u0000-\u001F\u007F-\u009F]/ - log.warn("Auth::HMAC validation failed: Timestamp contains invalid characters") - return nil - end - ts = parse_iso8601_timestamp(timestamp_value) - return ts if ts - ts = parse_unix_timestamp(timestamp_value) - return ts if ts - - # If neither format matches, return nil - log.warn("Auth::HMAC validation failed: Timestamp (#{timestamp_value}) is not valid ISO 8601 UTC or Unix format") - return nil - end - - # Check if timestamp string looks like ISO 8601 UTC format (must have UTC indicator) - # - # @param timestamp_value [String] The timestamp string to check - # @return [Boolean] true if it appears to be ISO 8601 format (with or without UTC indicator) - # @api private - def self.iso8601_timestamp?(timestamp_value) - !!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)?\z/) - end - - # Parse ISO 8601 UTC timestamp string (must have UTC indicator) - # - # @param timestamp_value [String] ISO 8601 timestamp string - # @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise - # @note Only accepts UTC timestamps (ending with 'Z', '+00:00', '+0000') - # @api private - def self.parse_iso8601_timestamp(timestamp_value) - if timestamp_value =~ /\A(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?: )\+0000\z/ - timestamp_value = "#{$1}T#{$2}+00:00" - end - # Ensure the timestamp explicitly includes a UTC indicator - return nil unless timestamp_value =~ /(Z|\+00:00|\+0000)\z/ - return nil unless iso8601_timestamp?(timestamp_value) - t = Time.parse(timestamp_value) rescue nil - return nil unless t - # The check for UTC indicator in regex makes this t.utc? or t.utc_offset == 0 redundant - # but kept for safety, though it should always be true now if Time.parse succeeds. - (t.utc? || t.utc_offset == 0) ? t.to_i : nil - end - - # Parse Unix timestamp string (must be positive integer, no leading zeros except for "0") - # - # @param timestamp_value [String] Unix timestamp string - # @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise - # @note Only accepts positive integer values, no leading zeros except for "0" - # @api private - def self.parse_unix_timestamp(timestamp_value) - return nil unless unix_timestamp?(timestamp_value) - ts = timestamp_value.to_i - return nil if ts <= 0 - ts + timestamp_validator.valid?(timestamp_value, tolerance) end - # Check if timestamp string looks like Unix timestamp format (no leading zeros except "0") + # Get timestamp validator instance # - # @param timestamp_value [String] The timestamp string to check - # @return [Boolean] true if it appears to be Unix timestamp format + # @return [TimestampValidator] Singleton timestamp validator instance # @api private - def self.unix_timestamp?(timestamp_value) - return true if timestamp_value == "0" - !!(timestamp_value =~ /\A[1-9]\d*\z/) + def self.timestamp_validator + @timestamp_validator ||= TimestampValidator.new end # Compute HMAC signature based on configuration requirements diff --git a/lib/hooks/plugins/auth/timestamp_validator.rb b/lib/hooks/plugins/auth/timestamp_validator.rb new file mode 100644 index 0000000..c6724d2 --- /dev/null +++ b/lib/hooks/plugins/auth/timestamp_validator.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "time" + +module Hooks + module Plugins + module Auth + # Validates and parses timestamps for webhook authentication + # + # This class provides secure timestamp validation supporting both + # ISO 8601 UTC format and Unix timestamp format. It includes + # strict validation to prevent various injection attacks. + # + # @example Basic usage + # validator = TimestampValidator.new + # validator.valid?("1609459200", 300) # => true/false + # validator.parse("2021-01-01T00:00:00Z") # => 1609459200 + # + # @api private + class TimestampValidator + # Validate timestamp against current time with tolerance + # + # @param timestamp_value [String] The timestamp string to validate + # @param tolerance [Integer] Maximum age in seconds (default: 300) + # @return [Boolean] true if timestamp is valid and within tolerance + def valid?(timestamp_value, tolerance = 300) + return false if timestamp_value.nil? || timestamp_value.strip.empty? + + parsed_timestamp = parse(timestamp_value.strip) + return false unless parsed_timestamp.is_a?(Integer) + + now = Time.now.utc.to_i + (now - parsed_timestamp).abs <= tolerance + end + + # Parse timestamp value supporting both ISO 8601 UTC and Unix formats + # + # @param timestamp_value [String] The timestamp string to parse + # @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise + # @note Security: Strict validation prevents various injection attacks + def parse(timestamp_value) + return nil if invalid_characters?(timestamp_value) + + parse_iso8601_timestamp(timestamp_value) || parse_unix_timestamp(timestamp_value) + end + + private + + # Check for control characters, whitespace, or null bytes + # + # @param timestamp_value [String] The timestamp to check + # @return [Boolean] true if contains invalid characters + def invalid_characters?(timestamp_value) + if timestamp_value =~ /[\u0000-\u001F\u007F-\u009F]/ + log_warning("Timestamp contains invalid characters") + true + else + false + end + end + + # Parse ISO 8601 UTC timestamp string (must have UTC indicator) + # + # @param timestamp_value [String] ISO 8601 timestamp string + # @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise + def parse_iso8601_timestamp(timestamp_value) + # Handle space-separated format and convert to standard ISO format + if timestamp_value =~ /\A(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?: )\+0000\z/ + timestamp_value = "#{$1}T#{$2}+00:00" + end + + # Ensure the timestamp explicitly includes a UTC indicator + return nil unless timestamp_value =~ /(Z|\+00:00|\+0000)\z/ + return nil unless iso8601_format?(timestamp_value) + + parsed_time = parse_time_safely(timestamp_value) + return nil unless parsed_time&.utc_offset&.zero? + + parsed_time.to_i + end + + # Parse Unix timestamp string (must be positive integer, no leading zeros except for "0") + # + # @param timestamp_value [String] Unix timestamp string + # @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise + def parse_unix_timestamp(timestamp_value) + return nil unless unix_format?(timestamp_value) + + ts = timestamp_value.to_i + return nil if ts <= 0 + + ts + end + + # Check if timestamp string looks like ISO 8601 format + # + # @param timestamp_value [String] The timestamp string to check + # @return [Boolean] true if it appears to be ISO 8601 format + def iso8601_format?(timestamp_value) + !!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)?\z/) + end + + # Check if timestamp string looks like Unix timestamp format + # + # @param timestamp_value [String] The timestamp string to check + # @return [Boolean] true if it appears to be Unix timestamp format + def unix_format?(timestamp_value) + return true if timestamp_value == "0" + !!(timestamp_value =~ /\A[1-9]\d*\z/) + end + + # Safely parse time string with error handling + # + # @param timestamp_value [String] The timestamp string to parse + # @return [Time, nil] Parsed time object or nil if parsing fails + def parse_time_safely(timestamp_value) + Time.parse(timestamp_value) + rescue ArgumentError + nil + end + + # Log warning message + # + # @param message [String] Warning message to log + def log_warning(message) + return unless defined?(Hooks::Log) && Hooks::Log.instance + + Hooks::Log.instance.warn("Auth::TimestampValidator validation failed: #{message}") + end + end + end + end +end diff --git a/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb b/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb index e5b17e9..aab7dff 100644 --- a/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb +++ b/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb @@ -31,9 +31,26 @@ def valid_with(args = {}) described_class.valid?(payload:, **args) end + def create_signature(signing_payload, algorithm = "sha256") + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(algorithm), secret, signing_payload) + end + + def create_algorithm_prefixed_signature(signing_payload = payload, algorithm = "sha256") + "#{algorithm}=#{create_signature(signing_payload, algorithm)}" + end + + def create_version_prefixed_signature(signing_payload, version = "v0") + "#{version}=#{create_signature(signing_payload)}" + end + + def create_timestamped_signature(timestamp, version = "v0") + signing_payload = "#{version}:#{timestamp}:#{payload}" + create_version_prefixed_signature(signing_payload, version) + end + describe ".valid?" do context "with algorithm-prefixed format" do - let(:signature) { "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload) } + let(:signature) { create_algorithm_prefixed_signature } let(:headers) { { default_header => signature } } it "returns true for a valid signature" do @@ -77,7 +94,7 @@ def valid_with(args = {}) } } end - let(:signature) { OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload) } + let(:signature) { create_signature(payload) } let(:headers) { { header => signature } } it "returns true for a valid hash-only signature" do @@ -94,9 +111,7 @@ def valid_with(args = {}) let(:header) { "X-Signature-Versioned" } let(:timestamp_header) { "X-Request-Timestamp" } let(:timestamp) { Time.now.to_i.to_s } - let(:payload_template) { "v0:{timestamp}:{body}" } - let(:signing_payload) { "v0:#{timestamp}:#{payload}" } - let(:signature) { "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) } + let(:signature) { create_timestamped_signature(timestamp) } let(:headers) { { header => signature, timestamp_header => timestamp } } let(:config) do { @@ -106,7 +121,7 @@ def valid_with(args = {}) algorithm: "sha256", format: "version=signature", version_prefix: "v0", - payload_template: payload_template, + payload_template: "v0:{timestamp}:{body}", timestamp_tolerance: 300, secret_env_key: "HMAC_TEST_SECRET" } @@ -119,8 +134,7 @@ def valid_with(args = {}) it "returns false for an expired timestamp" do old_timestamp = (Time.now.to_i - 1000).to_s - old_signing_payload = "v0:#{old_timestamp}:#{payload}" - old_signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, old_signing_payload) + old_signature = create_timestamped_signature(old_timestamp) bad_headers = { header => old_signature, timestamp_header => old_timestamp } expect(valid_with(headers: bad_headers, config:)).to be false end @@ -156,7 +170,7 @@ def valid_with(args = {}) end context "with missing config values" do - let(:headers) { { "X-Signature" => "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload) } } + let(:headers) { { "X-Signature" => create_algorithm_prefixed_signature } } let(:config) { { auth: { secret_env_key: "HMAC_TEST_SECRET" } } } it "uses defaults and validates correctly" do @@ -165,7 +179,7 @@ def valid_with(args = {}) end context "with tampered payload" do - let(:signature) { "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload) } + let(:signature) { create_algorithm_prefixed_signature } let(:headers) { { default_header => signature } } let(:tampered_payload) { '{"foo":"evil"}' } @@ -190,7 +204,7 @@ def valid_with(args = {}) end context "security and edge cases" do - let(:signature) { "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload) } + let(:signature) { create_algorithm_prefixed_signature } let(:headers) { { default_header => signature } } it "returns false for empty signature header value" do @@ -214,7 +228,7 @@ def valid_with(args = {}) end it "returns false for signature with wrong algorithm prefix" do - wrong_algo_headers = { default_header => "sha1=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha1"), secret, payload) } + wrong_algo_headers = { default_header => create_algorithm_prefixed_signature(payload, "sha1") } expect(valid_with(headers: wrong_algo_headers)).to be false end @@ -225,7 +239,7 @@ def valid_with(args = {}) it "returns false for signature with leading/trailing whitespace" do whitespace_headers = { default_header => " #{signature} " } - expect(valid_with(headers: whitespace_headers)). to be false + expect(valid_with(headers: whitespace_headers)).to be false end it "returns false for case-sensitive signature tampering" do @@ -264,7 +278,7 @@ def valid_with(args = {}) it "handles very long payloads" do long_payload = "a" * 100000 - long_signature = "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, long_payload) + long_signature = create_algorithm_prefixed_signature(long_payload) long_headers = { default_header => long_signature } expect(valid_with(payload: long_payload, headers: long_headers)).to be true end @@ -415,122 +429,79 @@ def valid_with(args = {}) end context "Unix timestamp validation" do - it "returns false for negative timestamp" do - negative_timestamp = "-1" - signing_payload = "v0:#{negative_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => negative_timestamp } - + def test_invalid_timestamp(invalid_timestamp, description) + signature = create_timestamped_signature(invalid_timestamp) + headers = { header => signature, timestamp_header => invalid_timestamp } expect(valid_with(headers:, config: base_config)).to be false end - it "returns false for zero timestamp" do - zero_timestamp = "0" - signing_payload = "v0:#{zero_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => zero_timestamp } + it "returns false for negative timestamp" do + test_invalid_timestamp("-1", "negative timestamp") + end - expect(valid_with(headers:, config: base_config)).to be false + it "returns false for zero timestamp" do + test_invalid_timestamp("0", "zero timestamp") end it "returns false for timestamp with decimal point" do - decimal_timestamp = "#{Time.now.to_i}.5" - signing_payload = "v0:#{decimal_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => decimal_timestamp } - - expect(valid_with(headers:, config: base_config)).to be false + test_invalid_timestamp("#{Time.now.to_i}.5", "decimal timestamp") end it "returns false for timestamp with leading zeros" do - padded_timestamp = "00#{Time.now.to_i}" - signing_payload = "v0:#{padded_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => padded_timestamp } - - expect(valid_with(headers:, config: base_config)).to be false + test_invalid_timestamp("00#{Time.now.to_i}", "padded timestamp") end it "returns false for timestamp with embedded null bytes" do - null_timestamp = "#{Time.now.to_i}\x00123" - signing_payload = "v0:#{null_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => null_timestamp } - - expect(valid_with(headers:, config: base_config)).to be false + test_invalid_timestamp("#{Time.now.to_i}\x00123", "null byte timestamp") end it "returns false for very large timestamp (year 2100+)" do - future_timestamp = "4000000000" # Year ~2096 - signing_payload = "v0:#{future_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => future_timestamp } - - expect(valid_with(headers:, config: base_config)).to be false + test_invalid_timestamp("4000000000", "future timestamp") end end context "ISO 8601 UTC timestamp validation" do - it "returns true for valid ISO 8601 UTC timestamp with Z suffix" do - iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") - signing_payload = "v0:#{iso_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) + def test_iso_timestamp(iso_timestamp, should_be_valid) + signature = create_timestamped_signature(iso_timestamp) headers = { header => signature, timestamp_header => iso_timestamp } + expect(valid_with(headers:, config: base_config)).to be should_be_valid + end - expect(valid_with(headers:, config: base_config)).to be true + it "returns true for valid ISO 8601 UTC timestamp with Z suffix" do + iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + test_iso_timestamp(iso_timestamp, true) end it "returns true for valid ISO 8601 UTC timestamp with +00:00 suffix" do iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S+00:00") - signing_payload = "v0:#{iso_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => iso_timestamp } - - expect(valid_with(headers:, config: base_config)).to be true + test_iso_timestamp(iso_timestamp, true) end it "returns false for ISO 8601 timestamp without UTC indicator" do iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S") # No Z or +00:00 - signing_payload = "v0:#{iso_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => iso_timestamp } - - expect(valid_with(headers:, config: base_config)).to be false + test_iso_timestamp(iso_timestamp, false) end it "returns false for ISO 8601 timestamp with non-UTC timezone" do iso_timestamp = Time.now.strftime("%Y-%m-%dT%H:%M:%S-05:00") # EST timezone - signing_payload = "v0:#{iso_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => iso_timestamp } - - expect(valid_with(headers:, config: base_config)).to be false + test_iso_timestamp(iso_timestamp, false) end it "returns false for expired ISO 8601 timestamp" do expired_time = Time.now.utc - 1000 # 1000 seconds ago iso_timestamp = expired_time.strftime("%Y-%m-%dT%H:%M:%SZ") - signing_payload = "v0:#{iso_timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => iso_timestamp } - - expect(valid_with(headers:, config: base_config)).to be false + test_iso_timestamp(iso_timestamp, false) end it "returns false for malformed ISO 8601 timestamp" do malformed_iso = "2025-13-32T25:61:61Z" # Invalid date/time values - signing_payload = "v0:#{malformed_iso}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) - headers = { header => signature, timestamp_header => malformed_iso } - - expect(valid_with(headers:, config: base_config)).to be false + test_iso_timestamp(malformed_iso, false) end end it "returns true when timestamp header name case differs due to normalization" do timestamp = Time.now.to_i.to_s - signing_payload = "v0:#{timestamp}:#{payload}" - signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) + signature = create_timestamped_signature(timestamp) # Use uppercase timestamp header name in the request headers headers = { header => signature, timestamp_header.upcase => timestamp } @@ -679,126 +650,4 @@ def valid_with(args = {}) expect(described_class.send(:valid_timestamp?, headers, config)).to be true end end - - describe ".parse_timestamp" do - it "parses valid Unix timestamp" do - unix_timestamp = Time.now.to_i.to_s - result = described_class.send(:parse_timestamp, unix_timestamp) - expect(result).to eq(unix_timestamp.to_i) - end - - it "parses valid ISO 8601 UTC timestamp with Z" do - iso_timestamp = "2025-06-12T10:30:00Z" - result = described_class.send(:parse_timestamp, iso_timestamp) - expect(result).to eq(Time.parse(iso_timestamp).to_i) - end - - it "parses valid ISO 8601 UTC timestamp with +00:00" do - iso_timestamp = "2025-06-12T10:30:00+00:00" - result = described_class.send(:parse_timestamp, iso_timestamp) - expect(result).to eq(Time.parse(iso_timestamp).to_i) - end - - it "returns nil for invalid timestamp format" do - expect(described_class.send(:parse_timestamp, "invalid")).to be_nil - end - - it "returns nil for timestamp with null bytes" do - expect(described_class.send(:parse_timestamp, "123\x00456")).to be_nil - end - - it "returns nil for timestamp with whitespace" do - expect(described_class.send(:parse_timestamp, " 1234567890 ")).to be_nil - end - end - - describe ".iso8601_timestamp?" do - it "returns true for valid ISO 8601 format with Z" do - expect(described_class.send(:iso8601_timestamp?, "2025-06-12T10:30:00Z")).to be true - end - - it "returns true for valid ISO 8601 format without Z" do - expect(described_class.send(:iso8601_timestamp?, "2025-06-12T10:30:00")).to be true - end - - it "returns false for Unix timestamp" do - expect(described_class.send(:iso8601_timestamp?, "1234567890")).to be false - end - - it "returns false for invalid format" do - expect(described_class.send(:iso8601_timestamp?, "not-a-timestamp")).to be false - end - end - - describe ".unix_timestamp?" do - it "returns true for valid Unix timestamp" do - expect(described_class.send(:unix_timestamp?, "1234567890")).to be true - end - - it "returns true for zero" do - expect(described_class.send(:unix_timestamp?, "0")).to be true - end - - it "returns false for timestamp with leading zeros" do - expect(described_class.send(:unix_timestamp?, "0123456789")).to be false - end - - it "returns false for negative timestamp" do - expect(described_class.send(:unix_timestamp?, "-123")).to be false - end - - it "returns false for ISO 8601 format" do - expect(described_class.send(:unix_timestamp?, "2025-06-12T10:30:00Z")).to be false - end - end - - describe ".parse_iso8601_timestamp" do - it "parses valid ISO 8601 UTC timestamp with Z" do - iso_timestamp = "2025-06-12T10:30:00Z" - result = described_class.send(:parse_iso8601_timestamp, iso_timestamp) - expect(result).to eq(Time.parse(iso_timestamp).to_i) - end - - it "parses valid ISO 8601 UTC timestamp with +00:00" do - iso_timestamp = "2025-06-12T10:30:00+00:00" - result = described_class.send(:parse_iso8601_timestamp, iso_timestamp) - expect(result).to eq(Time.parse(iso_timestamp).to_i) - end - - it "returns nil for non-UTC timezone" do - iso_timestamp = "2025-06-12T10:30:00-05:00" - result = described_class.send(:parse_iso8601_timestamp, iso_timestamp) - expect(result).to be_nil - end - - it "returns nil for timestamp without timezone" do - iso_timestamp = "2025-06-12T10:30:00" - result = described_class.send(:parse_iso8601_timestamp, iso_timestamp) - expect(result).to be_nil - end - - it "returns nil for invalid ISO 8601 format" do - iso_timestamp = "invalid-timestamp" - result = described_class.send(:parse_iso8601_timestamp, iso_timestamp) - expect(result).to be_nil - end - end - - describe ".parse_unix_timestamp" do - it "parses valid Unix timestamp" do - unix_timestamp = "1234567890" - result = described_class.send(:parse_unix_timestamp, unix_timestamp) - expect(result).to eq(1234567890) - end - - it "returns nil for zero timestamp" do - result = described_class.send(:parse_unix_timestamp, "0") - expect(result).to be_nil - end - - it "returns nil for negative timestamp" do - result = described_class.send(:parse_unix_timestamp, "-123") - expect(result).to be_nil - end - end end diff --git a/spec/unit/lib/hooks/plugins/auth/timestamp_validator_spec.rb b/spec/unit/lib/hooks/plugins/auth/timestamp_validator_spec.rb new file mode 100644 index 0000000..bb7e2b6 --- /dev/null +++ b/spec/unit/lib/hooks/plugins/auth/timestamp_validator_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require_relative "../../../../spec_helper" + +describe Hooks::Plugins::Auth::TimestampValidator do + let(:validator) { described_class.new } + let(:log) { instance_double(Logger).as_null_object } + + before(:each) do + Hooks::Log.instance = log + end + + describe "#valid?" do + context "with valid Unix timestamps" do + it "returns true for current timestamp" do + timestamp = Time.now.to_i.to_s + expect(validator.valid?(timestamp, 300)).to be true + end + + it "returns true for timestamp within tolerance" do + timestamp = (Time.now.to_i - 100).to_s + expect(validator.valid?(timestamp, 300)).to be true + end + + it "returns false for timestamp outside tolerance" do + timestamp = (Time.now.to_i - 500).to_s + expect(validator.valid?(timestamp, 300)).to be false + end + end + + context "with valid ISO 8601 timestamps" do + it "returns true for current ISO 8601 timestamp with Z" do + timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + expect(validator.valid?(timestamp, 300)).to be true + end + + it "returns true for current ISO 8601 timestamp with +00:00" do + timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S+00:00") + expect(validator.valid?(timestamp, 300)).to be true + end + end + + context "with invalid timestamps" do + it "returns false for nil timestamp" do + expect(validator.valid?(nil, 300)).to be false + end + + it "returns false for empty timestamp" do + expect(validator.valid?("", 300)).to be false + end + + it "returns false for whitespace-only timestamp" do + expect(validator.valid?(" ", 300)).to be false + end + + it "returns false for invalid format" do + expect(validator.valid?("invalid", 300)).to be false + end + end + end + + describe "#parse" do + context "Unix timestamps" do + it "parses valid Unix timestamp" do + unix_timestamp = Time.now.to_i.to_s + result = validator.parse(unix_timestamp) + expect(result).to eq(unix_timestamp.to_i) + end + + it "returns nil for zero timestamp" do + result = validator.parse("0") + expect(result).to be_nil + end + + it "returns nil for negative timestamp" do + result = validator.parse("-123") + expect(result).to be_nil + end + + it "returns nil for timestamp with leading zeros" do + result = validator.parse("0123456789") + expect(result).to be_nil + end + end + + context "ISO 8601 timestamps" do + it "parses valid ISO 8601 UTC timestamp with Z" do + iso_timestamp = "2025-06-12T10:30:00Z" + result = validator.parse(iso_timestamp) + expect(result).to eq(Time.parse(iso_timestamp).to_i) + end + + it "parses valid ISO 8601 UTC timestamp with +00:00" do + iso_timestamp = "2025-06-12T10:30:00+00:00" + result = validator.parse(iso_timestamp) + expect(result).to eq(Time.parse(iso_timestamp).to_i) + end + + it "returns nil for non-UTC timezone" do + iso_timestamp = "2025-06-12T10:30:00-05:00" + result = validator.parse(iso_timestamp) + expect(result).to be_nil + end + + it "returns nil for timestamp without timezone" do + iso_timestamp = "2025-06-12T10:30:00" + result = validator.parse(iso_timestamp) + expect(result).to be_nil + end + + it "returns nil for invalid ISO 8601 format" do + iso_timestamp = "invalid-timestamp" + result = validator.parse(iso_timestamp) + expect(result).to be_nil + end + end + + context "security validations" do + it "returns nil for timestamp with null bytes" do + result = validator.parse("123\x00456") + expect(result).to be_nil + end + + it "returns nil for timestamp with control characters" do + result = validator.parse("123\x01456") + expect(result).to be_nil + end + + it "returns nil for timestamp with whitespace" do + result = validator.parse(" 1234567890 ") + expect(result).to be_nil + end + end + end +end diff --git a/spec/unit/required_coverage_percentage.rb b/spec/unit/required_coverage_percentage.rb index d11ab8a..179cc0c 100644 --- a/spec/unit/required_coverage_percentage.rb +++ b/spec/unit/required_coverage_percentage.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -REQUIRED_COVERAGE_PERCENTAGE = 90 +REQUIRED_COVERAGE_PERCENTAGE = 99