Skip to content

Commit 5323fa2

Browse files
CopilotGrantBirki
andcommitted
Add support for structured signature headers (Tailscale-style)
Co-authored-by: GrantBirki <[email protected]>
1 parent 7ae92bd commit 5323fa2

File tree

2 files changed

+225
-3
lines changed

2 files changed

+225
-3
lines changed

lib/hooks/plugins/auth/hmac.rb

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ class HMAC < Base
4242
format: "algorithm=signature", # Format: algorithm=hash
4343
header: "X-Signature", # Default header containing the signature
4444
timestamp_tolerance: 300, # 5 minutes tolerance for timestamp validation
45-
version_prefix: "v0" # Default version prefix for versioned signatures
45+
version_prefix: "v0", # Default version prefix for versioned signatures
46+
header_format: "simple" # Header format: "simple" or "structured"
4647
}.freeze
4748

4849
# Mapping of signature format strings to internal format symbols
@@ -75,6 +76,9 @@ class HMAC < Base
7576
# @option config [String] :format ('algorithm=signature') Signature format
7677
# @option config [String] :version_prefix ('v0') Version prefix for versioned signatures
7778
# @option config [String] :payload_template Template for payload construction
79+
# @option config [String] :header_format ('simple') Header format: 'simple' or 'structured'
80+
# @option config [String] :signature_key ('v1') Key for signature in structured headers
81+
# @option config [String] :timestamp_key ('t') Key for timestamp in structured headers
7882
# @return [Boolean] true if signature is valid, false otherwise
7983
# @raise [StandardError] Rescued internally, returns false on any error
8084
# @note This method is designed to be safe and will never raise exceptions
@@ -121,7 +125,28 @@ def self.valid?(payload:, headers:, config:)
121125

122126
# Now we can safely normalize headers for the rest of the validation
123127
normalized_headers = normalize_headers(headers)
124-
provided_signature = normalized_headers[signature_header.downcase]
128+
129+
# Handle structured headers (e.g., Tailscale format: "t=123,v1=abc")
130+
if validator_config[:header_format] == "structured"
131+
parsed_signature_data = parse_structured_header(raw_signature, validator_config)
132+
if parsed_signature_data.nil?
133+
log.warn("Auth::HMAC validation failed: Could not parse structured signature header")
134+
return false
135+
end
136+
137+
provided_signature = parsed_signature_data[:signature]
138+
139+
# For structured headers, timestamp comes from the signature header itself
140+
if parsed_signature_data[:timestamp]
141+
normalized_headers = normalized_headers.merge(
142+
"extracted_timestamp" => parsed_signature_data[:timestamp]
143+
)
144+
# Override timestamp_header to use our extracted timestamp
145+
validator_config = validator_config.merge(timestamp_header: "extracted_timestamp")
146+
end
147+
else
148+
provided_signature = normalized_headers[signature_header.downcase]
149+
end
125150

126151
# Validate timestamp if required (for services that include timestamp validation)
127152
if validator_config[:timestamp_header]
@@ -176,7 +201,10 @@ def self.build_config(config)
176201
algorithm: algorithm,
177202
format: validator_config[:format] || DEFAULT_CONFIG[:format],
178203
version_prefix: validator_config[:version_prefix] || DEFAULT_CONFIG[:version_prefix],
179-
payload_template: validator_config[:payload_template]
204+
payload_template: validator_config[:payload_template],
205+
header_format: validator_config[:header_format] || DEFAULT_CONFIG[:header_format],
206+
signature_key: validator_config[:signature_key] || "v1",
207+
timestamp_key: validator_config[:timestamp_key] || "t"
180208
})
181209
end
182210

@@ -321,6 +349,42 @@ def self.format_signature(hash, config)
321349
"#{config[:algorithm]}=#{hash}"
322350
end
323351
end
352+
353+
# Parse structured signature header containing comma-separated key-value pairs
354+
#
355+
# Parses signature headers like "t=1663781880,v1=0123456789abcdef..." used by
356+
# providers like Tailscale that include multiple values in a single header.
357+
#
358+
# @param header_value [String] Raw signature header value
359+
# @param config [Hash<Symbol, Object>] Validator configuration
360+
# @return [Hash<Symbol, String>, nil] Parsed data with :signature and :timestamp keys, or nil if parsing fails
361+
# @note Returns nil if the header format is invalid or required keys are missing
362+
# @api private
363+
def self.parse_structured_header(header_value, config)
364+
signature_key = config[:signature_key]
365+
timestamp_key = config[:timestamp_key]
366+
367+
# Parse comma-separated key-value pairs
368+
pairs = {}
369+
header_value.split(",").each do |pair|
370+
key, value = pair.split("=", 2)
371+
return nil if key.nil? || value.nil?
372+
373+
pairs[key.strip] = value.strip
374+
end
375+
376+
# Extract required signature
377+
signature = pairs[signature_key]
378+
return nil if signature.nil? || signature.empty?
379+
380+
result = { signature: signature }
381+
382+
# Extract optional timestamp
383+
timestamp = pairs[timestamp_key]
384+
result[:timestamp] = timestamp if timestamp && !timestamp.empty?
385+
386+
result
387+
end
324388
end
325389
end
326390
end

