diff --git a/.rubocop.yml b/.rubocop.yml index 19f93ae..2bfa662 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,6 +15,7 @@ AllCops: GitHub/InsecureHashAlgorithm: Exclude: - "spec/unit/lib/hooks/plugins/auth/hmac_spec.rb" + - "spec/acceptance/acceptance_tests.rb" GitHub/AvoidObjectSendWithDynamicMethod: Exclude: diff --git a/Gemfile.lock b/Gemfile.lock index 3bbeb73..5c1ce7d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - hooks-ruby (0.2.0) + hooks-ruby (0.2.1) dry-schema (~> 1.14, >= 1.14.1) grape (~> 2.3) puma (~> 6.6) diff --git a/docs/auth_plugins.md b/docs/auth_plugins.md index 9a5817d..b06e8b3 100644 --- a/docs/auth_plugins.md +++ b/docs/auth_plugins.md @@ -82,7 +82,49 @@ The maximum age (in seconds) allowed for timestamped requests. Only used when `t 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}` +**Example:** `{version}:{timestamp}:{body}` (Slack-style), `{timestamp}.{body}` (Tailscale-style) + +##### `header_format` (optional) + +The format of the signature header content. Use "structured" for headers containing comma-separated key-value pairs. + +**Default:** `simple` +**Valid values:** + +- `simple` - Standard single-value headers like "sha256=abc123..." or "abc123..." +- `structured` - Comma-separated key-value pairs like "t=1663781880,v1=abc123..." + +##### `signature_key` (optional) + +When `header_format` is "structured", this specifies the key name for the signature value in the header. + +**Default:** `v1` +**Example:** `signature` + +##### `timestamp_key` (optional) + +When `header_format` is "structured", this specifies the key name for the timestamp value in the header. + +**Default:** `t` +**Example:** `timestamp` + +##### `structured_header_separator` (optional) + +When `header_format` is "structured", this specifies the separator used between the unique keys in the structured header. + +For example, if the header is `t=1663781880,v1=abc123`, the `structured_header_separator` would be `,`. It defaults to `,` but can be changed if needed. + +**Example:** `.` +**Default:** `,` + +##### `key_value_separator` (optional) + +When `header_format` is "structured", this specifies the separator used between the key and value in the structured header. + +For example, in the header `t=1663781880,v1=abc123`, the `key_value_separator` would be `=`. It defaults to `=` but can be changed if needed. + +**Example:** `:` +**Default:** `=` #### HMAC Examples @@ -218,6 +260,59 @@ curl -X POST "$WEBHOOK_URL" \ 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`. +**Tailscale-style HMAC with structured headers:** + +This configuration supports providers like Tailscale that include both timestamp and signature in a single header using comma-separated key-value pairs. + +```yaml +auth: + type: hmac + secret_env_key: TAILSCALE_WEBHOOK_SECRET + header: Tailscale-Webhook-Signature + algorithm: sha256 + format: "signature_only" # produces "abc123..." (no prefix) + header_format: "structured" # enables parsing of "t=123,v1=abc" format + signature_key: "v1" # key for signature in structured header + timestamp_key: "t" # key for timestamp in structured header + payload_template: "{timestamp}.{body}" # dot-separated format + timestamp_tolerance: 300 # 5 minutes +``` + +**How it works:** + +1. The signature header contains both timestamp and signature: `Tailscale-Webhook-Signature: t=1663781880,v1=0123456789abcdef` +2. The timestamp and signature are extracted from the structured header +3. The HMAC is calculated over the payload using the template: `{timestamp}.{body}` +4. For example, if timestamp is "1663781880" and body is `{"event":"test"}`, the signed payload becomes: `1663781880.{"event":"test"}` +5. The signature is validated as a raw hex string (no prefix) + +**Example curl request:** + +```bash +#!/bin/bash + +# Configuration +WEBHOOK_URL="https://your-hooks-server.com/webhooks/tailscale" +SECRET="your_tailscale_webhook_secret" +TIMESTAMP=$(date +%s) +PAYLOAD='{"nodeId":"n123","event":"nodeCreated"}' + +# 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) +STRUCTURED_SIGNATURE="t=${TIMESTAMP},v1=${SIGNATURE}" + +# Send the request +curl -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -H "Tailscale-Webhook-Signature: $STRUCTURED_SIGNATURE" \ + -d "$PAYLOAD" +``` + +This format is particularly useful for providers that want to include multiple pieces of metadata in a single header while maintaining strong security through timestamp validation. + ### 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. diff --git a/docs/design.md b/docs/design.md index a23e4eb..8c4fcbe 100644 --- a/docs/design.md +++ b/docs/design.md @@ -100,7 +100,7 @@ Core configuration options can be provided via environment variables: export HOOKS_CONFIG=./config/config.yaml # Runtime settings (override config file) -export HOOKS_REQUEST_LIMIT=1048576 +export HOOKS_REQUEST_LIMIT=1048576 # 1 MB export HOOKS_REQUEST_TIMEOUT=15 export HOOKS_GRACEFUL_SHUTDOWN_TIMEOUT=30 export HOOKS_ROOT_PATH="/webhooks" diff --git a/lib/hooks/core/config_validator.rb b/lib/hooks/core/config_validator.rb index af47972..d2bd879 100644 --- a/lib/hooks/core/config_validator.rb +++ b/lib/hooks/core/config_validator.rb @@ -45,6 +45,11 @@ class ValidationError < StandardError; end optional(:format).filled(:string) optional(:version_prefix).filled(:string) optional(:payload_template).filled(:string) + optional(:header_format).filled(:string) + optional(:signature_key).filled(:string) + optional(:timestamp_key).filled(:string) + optional(:structured_header_separator).filled(:string) + optional(:key_value_separator).filled(:string) end optional(:opts).hash diff --git a/lib/hooks/plugins/auth/base.rb b/lib/hooks/plugins/auth/base.rb index 86324b9..1b0f1fc 100644 --- a/lib/hooks/plugins/auth/base.rb +++ b/lib/hooks/plugins/auth/base.rb @@ -14,6 +14,10 @@ module Auth class Base extend Hooks::Core::ComponentAccess + # Security constants shared across auth validators + MAX_HEADER_VALUE_LENGTH = ENV.fetch("HOOKS_MAX_HEADER_VALUE_LENGTH", 1024).to_i # Prevent DoS attacks via large header values + MAX_PAYLOAD_SIZE = ENV.fetch("HOOKS_MAX_PAYLOAD_SIZE", 10 * 1024 * 1024).to_i # 10MB limit for payload validation + # Validate request # # @param payload [String] Raw request body @@ -67,6 +71,61 @@ def self.find_header_value(headers, header_name) end nil end + + # Validate headers object for security issues + # + # @param headers [Object] Headers to validate + # @return [Boolean] true if headers are valid + def self.valid_headers?(headers) + unless headers.respond_to?(:each) + log.warn("Auth validation failed: Invalid headers object") + return false + end + true + end + + # Validate payload size for security issues + # + # @param payload [String] Payload to validate + # @return [Boolean] true if payload is valid + def self.valid_payload_size?(payload) + return true if payload.nil? + + if payload.bytesize > MAX_PAYLOAD_SIZE + log.warn("Auth validation failed: Payload size exceeds maximum limit of #{MAX_PAYLOAD_SIZE} bytes") + return false + end + true + end + + # Validate header value for security issues + # + # @param header_value [String] Header value to validate + # @param header_name [String] Header name for logging + # @return [Boolean] true if header value is valid + def self.valid_header_value?(header_value, header_name) + return false if header_value.nil? || header_value.empty? + + # Check length to prevent DoS + if header_value.length > MAX_HEADER_VALUE_LENGTH + log.warn("Auth validation failed: #{header_name} exceeds maximum length") + return false + end + + # Check for whitespace tampering + if header_value != header_value.strip + log.warn("Auth validation failed: #{header_name} contains leading/trailing whitespace") + return false + end + + # Check for control characters + if header_value.match?(/[\u0000-\u001f\u007f-\u009f]/) + log.warn("Auth validation failed: #{header_name} contains control characters") + return false + end + + true + end end end end diff --git a/lib/hooks/plugins/auth/hmac.rb b/lib/hooks/plugins/auth/hmac.rb index be937ae..927c5fc 100644 --- a/lib/hooks/plugins/auth/hmac.rb +++ b/lib/hooks/plugins/auth/hmac.rb @@ -32,7 +32,23 @@ module Auth # format: "version=signature" # version_prefix: "v0" # payload_template: "{version}:{timestamp}:{body}" + # + # @example 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 class HMAC < Base + # Security constants + MAX_SIGNATURE_LENGTH = ENV.fetch("HOOKS_MAX_SIGNATURE_LENGTH", 1024).to_i # Prevent DoS attacks via large signatures + # Default configuration values for HMAC validation # # @return [Hash] Default configuration settings @@ -42,7 +58,8 @@ class HMAC < Base 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 + version_prefix: "v0", # Default version prefix for versioned signatures + header_format: "simple" # Header format: "simple" or "structured" }.freeze # Mapping of signature format strings to internal format symbols @@ -75,6 +92,11 @@ class HMAC < Base # @option config [String] :format ('algorithm=signature') Signature format # @option config [String] :version_prefix ('v0') Version prefix for versioned signatures # @option config [String] :payload_template Template for payload construction + # @option config [String] :header_format ('simple') Header format: 'simple' or 'structured' + # @option config [String] :signature_key ('v1') Key for signature in structured headers + # @option config [String] :timestamp_key ('t') Key for timestamp in structured headers + # @option config [String] :structured_header_separator (',') Separator for structured headers + # @option config [String] :key_value_separator ('=') Separator for key-value pairs in structured headers # @return [Boolean] true if signature is valid, false otherwise # @raise [StandardError] Rescued internally, returns false on any error # @note This method is designed to be safe and will never raise exceptions @@ -91,11 +113,9 @@ def self.valid?(payload:, headers:, config:) validator_config = build_config(config) - # Security: Check raw headers BEFORE normalization to detect tampering - unless headers.respond_to?(:each) - log.warn("Auth::HMAC validation failed: Invalid headers object") - return false - end + # 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] @@ -107,23 +127,37 @@ def self.valid?(payload:, headers:, config:) return false end - # Security: Reject signatures with leading/trailing whitespace - if raw_signature != raw_signature.strip - log.warn("Auth::HMAC validation failed: Signature contains leading/trailing whitespace") - return false - end - - # Security: Reject signatures containing null bytes or other control characters - if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/) - log.warn("Auth::HMAC validation failed: Signature contains control characters") - 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) - provided_signature = normalized_headers[signature_header.downcase] + + # 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") @@ -154,6 +188,22 @@ def self.valid?(payload:, headers:, config:) private + # Validate signature format for HMAC (uses HMAC-specific length limit) + # + # @param signature [String] Raw signature to validate + # @return [Boolean] true if signature is valid + # @api private + def self.validate_signature_format(signature) + # Check signature length with HMAC-specific limit + if signature.length > MAX_SIGNATURE_LENGTH + log.warn("Auth::HMAC validation failed: Signature length exceeds maximum limit of #{MAX_SIGNATURE_LENGTH} characters") + return false + end + + # Use shared validation for other checks + valid_header_value?(signature, "Signature") + end + # Build final configuration by merging defaults with provided config # # Combines default configuration values with user-provided settings, @@ -176,7 +226,12 @@ def self.build_config(config) algorithm: algorithm, format: validator_config[:format] || DEFAULT_CONFIG[:format], version_prefix: validator_config[:version_prefix] || DEFAULT_CONFIG[:version_prefix], - payload_template: validator_config[:payload_template] + payload_template: validator_config[:payload_template], + header_format: validator_config[:header_format] || DEFAULT_CONFIG[:header_format], + signature_key: validator_config[:signature_key] || "v1", + timestamp_key: validator_config[:timestamp_key] || "t", + structured_header_separator: validator_config[:structured_header_separator] || ",", + key_value_separator: validator_config[:key_value_separator] || "=" }) end @@ -321,6 +376,44 @@ def self.format_signature(hash, config) "#{config[:algorithm]}=#{hash}" end end + + # Parse structured signature header containing comma-separated key-value pairs + # + # Parses signature headers like "t=1663781880,v1=0123456789abcdef..." used by + # providers like Tailscale that include multiple values in a single header. + # + # @param header_value [String] Raw signature header value + # @param config [Hash] Validator configuration + # @return [Hash, nil] Parsed data with :signature and :timestamp keys, or nil if parsing fails + # @note Returns nil if the header format is invalid or required keys are missing + # @api private + def self.parse_structured_header(header_value, config) + signature_key = config[:signature_key] + timestamp_key = config[:timestamp_key] + separator = config[:structured_header_separator] + key_value_separator = config[:key_value_separator] + + # Parse comma-separated key-value pairs + pairs = {} + header_value.split(separator).each do |pair| + key, value = pair.split(key_value_separator, 2) + return nil if key.nil? || value.nil? + + pairs[key.strip] = value.strip + end + + # Extract required signature + signature = pairs[signature_key] + return nil if signature.nil? || signature.empty? + + result = { signature: signature } + + # Extract optional timestamp + timestamp = pairs[timestamp_key] + result[:timestamp] = timestamp if timestamp && !timestamp.empty? + + result + end end end end diff --git a/lib/hooks/plugins/auth/shared_secret.rb b/lib/hooks/plugins/auth/shared_secret.rb index 50550bd..2de0e1a 100644 --- a/lib/hooks/plugins/auth/shared_secret.rb +++ b/lib/hooks/plugins/auth/shared_secret.rb @@ -61,11 +61,9 @@ def self.valid?(payload:, headers:, config:) validator_config = build_config(config) - # Security: Check raw headers BEFORE normalization to detect tampering - unless headers.respond_to?(:each) - log.warn("Auth::SharedSecret validation failed: Invalid headers object") - return false - end + # Security: Check raw headers and payload BEFORE processing + return false unless valid_headers?(headers) + return false unless valid_payload_size?(payload) secret_header = validator_config[:header] @@ -77,19 +75,13 @@ def self.valid?(payload:, headers:, config:) return false end - stripped_secret = raw_secret.strip - - # Security: Reject secrets with leading/trailing whitespace - if raw_secret != stripped_secret - log.warn("Auth::SharedSecret validation failed: Secret contains leading/trailing whitespace") + # Validate secret format using shared validation + unless valid_header_value?(raw_secret, "Secret") + log.warn("Auth::SharedSecret validation failed: Invalid secret format") return false end - # Security: Reject secrets containing null bytes or other control characters - if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/) - log.warn("Auth::SharedSecret validation failed: Secret contains control characters") - return false - end + stripped_secret = raw_secret.strip # Use secure comparison to prevent timing attacks result = Rack::Utils.secure_compare(secret, stripped_secret) @@ -106,12 +98,6 @@ def self.valid?(payload:, headers:, config:) private - # Short logger accessor for auth module - # @return [Hooks::Log] Logger instance - def self.log - Hooks::Log.instance - end - # Build final configuration by merging defaults with provided config # # Combines default configuration values with user-provided settings, diff --git a/lib/hooks/version.rb b/lib/hooks/version.rb index 9f42eac..cea51c1 100644 --- a/lib/hooks/version.rb +++ b/lib/hooks/version.rb @@ -4,5 +4,5 @@ module Hooks # Current version of the Hooks webhook framework # @return [String] The version string following semantic versioning - VERSION = "0.2.0".freeze + VERSION = "0.2.1".freeze end diff --git a/spec/acceptance/acceptance_tests.rb b/spec/acceptance/acceptance_tests.rb index fce8b83..500ad80 100644 --- a/spec/acceptance/acceptance_tests.rb +++ b/spec/acceptance/acceptance_tests.rb @@ -52,6 +52,12 @@ def generate_slack_signature(payload, secret, timestamp) "v0=#{digest}" end + def generate_tailscale_signature(payload, secret, timestamp = unix_timestamp) + signing_payload = "#{timestamp}.#{payload}" + signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) + "t=#{timestamp},v1=#{signature}" + end + def current_timestamp Time.now.utc.iso8601 end @@ -520,5 +526,47 @@ def expired_unix_timestamp(seconds_ago = 600) expect(response.code).to eq("500") end end + + describe "tailscale" do + it "successfully processes a valid POST request from a tailscale style webhook" do + payload = { event: "user.login", user: { id: "12345" } } + json_payload = payload.to_json + timestamp = unix_timestamp + signature = generate_tailscale_signature(json_payload, FAKE_ALT_HMAC_SECRET, timestamp) + headers = json_headers("Tailscale-Webhook-Signature" => signature) + response = make_request(:post, "/webhooks/tailscale", json_payload, headers) + expect_response(response, Net::HTTPSuccess) + + body = parse_json_response(response) + expect(body["status"]).to eq("success") + end + + it "rejects request with invalid signature" do + payload = { event: "user.login", user: { id: "12345" } } + json_payload = payload.to_json + headers = json_headers("Tailscale-Webhook-Signature" => "t=1663781880,v1=invalidsignature") + response = make_request(:post, "/webhooks/tailscale", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") + end + + it "rejects request with missing signature header" do + payload = { event: "user.login", user: { id: "12345" } } + json_payload = payload.to_json + response = make_request(:post, "/webhooks/tailscale", json_payload, json_headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") + end + + it "rejects request with wrong signature algorithm" do + payload = { event: "user.login", user: { id: "12345" } } + json_payload = payload.to_json + timestamp = unix_timestamp + # Generate with sha1 instead of sha256 + signing_payload = "#{timestamp}.#{json_payload}" + wrong_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha1"), FAKE_ALT_HMAC_SECRET, signing_payload) + headers = json_headers("Tailscale-Webhook-Signature" => "t=#{timestamp},v1=#{wrong_signature}") + response = make_request(:post, "/webhooks/tailscale", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") + end + end end end diff --git a/spec/acceptance/config/endpoints/tailscale.yaml b/spec/acceptance/config/endpoints/tailscale.yaml new file mode 100644 index 0000000..5382bfa --- /dev/null +++ b/spec/acceptance/config/endpoints/tailscale.yaml @@ -0,0 +1,14 @@ +path: /tailscale +handler: Hello + +auth: + type: hmac + secret_env_key: ALT_WEBHOOK_SECRET + header: Tailscale-Webhook-Signature + algorithm: sha256 + format: "signature_only" # produces "abc123..." (no prefix) + header_format: "structured" # enables parsing of "t=123,v1=abc" format, this is what tailscale uses + signature_key: "v1" # key for signature in structured header + timestamp_key: "t" # key for timestamp in structured header + payload_template: "{timestamp}.{body}" # dot-separated format + timestamp_tolerance: 300 # 5 minutes diff --git a/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb b/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb index e8b37f1..0d321cf 100644 --- a/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb +++ b/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb @@ -23,6 +23,7 @@ before(:each) do Hooks::Log.instance = log + allow(ENV).to receive(:[]).and_call_original allow(ENV).to receive(:[]).with("HMAC_TEST_SECRET").and_return(secret) end @@ -273,9 +274,18 @@ def create_timestamped_signature(timestamp, version = "v0") it "handles very long signatures gracefully" do long_signature = "sha256=" + ("a" * 10000) long_headers = { default_header => long_signature } + expect(log).to receive(:warn).with(/Signature length exceeds maximum limit/) expect(valid_with(headers: long_headers)).to be false end + it "returns false for signatures exceeding maximum length limit" do + # Create signature larger than MAX_SIGNATURE_LENGTH (1024 + 1 characters) + oversized_signature = "sha256=" + ("a" * (1024 - 7 + 1)) # -7 for "sha256=" prefix + oversized_headers = { default_header => oversized_signature } + expect(log).to receive(:warn).with(/Signature length exceeds maximum limit/) + expect(valid_with(headers: oversized_headers)).to be false + end + it "handles very long payloads" do long_payload = "a" * 100000 long_signature = create_algorithm_prefixed_signature(long_payload) @@ -283,6 +293,15 @@ def create_timestamped_signature(timestamp, version = "v0") expect(valid_with(payload: long_payload, headers: long_headers)).to be true end + it "returns false for payloads exceeding maximum size limit" do + # Create payload larger than MAX_PAYLOAD_SIZE (10MB + 1 byte) + oversized_payload = "a" * (10 * 1024 * 1024 + 1) + signature = create_algorithm_prefixed_signature(payload) # Use regular payload for signature + headers_with_signature = { default_header => signature } + expect(log).to receive(:warn).with(/Payload size exceeds maximum limit/) + expect(valid_with(payload: oversized_payload, headers: headers_with_signature)).to be false + end + it "returns false and logs for signature containing non-null control characters" do control_char = "\x01" headers_with_control = { default_header => signature + control_char } @@ -696,4 +715,174 @@ def test_iso_timestamp(iso_timestamp, should_be_valid) expect(Hooks::Log.instance).to have_received(:warn).with("Auth::HMAC validation failed: Signature mismatch") end end + + describe ".parse_structured_header" do + it "parses valid structured header with timestamp and signature" do + config = { signature_key: "v1", timestamp_key: "t", key_value_separator: "=", structured_header_separator: "," } + header_value = "t=1663781880,v1=0123456789abcdef" + + result = described_class.send(:parse_structured_header, header_value, config) + + expect(result).to eq({ + signature: "0123456789abcdef", + timestamp: "1663781880" + }) + end + + it "parses valid structured header with timestamp and signature using different separators" do + config = { signature_key: "v1", timestamp_key: "t", key_value_separator: ":", structured_header_separator: "." } + header_value = "t:1663781880.v1:0123456789abcdef" + + result = described_class.send(:parse_structured_header, header_value, config) + + expect(result).to eq({ + signature: "0123456789abcdef", + timestamp: "1663781880" + }) + end + + it "parses structured header with only signature" do + config = { signature_key: "v1", timestamp_key: "t", key_value_separator: "=", structured_header_separator: "," } + header_value = "v1=abcdef123456" + + result = described_class.send(:parse_structured_header, header_value, config) + + expect(result).to eq({ + signature: "abcdef123456" + }) + end + + it "handles extra whitespace in key-value pairs" do + config = { signature_key: "v1", timestamp_key: "t", key_value_separator: "=", structured_header_separator: "," } + header_value = "t = 1663781880 , v1 = 0123456789abcdef " + + result = described_class.send(:parse_structured_header, header_value, config) + + expect(result).to eq({ + signature: "0123456789abcdef", + timestamp: "1663781880" + }) + end + + it "returns nil for malformed header (missing equals)" do + config = { signature_key: "v1", timestamp_key: "t", key_value_separator: "=", structured_header_separator: "," } + header_value = "t,v1=abcdef" + + result = described_class.send(:parse_structured_header, header_value, config) + + expect(result).to be_nil + end + + it "returns nil when signature key is missing" do + config = { signature_key: "v1", timestamp_key: "t", key_value_separator: "=", structured_header_separator: "," } + header_value = "t=1663781880,other=value" + + result = described_class.send(:parse_structured_header, header_value, config) + + expect(result).to be_nil + end + + it "returns nil when signature value is empty" do + config = { signature_key: "v1", timestamp_key: "t", key_value_separator: "=", structured_header_separator: "," } + header_value = "t=1663781880,v1=" + + result = described_class.send(:parse_structured_header, header_value, config) + + expect(result).to be_nil + end + + it "ignores extra key-value pairs not in config" do + config = { signature_key: "v1", timestamp_key: "t", key_value_separator: "=", structured_header_separator: "," } + header_value = "t=1663781880,v1=abcdef,extra=ignored,another=also_ignored" + + result = described_class.send(:parse_structured_header, header_value, config) + + expect(result).to eq({ + signature: "abcdef", + timestamp: "1663781880" + }) + end + end + + describe "structured header format validation" do + let(:secret) { "supersecret" } + let(:payload) { '{"event":"test"}' } + let(:timestamp) { Time.now.to_i.to_s } + + def create_tailscale_signature(payload, timestamp, secret) + signing_payload = "#{timestamp}.#{payload}" + signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) + "t=#{timestamp},v1=#{signature}" + end + + context "with structured header format" do + let(:config) do + { + auth: { + 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, + secret_env_key: "HMAC_TEST_SECRET" + } + } + end + + it "validates Tailscale-style structured signatures" do + signature_header_value = create_tailscale_signature(payload, timestamp, secret) + headers = { "Tailscale-Webhook-Signature" => signature_header_value } + + expect(valid_with(payload:, headers:, config:)).to be true + end + + it "fails with invalid structured signature" do + headers = { "Tailscale-Webhook-Signature" => "t=#{timestamp},v1=invalid_signature" } + + expect(valid_with(payload:, headers:, config:)).to be false + end + + it "fails with malformed structured header" do + headers = { "Tailscale-Webhook-Signature" => "malformed_header" } + + expect(valid_with(payload:, headers:, config:)).to be false + end + + it "fails when signature key is missing from structured header" do + headers = { "Tailscale-Webhook-Signature" => "t=#{timestamp},other=value" } + + expect(valid_with(payload:, headers:, config:)).to be false + end + + it "validates with timestamp tolerance" do + old_timestamp = (Time.now.to_i - 250).to_s # Within 300s tolerance + signature_header_value = create_tailscale_signature(payload, old_timestamp, secret) + headers = { "Tailscale-Webhook-Signature" => signature_header_value } + + expect(valid_with(payload:, headers:, config:)).to be true + end + + it "fails when timestamp is too old" do + old_timestamp = (Time.now.to_i - 400).to_s # Beyond 300s tolerance + signature_header_value = create_tailscale_signature(payload, old_timestamp, secret) + headers = { "Tailscale-Webhook-Signature" => signature_header_value } + + expect(valid_with(payload:, headers:, config:)).to be false + end + + it "works without timestamp when not required" do + config_no_timestamp = config[:auth].dup + config_no_timestamp.delete(:payload_template) + config_with_no_timestamp = { auth: config_no_timestamp } + + signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload) + headers = { "Tailscale-Webhook-Signature" => "v1=#{signature}" } + + expect(valid_with(payload:, headers:, config: config_with_no_timestamp)).to be true + end + end + end end diff --git a/spec/unit/lib/hooks/plugins/auth/shared_secret_spec.rb b/spec/unit/lib/hooks/plugins/auth/shared_secret_spec.rb index 25fc286..64373e1 100644 --- a/spec/unit/lib/hooks/plugins/auth/shared_secret_spec.rb +++ b/spec/unit/lib/hooks/plugins/auth/shared_secret_spec.rb @@ -26,6 +26,7 @@ def valid_with(args = {}) end before do + allow(ENV).to receive(:[]).and_call_original allow(ENV).to receive(:[]).with("SUPER_WEBHOOK_SECRET").and_return(secret) end @@ -99,6 +100,22 @@ def valid_with(args = {}) nil_headers = { default_header => nil } expect(valid_with(headers: nil_headers)).to be false end + + it "returns false for secrets exceeding maximum length limit" do + # Create secret larger than MAX_HEADER_VALUE_LENGTH (1024 + 1 characters) + oversized_secret = "a" * (1024 + 1) + oversized_headers = { default_header => oversized_secret } + expect(log).to receive(:warn).with(/exceeds maximum length/) + expect(valid_with(headers: oversized_headers)).to be false + end + + it "returns false for payloads exceeding maximum size limit" do + # Create payload larger than MAX_PAYLOAD_SIZE (10MB + 1 byte) + oversized_payload = "a" * (10 * 1024 * 1024 + 1) + headers = { default_header => secret } + expect(log).to receive(:warn).with(/Payload size exceeds maximum limit/) + expect(valid_with(payload: oversized_payload, headers: headers)).to be false + end end context "with custom header configuration" do