diff --git a/docs/auth_plugins.md b/docs/auth_plugins.md index c817413..9a5817d 100644 --- a/docs/auth_plugins.md +++ b/docs/auth_plugins.md @@ -1,6 +1,267 @@ # Auth Plugins -This document provides an example of how to implement a custom authentication plugin for a hypothetical system. The plugin checks for a specific authorization header and validates it against a secret stored in an environment variable. +This document provides information on how to use authentication plugins for webhook validation, including built-in plugins and how to implement custom authentication plugins. + +In your global configuration file (e.g. `hooks.yml`) you would likely set `auth_plugin_dir` to something like `./plugins/auth`. + +Here is an example snippet of how you might configure the global settings in `hooks.yml`: + +```yaml +# hooks.yml +auth_plugin_dir: ./plugins/auth # Directory where custom auth plugins are stored +``` + +## Built-in Auth Plugins + +The system comes with several built-in authentication plugins that cover common webhook authentication patterns. + +### HMAC Authentication + +The HMAC plugin provides secure signature-based authentication using HMAC (Hash-based Message Authentication Code). This is the most secure authentication method and is used by major webhook providers like GitHub, GitLab, and Shopify. + +It works well because it HMACs provide the ability to verify both the integrity and authenticity of the request, ensuring that the payload has not been tampered with and that it comes from a trusted source. + +**Type:** `hmac` + +#### HMAC Configuration Options + +##### `secret_env_key` (required) + +The name of the environment variable containing the shared secret used for HMAC signature generation. + +**Example:** `GITHUB_WEBHOOK_SECRET` + +##### `header` + +The HTTP header containing the HMAC signature. + +**Default:** `X-Signature` +**Example:** `X-Hub-Signature-256` + +##### `algorithm` + +The hashing algorithm to use for HMAC signature generation. + +**Default:** `sha256` +**Valid values:** `sha1`, `sha256`, `sha384`, `sha512` +**Example:** `sha256` + +##### `format` + +The format of the signature in the header. This determines how the signature is structured. + +**Default:** `algorithm=signature` + +**Valid values:** + +- `algorithm=signature` - Produces "sha256=abc123..." (GitHub, GitLab style) +- `signature_only` - Produces "abc123..." (Shopify style) +- `version=signature` - Produces "v0=abc123..." (Slack style) + +##### `version_prefix` + +The version prefix used when `format` is set to `version=signature`. + +**Default:** `v0` +**Example:** `v1` + +##### `timestamp_header` (optional) + +The HTTP header containing the request timestamp for timestamp validation. When specified, requests must include a valid timestamp within the tolerance window. + +**Example:** `X-Request-Timestamp` + +##### `timestamp_tolerance` + +The maximum age (in seconds) allowed for timestamped requests. Only used when `timestamp_header` is specified. + +**Default:** `300` (5 minutes) +**Example:** `600` + +##### `payload_template` (optional) + +A template for constructing the payload used in signature generation when timestamp validation is enabled. Use placeholders like `{version}`, `{timestamp}`, and `{body}`. + +**Example:** `{version}:{timestamp}:{body}` + +#### HMAC Examples + +**Basic GitHub-style HMAC:** + +```yaml +auth: + type: hmac + secret_env_key: GITHUB_WEBHOOK_SECRET + header: X-Hub-Signature-256 + algorithm: sha256 + format: "algorithm=signature" # produces "sha256=abc123..." +``` + +**Shopify-style HMAC (signature only):** + +```yaml +auth: + type: hmac + secret_env_key: SHOPIFY_WEBHOOK_SECRET + header: X-Shopify-Hmac-Sha256 + algorithm: sha256 + format: "signature_only" # produces "abc123..." +``` + +**Slack-style HMAC with timestamp validation:** + +This is the most secure authentication method as it includes timestamp validation directly in the HMAC signature, preventing replay attacks even if an attacker intercepts the request. + +```yaml +auth: + type: hmac + secret_env_key: SLACK_WEBHOOK_SECRET + header: X-Slack-Signature + timestamp_header: X-Slack-Request-Timestamp + timestamp_tolerance: 300 # 5 minutes + algorithm: sha256 + format: "version=signature" # produces "v0=abc123..." + version_prefix: "v0" + payload_template: "{version}:{timestamp}:{body}" +``` + +**Security Benefits:** + +The timestamp validation provides several critical security advantages: + +1. **Replay Attack Prevention**: Even if an attacker captures a valid request, they cannot replay it after the timestamp tolerance window expires +2. **HMAC Integrity**: The timestamp is included in the HMAC calculation itself (via `payload_template`), so tampering with either the timestamp or payload will invalidate the signature +3. **Time-bound Validity**: Requests are only valid within a specific time window, reducing the attack surface + +**How it works:** + +1. The client includes the current Unix timestamp in the `X-Slack-Request-Timestamp` header +2. The HMAC is calculated over a constructed payload using the template: `{version}:{timestamp}:{body}` +3. For example, if the version is "v0", timestamp is "1609459200", and body is `{"event":"push"}`, the signed payload becomes: `v0:1609459200:{"event":"push"}` +4. The resulting signature format is: `v0=computed_hmac_hash` + +**Example curl request:** + +```bash +#!/bin/bash + +# Configuration +WEBHOOK_URL="https://your-hooks-server.com/webhooks/slack" +SECRET="your_slack_webhook_secret" +TIMESTAMP=$(date +%s) +PAYLOAD='{"event":"push","repository":"my-repo"}' + +# Construct the signing payload +VERSION="v0" +SIGNING_PAYLOAD="${VERSION}:${TIMESTAMP}:${PAYLOAD}" + +# Generate HMAC signature +SIGNATURE=$(echo -n "$SIGNING_PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) +FORMATTED_SIGNATURE="${VERSION}=${SIGNATURE}" + +# Send the request +curl -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -H "X-Slack-Signature: $FORMATTED_SIGNATURE" \ + -H "X-Slack-Request-Timestamp: $TIMESTAMP" \ + -d "$PAYLOAD" +``` + +**Important Security Notes:** + +- The timestamp must be included in the HMAC calculation (not just validated separately) to prevent signature reuse with different timestamps +- Use a reasonable `timestamp_tolerance` (5-10 minutes) to account for clock skew while minimizing replay window +- Always use HTTPS to prevent man-in-the-middle attacks +- Store webhook secrets securely + +**General HMAC with timestamp validation (no version):** + +For services that require timestamp validation but don't use version prefixes, you can use a simpler template format with the standard `algorithm=signature` format. + +```yaml +auth: + type: hmac + secret_env_key: WEBHOOK_SECRET + header: X-Signature + timestamp_header: X-Timestamp + timestamp_tolerance: 600 # 10 minutes + algorithm: sha256 + format: "algorithm=signature" # produces "sha256=abc123..." + payload_template: "{timestamp}:{body}" +``` + +**Example curl request:** + +```bash +#!/bin/bash + +# Configuration +WEBHOOK_URL="https://your-hooks-server.com/webhooks/generic" +SECRET="your_webhook_secret" +TIMESTAMP=$(date +%s) +PAYLOAD='{"event":"deployment","status":"success"}' + +# Construct the signing payload (timestamp:body format) +SIGNING_PAYLOAD="${TIMESTAMP}:${PAYLOAD}" + +# Generate HMAC signature +SIGNATURE=$(echo -n "$SIGNING_PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) +FORMATTED_SIGNATURE="sha256=${SIGNATURE}" + +# Send the request +curl -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -H "X-Signature: $FORMATTED_SIGNATURE" \ + -H "X-Timestamp: $TIMESTAMP" \ + -d "$PAYLOAD" +``` + +This approach provides strong security through timestamp validation while using a simpler format than the Slack-style implementation. The signing payload becomes `1609459200:{"event":"deployment","status":"success"}` and the resulting signature format is `sha256=computed_hmac_hash`. + +### Shared Secret Authentication + +The SharedSecret plugin provides simple secret-based authentication by comparing a secret value sent in an HTTP header. While simpler than HMAC, it provides less security since the secret is transmitted directly in the request header. + +**Type:** `shared_secret` + +#### Shared Secret Configuration Options + +##### `secret_env_key` (required for shared secrets) + +The name of the environment variable containing the shared secret for validation. + +**Example:** `WEBHOOK_SECRET` + +##### `header` (contains the shared secret) + +The HTTP header where the shared secret is transmitted. + +**Default:** `Authorization` +**Example:** `X-API-Key` + +#### Shared Secret Examples + +**Basic shared secret with Authorization header:** + +```yaml +auth: + type: shared_secret + secret_env_key: WEBHOOK_SECRET + header: Authorization +``` + +**Custom header shared secret:** + +```yaml +auth: + type: shared_secret + secret_env_key: API_KEY_SECRET + header: X-API-Key +``` + +## Custom Auth Plugins + +This section provides an example of how to implement a custom authentication plugin for a hypothetical system. The plugin checks for a specific authorization header and validates it against a secret stored in an environment variable. In your global configuration file (e.g. `hooks.yml`) you would likely set `auth_plugin_dir` to something like `./plugins/auth`. diff --git a/docs/configuration.md b/docs/configuration.md index eb21b96..0fefa74 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -147,6 +147,8 @@ auth: format: "algorithm=signature" # produces "sha256=abc123..." ``` +See the [Auth Plugins documentation](./auth_plugins.md) for more details on how to implement custom authentication plugins. You will also find configurations for built-in authentication plugins in that document as well. + ### `opts` Additional options for the endpoint. This section can include any custom options that the handler may require. The options are specific to the handler and can vary based on its implementation. You can put anything your heart desires here. diff --git a/lib/hooks/app/auth/auth.rb b/lib/hooks/app/auth/auth.rb index fc53d2f..03f9de5 100644 --- a/lib/hooks/app/auth/auth.rb +++ b/lib/hooks/app/auth/auth.rb @@ -37,12 +37,8 @@ def validate_auth!(payload, headers, endpoint_config, global_config = {}) end log.debug("validating auth for request with auth_class: #{auth_class.name}") - - unless auth_class.valid?( - payload:, - headers:, - config: endpoint_config - ) + unless auth_class.valid?(payload:, headers:, config: endpoint_config) + log.warn("authentication failed for request with auth_class: #{auth_class.name}") error!("authentication failed", 401) end end diff --git a/lib/hooks/plugins/auth/hmac.rb b/lib/hooks/plugins/auth/hmac.rb index 93d31db..8394752 100644 --- a/lib/hooks/plugins/auth/hmac.rb +++ b/lib/hooks/plugins/auth/hmac.rb @@ -117,7 +117,10 @@ def self.valid?(payload:, headers:, config:) # Validate timestamp if required (for services that include timestamp validation) if validator_config[:timestamp_header] - return false unless valid_timestamp?(normalized_headers, validator_config) + unless valid_timestamp?(normalized_headers, validator_config) + log.warn("Auth::HMAC validation failed: Invalid timestamp") + return false + end end # Compute expected signature @@ -180,34 +183,99 @@ def self.normalize_headers(headers) # # Checks if the provided timestamp is within the configured tolerance # of the current time. This prevents replay attacks using old requests. + # Supports both ISO 8601 UTC timestamps and Unix timestamps. # # @param headers [Hash] Normalized HTTP headers # @param config [Hash] Validator configuration # @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] + tolerance = config[:timestamp_tolerance] || 300 return false unless timestamp_header - timestamp_header = timestamp_header.downcase - timestamp_value = headers[timestamp_header] - + timestamp_value = headers[timestamp_header.downcase] return false unless timestamp_value + return false if timestamp_value.strip.empty? - # Security: Strict timestamp validation - must be only digits with no leading zeros - return false unless timestamp_value.match?(/\A[1-9]\d*\z/) || timestamp_value == "0" + parsed_timestamp = parse_timestamp(timestamp_value.strip) + return false unless parsed_timestamp - timestamp = timestamp_value.to_i + # parsed_timestamp is a Time object + now = Time.now.utc + (now - parsed_timestamp).abs <= tolerance + end - # Ensure timestamp is a positive integer (reject zero and negative) - return false unless timestamp > 0 + # Parse timestamp value supporting both ISO 8601 UTC and Unix formats + # + # Attempts to parse the timestamp in the following order: + # 1. ISO 8601 UTC format (e.g., "2025-06-12T10:30:00Z") + # 2. Unix timestamp (e.g., "1609459200") + # + # @param timestamp_value [String] The timestamp string to parse + # @return [Time, nil] Time object if parsing succeeds, nil otherwise + # @note Security: Strict validation prevents various injection attacks + # @api private + def self.parse_timestamp(timestamp_value) + # Try ISO 8601 first, then Unix + ts = parse_iso8601_timestamp(timestamp_value) + return ts if ts + ts = parse_unix_timestamp(timestamp_value) + return ts if ts + nil + end - current_time = Time.now.to_i - tolerance = config[:timestamp_tolerance] + # Check if timestamp string looks like ISO 8601 UTC format + # + # @param timestamp_value [String] The timestamp string to check + # @return [Boolean] true if it appears to be ISO 8601 format + # @api private + def self.iso8601_timestamp?(timestamp_value) + # Accepts Z, +00:00, or +0000, and T or space as separator + !!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)\z/) + end - (current_time - timestamp).abs <= tolerance + # Parse ISO 8601 UTC timestamp string + # + # @param timestamp_value [String] ISO 8601 timestamp string + # @return [Time, nil] Time object if parsing succeeds, nil otherwise + # @note Only accepts UTC timestamps (ending with 'Z' or explicit UTC) + # @api private + def self.parse_iso8601_timestamp(timestamp_value) + # Normalize 'YYYY-MM-DD HH:MM:SS(.fraction)? +0000' to 'YYYY-MM-DDTHH:MM:SS(.fraction)?+00:00' + 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 + return nil unless iso8601_timestamp?(timestamp_value) + t = Time.iso8601(timestamp_value) rescue nil + return nil unless t + # Only accept UTC (Z, +00:00, or +0000) + return t if t.utc? || t.utc_offset == 0 + nil + end + + # Parse Unix timestamp string + # + # @param timestamp_value [String] Unix timestamp string + # @return [Time, nil] Time object if parsing succeeds, nil otherwise + # @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 + Time.at(ts).utc + 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 + # @api private + def self.unix_timestamp?(timestamp_value) + !!(timestamp_value =~ /\A\d+\z/) || timestamp_value == "0" end # Compute HMAC signature based on configuration requirements diff --git a/spec/acceptance/acceptance_tests.rb b/spec/acceptance/acceptance_tests.rb index e801565..1ba48b5 100644 --- a/spec/acceptance/acceptance_tests.rb +++ b/spec/acceptance/acceptance_tests.rb @@ -117,12 +117,149 @@ end describe "slack" do - it "receives a POST request but contains an invalid HMAC signature" do + it "successfully processes a valid POST request with HMAC signature and timestamp" do payload = { text: "Hello, Slack!" } - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, payload.to_json) - headers = { "Content-Type" => "application/json", "Signature-256" => "sha256=#{digest}" } + timestamp = Time.now.to_i.to_s + body = payload.to_json + signing_payload = "v0:#{timestamp}:#{body}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) + headers = { + "Content-Type" => "application/json", + "Signature-256" => "v0=#{digest}", + "X-Timestamp" => timestamp + } + response = http.post("/webhooks/slack", body, headers) + expect(response).to be_a(Net::HTTPSuccess) + body = JSON.parse(response.body) + expect(body["status"]).to eq("success") + end + + it "rejects request with expired timestamp" do + payload = { text: "Hello, Slack!" } + # Use timestamp that's 10 minutes old (beyond the 5 minute tolerance) + expired_timestamp = (Time.now.to_i - 600).to_s + + signing_payload = "v0:#{expired_timestamp}:#{payload.to_json}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) + + headers = { + "Content-Type" => "application/json", + "Signature-256" => "v0=#{digest}", + "X-Timestamp" => expired_timestamp + } + response = http.post("/webhooks/slack", payload.to_json, headers) + expect(response).to be_a(Net::HTTPUnauthorized) + expect(response.body).to include("authentication failed") + end + + it "rejects request with missing timestamp header" do + payload = { text: "Hello, Slack!" } + timestamp = Time.now.to_i.to_s + # Create signature with timestamp but don't include timestamp header + signing_payload = "v0:#{timestamp}:#{payload.to_json}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) + + headers = { + "Content-Type" => "application/json", + "Signature-256" => "v0=#{digest}" + # Missing X-Timestamp header + } + + response = http.post("/webhooks/slack", payload.to_json, headers) + expect(response).to be_a(Net::HTTPUnauthorized) + expect(response.body).to include("authentication failed") + end + + it "rejects request with invalid timestamp format" do + payload = { text: "Hello, Slack!" } + invalid_timestamp = "not-a-timestamp" + + signing_payload = "v0:#{invalid_timestamp}:#{payload.to_json}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) + + headers = { + "Content-Type" => "application/json", + "Signature-256" => "v0=#{digest}", + "X-Timestamp" => invalid_timestamp + } + + response = http.post("/webhooks/slack", payload.to_json, headers) + expect(response).to be_a(Net::HTTPUnauthorized) + expect(response.body).to include("authentication failed") + end + + it "successfully processes request with ISO 8601 UTC timestamp" do + payload = { text: "Hello, Slack!" } + iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + body = payload.to_json + signing_payload = "v0:#{iso_timestamp}:#{body}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) + headers = { + "Content-Type" => "application/json", + "Signature-256" => "v0=#{digest}", + "X-Timestamp" => iso_timestamp + } + response = http.post("/webhooks/slack", body, headers) + expect(response).to be_a(Net::HTTPSuccess) + body = JSON.parse(response.body) + expect(body["status"]).to eq("success") + end + + it "successfully processes request with ISO 8601 UTC timestamp using +00:00 format" do + payload = { text: "Hello, Slack!" } + iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S+00:00") + body = payload.to_json + signing_payload = "v0:#{iso_timestamp}:#{body}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) + headers = { + "Content-Type" => "application/json", + "Signature-256" => "v0=#{digest}", + "X-Timestamp" => iso_timestamp + } + response = http.post("/webhooks/slack", body, headers) + expect(response).to be_a(Net::HTTPSuccess) + body = JSON.parse(response.body) + expect(body["status"]).to eq("success") + end + + it "rejects request with non-UTC ISO 8601 timestamp" do + payload = { text: "Hello, Slack!" } + # Use EST timezone (non-UTC) + non_utc_timestamp = Time.now.strftime("%Y-%m-%dT%H:%M:%S-05:00") + + signing_payload = "v0:#{non_utc_timestamp}:#{payload.to_json}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) + + headers = { + "Content-Type" => "application/json", + "Signature-256" => "v0=#{digest}", + "X-Timestamp" => non_utc_timestamp + } + + response = http.post("/webhooks/slack", payload.to_json, headers) + expect(response).to be_a(Net::HTTPUnauthorized) + expect(response.body).to include("authentication failed") + end + + it "rejects request with timestamp manipulation attack" do + payload = { text: "Hello, Slack!" } + original_timestamp = Time.now.to_i.to_s + manipulated_timestamp = (Time.now.to_i + 100).to_s # Future timestamp + + # Create signature with original timestamp + signing_payload = "v0:#{original_timestamp}:#{payload.to_json}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) + + # But send manipulated timestamp in header + headers = { + "Content-Type" => "application/json", + "Signature-256" => "v0=#{digest}", + "X-Timestamp" => manipulated_timestamp + } + + response = http.post("/webhooks/slack", payload.to_json, headers) expect(response).to be_a(Net::HTTPUnauthorized) expect(response.body).to include("authentication failed") end diff --git a/spec/acceptance/docker-compose.yml b/spec/acceptance/docker-compose.yml index 7f94957..fc2a766 100644 --- a/spec/acceptance/docker-compose.yml +++ b/spec/acceptance/docker-compose.yml @@ -9,7 +9,7 @@ services: environment: LOG_LEVEL: DEBUG GITHUB_WEBHOOK_SECRET: "octoawesome-secret" - ALT_WEBHOOK_SECRET: "octoawesome-too-secret" + ALT_WEBHOOK_SECRET: "octoawesome-2-secret" SHARED_SECRET: "octoawesome-shared-secret" DEFAULT_RETRY_SLEEP: 0 RETRY_LOG_RETRIES: "false" diff --git a/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb b/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb index fa12174..e5b17e9 100644 --- a/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb +++ b/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb @@ -2,6 +2,8 @@ require_relative "../../../../spec_helper" +# calls into spec_helper which sets up mocks around Time which is important to be aware of for HMAC tests + describe Hooks::Plugins::Auth::HMAC do let(:log) { instance_double(Logger).as_null_object } let(:secret) { "supersecret" } @@ -412,58 +414,117 @@ def valid_with(args = {}) } end - 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 } + 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 } + + 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 } - expect(valid_with(headers:, config: base_config)).to be false - end + 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 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 - end + expect(valid_with(headers:, config: base_config)).to be false + 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 } + 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 - end + expect(valid_with(headers:, config: base_config)).to be false + 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 } + 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 - end + expect(valid_with(headers:, config: base_config)).to be false + 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 } + 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 + expect(valid_with(headers:, config: base_config)).to be false + end 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 } + 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) + headers = { header => signature, timestamp_header => iso_timestamp } + + expect(valid_with(headers:, config: base_config)).to be 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 + 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 + expect(valid_with(headers:, config: base_config)).to be 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 + 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 + 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 + end end it "returns true when timestamp header name case differs due to normalization" do @@ -605,5 +666,139 @@ def valid_with(args = {}) expect(described_class.send(:valid_timestamp?, headers, config)).to be false end + + it "returns true for valid Unix timestamp" do + headers = { "x-timestamp" => Time.now.to_i.to_s } + + expect(described_class.send(:valid_timestamp?, headers, config)).to be true + end + + it "returns true for valid ISO 8601 UTC timestamp" do + headers = { "x-timestamp" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") } + + 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