Class: Hooks::Plugins::Auth::HMAC

Inherits:
Base
  • Object
show all
Defined in:
lib/hooks/plugins/auth/hmac.rb

Overview

Generic HMAC signature validator for webhooks

This validator supports multiple webhook providers with different signature formats. It provides flexible configuration options to handle various HMAC-based authentication schemes.

Examples:

Basic configuration with algorithm prefix

auth:
  type: HMAC
  secret_env_key: WEBHOOK_SECRET
  header: X-Hub-Signature-256
  algorithm: sha256
  format: "algorithm=signature"

Configuration with timestamp validation

auth:
  type: HMAC
  secret_env_key: WEBHOOK_SECRET
  header: X-Signature
  timestamp_header: X-Request-Timestamp
  timestamp_tolerance: 300  # 5 minutes
  algorithm: sha256
  format: "version=signature"
  version_prefix: "v0"
  payload_template: "{version}:{timestamp}:{body}"

Configuration for Tailscale-style structured headers

auth:
  type: HMAC
  secret_env_key: WEBHOOK_SECRET
  header: Tailscale-Webhook-Signature
  algorithm: sha256
  format: "signature_only"
  header_format: "structured"
  signature_key: "v1"
  timestamp_key: "t"
  payload_template: "{timestamp}.{body}"
  timestamp_tolerance: 300  # 5 minutes

Constant Summary collapse

MAX_SIGNATURE_LENGTH =

Security constants

ENV.fetch("HOOKS_MAX_SIGNATURE_LENGTH", 1024).to_i
DEFAULT_CONFIG =
Note:

These values provide sensible defaults for most webhook implementations

Default configuration values for HMAC validation

Returns:

  • (Hash<Symbol, String|Integer>)

    Default configuration settings

{
  algorithm: "sha256",
  format: "algorithm=signature",  # Format: algorithm=hash
  header: "X-Signature",         # Default header containing the signature
  timestamp_tolerance: 300,       # 5 minutes tolerance for timestamp validation
  version_prefix: "v0",          # Default version prefix for versioned signatures
  header_format: "simple"        # Header format: "simple" or "structured"
}.freeze
FORMATS =
Note:

Supports three common webhook signature formats:

  • algorithm=signature: “sha256=abc123…” (GitHub, GitLab style)

  • signature_only: “abc123…” (Shopify style)

  • version=signature: “v0=abc123…” (Slack style)

Mapping of signature format strings to internal format symbols

Returns:

  • (Hash<String, Symbol>)

    Format string to symbol mapping

{
  "algorithm=signature" => :algorithm_prefixed,  # "sha256=abc123..."
  "signature_only" => :hash_only,                # "abc123..."
  "version=signature" => :version_prefixed       # "v0=abc123..."
}.freeze

Constants inherited from Base

Base::MAX_HEADER_VALUE_LENGTH, Base::MAX_PAYLOAD_SIZE

Class Method Summary collapse

Methods inherited from Base

fetch_secret, find_header_value, timestamp_validator, valid_header_value?, valid_headers?, valid_payload_size?

Methods included from Core::ComponentAccess

#failbot, #log, #method_missing, #respond_to_missing?, #stats

Class Method Details

.valid?(payload:, headers:, config:) ⇒ Boolean

Note:

This method is designed to be safe and will never raise exceptions

Note:

Uses Rack::Utils.secure_compare to prevent timing attacks

Validate HMAC signature from webhook requests

Performs comprehensive HMAC signature validation with support for multiple signature formats and optional timestamp validation. Uses secure comparison to prevent timing attacks.

Examples:

Basic validation

HMAC.valid?(
  payload: request_body,
  headers: request.headers,
  config: { auth: { header: 'X-Signature' } }
)

Parameters:

  • payload (String)

    Raw request body to validate

  • headers (Hash<String, String>)

    HTTP headers from the request

  • config (Hash)

    Endpoint configuration containing validator settings

Options Hash (config:):

  • :auth (Hash)

    Validator-specific configuration

  • :header (String) — default: 'X-Signature'

    Header containing the signature

  • :timestamp_header (String)

    Header containing timestamp (optional)

  • :timestamp_tolerance (Integer) — default: 300

    Timestamp tolerance in seconds

  • :algorithm (String) — default: 'sha256'

    HMAC algorithm to use

  • :format (String) — default: 'algorithm=signature'

    Signature format

  • :version_prefix (String) — default: 'v0'

    Version prefix for versioned signatures

  • :payload_template (String)

    Template for payload construction

  • :header_format (String) — default: 'simple'

    Header format: ‘simple’ or ‘structured’

  • :signature_key (String) — default: 'v1'

    Key for signature in structured headers

  • :timestamp_key (String) — default: 't'

    Key for timestamp in structured headers

  • :structured_header_separator (String) — default: ','

    Separator for structured headers

  • :key_value_separator (String) — default: '='

    Separator for key-value pairs in structured headers

Returns:

  • (Boolean)

    true if signature is valid, false otherwise

Raises:

  • (StandardError)

    Rescued internally, returns false on any error



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/hooks/plugins/auth/hmac.rb', line 109

def self.valid?(payload:, headers:, config:)
  # fetch the required secret from environment variable as specified in the config
  secret = fetch_secret(config)

  validator_config = build_config(config)

  # Security: Check raw headers and payload BEFORE processing
  return false unless valid_headers?(headers)
  return false unless valid_payload_size?(payload)

  signature_header = validator_config[:header]

  # Find the signature header with case-insensitive matching
  raw_signature = find_header_value(headers, signature_header)

  if raw_signature.nil? || raw_signature.empty?
    log.warn("Auth::HMAC validation failed: Missing or empty signature header '#{signature_header}'")
    return false
  end

  # Validate signature format using shared validation but with HMAC-specific length limit
  return false unless validate_signature_format?(raw_signature)

  # Now we can safely normalize headers for the rest of the validation
  normalized_headers = normalize_headers(headers)

  # Handle structured headers (e.g., Tailscale format: "t=123,v1=abc")
  if validator_config[:header_format] == "structured"
    parsed_signature_data = parse_structured_header(raw_signature, validator_config)
    if parsed_signature_data.nil?
      log.warn("Auth::HMAC validation failed: Could not parse structured signature header")
      return false
    end

    provided_signature = parsed_signature_data[:signature]

    # For structured headers, timestamp comes from the signature header itself
    if parsed_signature_data[:timestamp]
      normalized_headers = normalized_headers.merge(
        "extracted_timestamp" => parsed_signature_data[:timestamp]
      )
      # Override timestamp_header to use our extracted timestamp
      validator_config = validator_config.merge(timestamp_header: "extracted_timestamp")
    end
  else
    provided_signature = normalized_headers[signature_header.downcase]
  end

  # Validate timestamp if required (for services that include timestamp validation)
  # It should be noted that not all HMAC implementations require timestamp validation,
  # so this is optional based on configuration.
  if validator_config[:timestamp_header]
    unless valid_timestamp?(normalized_headers, validator_config)
      log.warn("Auth::HMAC validation failed: Invalid timestamp")
      return false
    end
  end

  # Compute expected signature
  computed_signature = compute_signature(
    payload:,
    headers: normalized_headers,
    secret:,
    config: validator_config
  )

  # Use secure comparison to prevent timing attacks
  result = Rack::Utils.secure_compare(computed_signature, provided_signature)
  if result
    log.debug("Auth::HMAC validation successful for header '#{signature_header}'")
  else
    log.warn("Auth::HMAC validation failed: Signature mismatch")
  end
  result
rescue StandardError => e
  log.error("Auth::HMAC validation failed: #{e.message}")
  false
end