@@ -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
699857end
0 commit comments