spec/unit/lib/hooks/plugins/auth/hmac_spec.rb

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,4 +696,162 @@ def test_iso_timestamp(iso_timestamp, should_be_valid)
696696
expect(Hooks::Log.instance).to have_received(:warn).with("Auth::HMAC validation failed: Signature mismatch")
697697
end
698698
end
699+
700+
describe ".parse_structured_header" do
701+
it "parses valid structured header with timestamp and signature" do
702+
config = { signature_key: "v1", timestamp_key: "t" }
703+
header_value = "t=1663781880,v1=0123456789abcdef"
704+
705+
result = described_class.send(:parse_structured_header, header_value, config)
706+
707+
expect(result).to eq({
708+
signature: "0123456789abcdef",
709+
timestamp: "1663781880"
710+
})
711+
end
712+
713+
it "parses structured header with only signature" do
714+
config = { signature_key: "v1", timestamp_key: "t" }
715+
header_value = "v1=abcdef123456"
716+
717+
result = described_class.send(:parse_structured_header, header_value, config)
718+
719+
expect(result).to eq({
720+
signature: "abcdef123456"
721+
})
722+
end
723+
724+
it "handles extra whitespace in key-value pairs" do
725+
config = { signature_key: "v1", timestamp_key: "t" }
726+
header_value = "t = 1663781880 , v1 = 0123456789abcdef "
727+
728+
result = described_class.send(:parse_structured_header, header_value, config)
729+
730+
expect(result).to eq({
731+
signature: "0123456789abcdef",
732+
timestamp: "1663781880"
733+
})
734+
end
735+
736+
it "returns nil for malformed header (missing equals)" do
737+
config = { signature_key: "v1", timestamp_key: "t" }
738+
header_value = "t,v1=abcdef"
739+
740+
result = described_class.send(:parse_structured_header, header_value, config)
741+
742+
expect(result).to be_nil
743+
end
744+
745+
it "returns nil when signature key is missing" do
746+
config = { signature_key: "v1", timestamp_key: "t" }
747+
header_value = "t=1663781880,other=value"
748+
749+
result = described_class.send(:parse_structured_header, header_value, config)
750+
751+
expect(result).to be_nil
752+
end
753+
754+
it "returns nil when signature value is empty" do
755+
config = { signature_key: "v1", timestamp_key: "t" }
756+
header_value = "t=1663781880,v1="
757+
758+
result = described_class.send(:parse_structured_header, header_value, config)
759+
760+
expect(result).to be_nil
761+
end
762+
763+
it "ignores extra key-value pairs not in config" do
764+
config = { signature_key: "v1", timestamp_key: "t" }
765+
header_value = "t=1663781880,v1=abcdef,extra=ignored,another=also_ignored"
766+
767+
result = described_class.send(:parse_structured_header, header_value, config)
768+
769+
expect(result).to eq({
770+
signature: "abcdef",
771+
timestamp: "1663781880"
772+
})
773+
end
774+
end
775+
776+
describe "structured header format validation" do
777+
let(:secret) { "supersecret" }
778+
let(:payload) { '{"event":"test"}' }
779+
let(:timestamp) { Time.now.to_i.to_s }
780+
781+
def create_tailscale_signature(payload, timestamp, secret)
782+
signing_payload = "#{timestamp}.#{payload}"
783+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload)
784+
"t=#{timestamp},v1=#{signature}"
785+
end
786+
787+
context "with structured header format" do
788+
let(:config) do
789+
{
790+
auth: {
791+
header: "Tailscale-Webhook-Signature",
792+
algorithm: "sha256",
793+
format: "signature_only",
794+
header_format: "structured",
795+
signature_key: "v1",
796+
timestamp_key: "t",
797+
payload_template: "{timestamp}.{body}",
798+
timestamp_tolerance: 300,
799+
secret_env_key: "HMAC_TEST_SECRET"
800+
}
801+
}
802+
end
803+
804+
it "validates Tailscale-style structured signatures" do
805+
signature_header_value = create_tailscale_signature(payload, timestamp, secret)
806+
headers = { "Tailscale-Webhook-Signature" => signature_header_value }
807+
808+
expect(valid_with(payload:, headers:, config:)).to be true
809+
end
810+
811+
it "fails with invalid structured signature" do
812+
headers = { "Tailscale-Webhook-Signature" => "t=#{timestamp},v1=invalid_signature" }
813+
814+
expect(valid_with(payload:, headers:, config:)).to be false
815+
end
816+
817+
it "fails with malformed structured header" do
818+
headers = { "Tailscale-Webhook-Signature" => "malformed_header" }
819+
820+
expect(valid_with(payload:, headers:, config:)).to be false
821+
end
822+
823+
it "fails when signature key is missing from structured header" do
824+
headers = { "Tailscale-Webhook-Signature" => "t=#{timestamp},other=value" }
825+
826+
expect(valid_with(payload:, headers:, config:)).to be false
827+
end
828+
829+
it "validates with timestamp tolerance" do
830+
old_timestamp = (Time.now.to_i - 250).to_s # Within 300s tolerance
831+
signature_header_value = create_tailscale_signature(payload, old_timestamp, secret)
832+
headers = { "Tailscale-Webhook-Signature" => signature_header_value }
833+
834+
expect(valid_with(payload:, headers:, config:)).to be true
835+
end
836+
837+
it "fails when timestamp is too old" do
838+
old_timestamp = (Time.now.to_i - 400).to_s # Beyond 300s tolerance
839+
signature_header_value = create_tailscale_signature(payload, old_timestamp, secret)
840+
headers = { "Tailscale-Webhook-Signature" => signature_header_value }
841+
842+
expect(valid_with(payload:, headers:, config:)).to be false
843+
end
844+
845+
it "works without timestamp when not required" do
846+
config_no_timestamp = config[:auth].dup
847+
config_no_timestamp.delete(:payload_template)
848+
config_with_no_timestamp = { auth: config_no_timestamp }
849+
850+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload)
851+
headers = { "Tailscale-Webhook-Signature" => "v1=#{signature}" }
852+
853+
expect(valid_with(payload:, headers:, config: config_with_no_timestamp)).to be true
854+
end
855+
end
856+
end
699857
end

0 commit comments

Comments
 (0)