@@ -696,4 +696,162 @@ def test_iso_timestamp(iso_timestamp, should_be_valid)
696
696
expect ( Hooks ::Log . instance ) . to have_received ( :warn ) . with ( "Auth::HMAC validation failed: Signature mismatch" )
697
697
end
698
698
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
699
857
end
0 commit comments