12
12
namespace Symfony \Component \Mailer \Bridge \Resend \Webhook ;
13
13
14
14
use Symfony \Component \HttpFoundation \ChainRequestMatcher ;
15
+ use Symfony \Component \HttpFoundation \HeaderBag ;
15
16
use Symfony \Component \HttpFoundation \Request ;
17
+ use Symfony \Component \HttpFoundation \RequestMatcher \HeaderRequestMatcher ;
16
18
use Symfony \Component \HttpFoundation \RequestMatcher \IsJsonRequestMatcher ;
17
19
use Symfony \Component \HttpFoundation \RequestMatcher \MethodRequestMatcher ;
18
- use Symfony \Component \HttpFoundation \RequestMatcher \SchemeRequestMatcher ;
19
20
use Symfony \Component \HttpFoundation \RequestMatcherInterface ;
20
21
use Symfony \Component \Mailer \Bridge \Resend \RemoteEvent \ResendPayloadConverter ;
22
+ use Symfony \Component \Mailer \Exception \InvalidArgumentException ;
21
23
use Symfony \Component \RemoteEvent \Event \Mailer \AbstractMailerEvent ;
22
24
use Symfony \Component \RemoteEvent \Exception \ParseException ;
23
25
use Symfony \Component \Webhook \Client \AbstractRequestParser ;
@@ -34,14 +36,23 @@ protected function getRequestMatcher(): RequestMatcherInterface
34
36
{
35
37
return new ChainRequestMatcher ([
36
38
new MethodRequestMatcher ('POST ' ),
37
- new SchemeRequestMatcher ('https ' ),
38
39
new IsJsonRequestMatcher (),
40
+ new HeaderRequestMatcher ([
41
+ 'svix-id ' ,
42
+ 'svix-timestamp ' ,
43
+ 'svix-signature ' ,
44
+ ]),
39
45
]);
40
46
}
41
47
42
48
protected function doParse (Request $ request , #[\SensitiveParameter] string $ secret ): ?AbstractMailerEvent
43
49
{
50
+ if (!$ secret ) {
51
+ throw new InvalidArgumentException ('A non-empty secret is required. ' );
52
+ }
53
+
44
54
$ content = $ request ->toArray ();
55
+
45
56
if (
46
57
!isset ($ content ['type ' ])
47
58
|| !isset ($ content ['created_at ' ])
@@ -55,10 +66,65 @@ protected function doParse(Request $request, #[\SensitiveParameter] string $secr
55
66
throw new RejectWebhookException (406 , 'Payload is malformed. ' );
56
67
}
57
68
69
+ $ this ->validateSignature ($ request ->getContent (), $ request ->headers , $ secret );
70
+
58
71
try {
59
72
return $ this ->converter ->convert ($ content );
60
73
} catch (ParseException $ e ) {
61
74
throw new RejectWebhookException (406 , $ e ->getMessage (), $ e );
62
75
}
63
76
}
77
+
78
+ private function validateSignature (string $ payload , HeaderBag $ headers , string $ secret ): void
79
+ {
80
+ $ secret = $ this ->decodeSecret ($ secret );
81
+ $ messageId = $ headers ->get ('svix-id ' );
82
+ $ messageTimestamp = (int ) $ headers ->get ('svix-timestamp ' );
83
+ $ messageSignature = $ headers ->get ('svix-signature ' );
84
+
85
+ $ signature = $ this ->sign ($ secret , $ messageId , $ messageTimestamp , $ payload );
86
+ $ expectedSignature = explode (', ' , $ signature , 2 )[1 ];
87
+ $ passedSignatures = explode (' ' , $ messageSignature );
88
+ $ signatureFound = false ;
89
+
90
+ foreach ($ passedSignatures as $ versionedSignature ) {
91
+ $ signatureParts = explode (', ' , $ versionedSignature , 2 );
92
+ $ version = $ signatureParts [0 ];
93
+
94
+ if ('v1 ' !== $ version ) {
95
+ continue ;
96
+ }
97
+
98
+ $ passedSignature = $ signatureParts [1 ];
99
+
100
+ if (hash_equals ($ expectedSignature , $ passedSignature )) {
101
+ $ signatureFound = true ;
102
+
103
+ break ;
104
+ }
105
+ }
106
+
107
+ if (!$ signatureFound ) {
108
+ throw new RejectWebhookException (406 , 'No signatures found matching the expected signature. ' );
109
+ }
110
+ }
111
+
112
+ private function sign (string $ secret , string $ messageId , int $ timestamp , string $ payload ): string
113
+ {
114
+ $ toSign = sprintf ('%s.%s.%s ' , $ messageId , $ timestamp , $ payload );
115
+ $ hash = hash_hmac ('sha256 ' , $ toSign , $ secret );
116
+ $ signature = base64_encode (pack ('H* ' , $ hash ));
117
+
118
+ return 'v1, ' .$ signature ;
119
+ }
120
+
121
+ private function decodeSecret (string $ secret ): string
122
+ {
123
+ $ prefix = 'whsec_ ' ;
124
+ if (str_starts_with ($ secret , $ prefix )) {
125
+ $ secret = substr ($ secret , \strlen ($ prefix ));
126
+ }
127
+
128
+ return base64_decode ($ secret );
129
+ }
64
130
}
0 commit comments