xtr-webhook 4.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtr_webhook/.agents/skills/xtr-webhook/SKILL.md +350 -0
- xtr_webhook/.agents/skills/xtr-webhook/references/signature-formats.md +189 -0
- xtr_webhook/__init__.py +10 -0
- xtr_webhook/_endpoint_paths.py +22 -0
- xtr_webhook/_standard_webhooks_signature.py +76 -0
- xtr_webhook/bundle/__init__.py +15 -0
- xtr_webhook/bundle/_routed_parsers.py +70 -0
- xtr_webhook/bundle/no_private_network_config.py +59 -0
- xtr_webhook/bundle/route_config.py +34 -0
- xtr_webhook/bundle/webhook_bundle.py +262 -0
- xtr_webhook/bundle/webhook_config.py +80 -0
- xtr_webhook/client/__init__.py +23 -0
- xtr_webhook/client/abstract_request_parser.py +248 -0
- xtr_webhook/client/request_parser.py +272 -0
- xtr_webhook/client/request_parser_interface.py +90 -0
- xtr_webhook/controller/__init__.py +12 -0
- xtr_webhook/controller/_built_parsers.py +65 -0
- xtr_webhook/controller/webhook_controller.py +140 -0
- xtr_webhook/exception/__init__.py +25 -0
- xtr_webhook/exception/delivery_error.py +38 -0
- xtr_webhook/exception/invalid_argument_error.py +18 -0
- xtr_webhook/exception/logic_error.py +15 -0
- xtr_webhook/exception/reject_webhook_error.py +52 -0
- xtr_webhook/exception/webhook_error.py +9 -0
- xtr_webhook/exception/webhook_runtime_error.py +16 -0
- xtr_webhook/messenger/__init__.py +14 -0
- xtr_webhook/messenger/send_webhook_handler.py +49 -0
- xtr_webhook/messenger/send_webhook_message.py +36 -0
- xtr_webhook/py.typed +0 -0
- xtr_webhook/recipe/__init__.py +18 -0
- xtr_webhook/recipe/files/config/routes/webhook.py.tmpl +25 -0
- xtr_webhook/recipe/files/config/webhook.py.tmpl +33 -0
- xtr_webhook/recipe/manifest.toml +35 -0
- xtr_webhook/routing/__init__.py +11 -0
- xtr_webhook/routing/_endpoints.py +38 -0
- xtr_webhook/routing/webhook_route_loader.py +31 -0
- xtr_webhook/server/__init__.py +39 -0
- xtr_webhook/server/header_signature_configurator.py +128 -0
- xtr_webhook/server/headers_configurator.py +92 -0
- xtr_webhook/server/json_body_configurator.py +80 -0
- xtr_webhook/server/json_payload_serializer.py +45 -0
- xtr_webhook/server/outgoing_request.py +35 -0
- xtr_webhook/server/payload_serializer_interface.py +34 -0
- xtr_webhook/server/request_configurator_interface.py +39 -0
- xtr_webhook/server/signature_format.py +65 -0
- xtr_webhook/server/transport.py +106 -0
- xtr_webhook/server/transport_interface.py +40 -0
- xtr_webhook/subscriber.py +41 -0
- xtr_webhook/testing/__init__.py +18 -0
- xtr_webhook/testing/_parser_cases.py +141 -0
- xtr_webhook/testing/parser_case.py +37 -0
- xtr_webhook-4.0.2.dist-info/METADATA +704 -0
- xtr_webhook-4.0.2.dist-info/RECORD +56 -0
- xtr_webhook-4.0.2.dist-info/WHEEL +4 -0
- xtr_webhook-4.0.2.dist-info/entry_points.txt +6 -0
- xtr_webhook-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Everything a parser of incoming webhooks does the same way as every other."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
import msgspec
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
from xtr_http_message_contracts import ResponseInterface
|
|
11
|
+
|
|
12
|
+
from xtr_webhook.exception import RejectWebhookError
|
|
13
|
+
|
|
14
|
+
from .request_parser_interface import RequestParserInterface
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
from xtr_http_foundation import RequestMatcherInterface
|
|
20
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
21
|
+
from xtr_remote_event import RemoteEvent
|
|
22
|
+
|
|
23
|
+
__all__ = ["AbstractRequestParser"]
|
|
24
|
+
|
|
25
|
+
R = TypeVar("R", bound=ResponseInterface)
|
|
26
|
+
|
|
27
|
+
_MALFORMED = "Request body is malformed."
|
|
28
|
+
_PLAIN_TEXT = "text/plain; charset=utf-8"
|
|
29
|
+
_UNACCEPTABLE = 406
|
|
30
|
+
_ACCEPTED = 202
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AbstractRequestParser(RequestParserInterface, ABC):
|
|
34
|
+
"""A parser with everything but the two things that differ per sender filled in.
|
|
35
|
+
|
|
36
|
+
What is left to a subclass is which requests are its sender's
|
|
37
|
+
(:meth:`get_request_matcher`) and how to check and read one
|
|
38
|
+
(:meth:`do_parse`). Everything around those is the same for every sender,
|
|
39
|
+
and wrong in the same way when each parser writes it again: the matcher has
|
|
40
|
+
to run before anything is read out of the request, an unreadable body has to
|
|
41
|
+
come out as a refusal rather than as a 500, and both answers have to be the
|
|
42
|
+
ones the sender's side is waiting for.
|
|
43
|
+
|
|
44
|
+
The matchers a parser says that with come from
|
|
45
|
+
[xtr-http-foundation](https://pypi.org/project/xtr-http-foundation/):
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
class AcmeRequestParser(AbstractRequestParser):
|
|
49
|
+
def get_request_matcher(self) -> RequestMatcherInterface:
|
|
50
|
+
return ChainRequestMatcher([MethodRequestMatcher("POST"), IsJsonRequestMatcher()])
|
|
51
|
+
|
|
52
|
+
async def do_parse(self, request, secret):
|
|
53
|
+
payload = await self.read_json(request)
|
|
54
|
+
return RemoteEvent("acme", str(payload["id"]), payload)
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@override
|
|
59
|
+
async def parse(
|
|
60
|
+
self,
|
|
61
|
+
request: ServerRequestInterface,
|
|
62
|
+
secret: str,
|
|
63
|
+
) -> RemoteEvent | Sequence[RemoteEvent] | None:
|
|
64
|
+
"""Check the shape of the request, then hand it to :meth:`do_parse`.
|
|
65
|
+
|
|
66
|
+
A body that will not decode is the sender's doing, not this
|
|
67
|
+
application's, so it comes back as a refusal the endpoint can answer
|
|
68
|
+
with rather than as an error the endpoint would report as its own
|
|
69
|
+
fault.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
request: The request that arrived.
|
|
73
|
+
secret: What the sender's signature is checked against.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Whatever :meth:`do_parse` read out of the request.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
RejectWebhookError: When the request is not one this parser
|
|
80
|
+
claims, when its body will not decode, or when
|
|
81
|
+
:meth:`do_parse` refuses it.
|
|
82
|
+
"""
|
|
83
|
+
await self.validate(request)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return await self.do_parse(request, secret)
|
|
87
|
+
except (msgspec.DecodeError, UnicodeDecodeError) as unreadable:
|
|
88
|
+
raise RejectWebhookError(_UNACCEPTABLE, _MALFORMED) from unreadable
|
|
89
|
+
|
|
90
|
+
@override
|
|
91
|
+
async def create_successful_response(self, request: ServerRequestInterface, response: R) -> R:
|
|
92
|
+
"""Answer a webhook that was read with 202 and nothing else.
|
|
93
|
+
|
|
94
|
+
The sender is told the webhook was taken, not what became of it: the
|
|
95
|
+
events it carried are handled after the answer has gone, so there is
|
|
96
|
+
nothing yet to report and nothing the sender could do with it.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
request: The request the endpoint received. Unused here; a parser
|
|
100
|
+
whose sender wants something echoed back overrides this.
|
|
101
|
+
response: The response the endpoint built.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
``response`` with the status set, its body left empty.
|
|
105
|
+
"""
|
|
106
|
+
del request
|
|
107
|
+
|
|
108
|
+
return response.with_status(_ACCEPTED)
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
async def create_rejected_response(
|
|
112
|
+
self,
|
|
113
|
+
reason: str,
|
|
114
|
+
request: ServerRequestInterface,
|
|
115
|
+
response: R,
|
|
116
|
+
) -> R:
|
|
117
|
+
"""Answer a refused webhook with 406 and the reason as plain text.
|
|
118
|
+
|
|
119
|
+
406 says the request was understood and still not acceptable, which is
|
|
120
|
+
what a wrong signature, a stale timestamp and an unreadable payload all
|
|
121
|
+
amount to from the sender's side. The reason goes in the body as text
|
|
122
|
+
because that is the one thing a sender's log is likely to keep.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
reason: What to tell the sender.
|
|
126
|
+
request: The request the endpoint received. Unused here.
|
|
127
|
+
response: The response the endpoint built.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
``response`` with the status, the content type and the reason on
|
|
131
|
+
it.
|
|
132
|
+
"""
|
|
133
|
+
del request
|
|
134
|
+
|
|
135
|
+
refused = response.with_status(_UNACCEPTABLE).with_header("Content-Type", _PLAIN_TEXT)
|
|
136
|
+
_ = await refused.get_body().write(reason.encode())
|
|
137
|
+
|
|
138
|
+
return refused
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def get_request_matcher(self) -> RequestMatcherInterface:
|
|
142
|
+
"""Return what decides whether a request is this parser's sender's.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The matcher every request is put through before it is read.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
@abstractmethod
|
|
149
|
+
async def do_parse(
|
|
150
|
+
self,
|
|
151
|
+
request: ServerRequestInterface,
|
|
152
|
+
secret: str,
|
|
153
|
+
) -> RemoteEvent | Sequence[RemoteEvent] | None:
|
|
154
|
+
"""Check that the request is genuine and read the events out of it.
|
|
155
|
+
|
|
156
|
+
The request has already been through :meth:`get_request_matcher`, so
|
|
157
|
+
its shape is not in question here. Whatever the sender's protocol asks
|
|
158
|
+
for is: comparing a signature over the raw body against a header, in
|
|
159
|
+
constant time, and refusing a timestamp outside the window it allows.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
request: The request that arrived.
|
|
163
|
+
secret: What the sender's signature is checked against.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The event the request carried, every event when it carried
|
|
167
|
+
several, or ``None`` when it is one to accept and ignore.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
RejectWebhookError: On any failure to verify the request.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
async def validate(self, request: ServerRequestInterface) -> None:
|
|
174
|
+
"""Refuse a request this parser's matcher does not claim.
|
|
175
|
+
|
|
176
|
+
Awaited because a matcher may be about the body — whether it reads as
|
|
177
|
+
JSON, above all — which arrives as it is read.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
request: The request that arrived.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
RejectWebhookError: When the matcher does not claim the request.
|
|
184
|
+
"""
|
|
185
|
+
if not await self.get_request_matcher().matches(request):
|
|
186
|
+
raise RejectWebhookError(_UNACCEPTABLE, "Request does not match.")
|
|
187
|
+
|
|
188
|
+
async def read_body(self, request: ServerRequestInterface) -> bytes:
|
|
189
|
+
"""Return the body of the request, whole, from its first byte.
|
|
190
|
+
|
|
191
|
+
A signature is made over exactly the bytes that arrived, and whatever
|
|
192
|
+
read the body before this — a framework logging it, a matcher looking
|
|
193
|
+
at it — left the cursor wherever it stopped. Reading the whole of a
|
|
194
|
+
stream rewinds it first, where it can be rewound, so that is all this
|
|
195
|
+
has to ask for.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
request: The request that arrived.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Every byte of the body.
|
|
202
|
+
"""
|
|
203
|
+
return await request.get_body().get_contents()
|
|
204
|
+
|
|
205
|
+
async def read_json(self, request: ServerRequestInterface) -> dict[str, object]:
|
|
206
|
+
"""Return the body of the request read as a JSON object.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
request: The request that arrived.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
The object the body decoded to, its values left as they came.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
RejectWebhookError: When the body is valid JSON but not an object
|
|
216
|
+
— an array or a bare value is not a webhook payload.
|
|
217
|
+
msgspec.DecodeError: When the body is not JSON at all. Raised
|
|
218
|
+
rather than refused here, so that :meth:`parse` turns it into
|
|
219
|
+
the same refusal wherever in a subclass it came from.
|
|
220
|
+
"""
|
|
221
|
+
return self._decode_json(await self.read_body(request))
|
|
222
|
+
|
|
223
|
+
def _decode_json(self, body: bytes) -> dict[str, object]:
|
|
224
|
+
"""Return bytes already read from the body, as a JSON object.
|
|
225
|
+
|
|
226
|
+
What :meth:`read_json` does once the body is in hand, separately, for a
|
|
227
|
+
parser that needs those bytes before it is willing to decode them: a
|
|
228
|
+
signature covers the body, so it has to be read whole and verified
|
|
229
|
+
first, and reading the stream a second time to decode it would be
|
|
230
|
+
decoding something nothing checked.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
body: The bytes of the body, whole.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The object they decoded to, their values left as they came.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
RejectWebhookError: When they are valid JSON but not an object
|
|
240
|
+
— an array or a bare value is not a webhook payload.
|
|
241
|
+
msgspec.DecodeError: When they are not JSON at all. Raised rather
|
|
242
|
+
than refused here, so that :meth:`parse` turns it into the same
|
|
243
|
+
refusal wherever in a subclass it came from.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
return msgspec.json.decode(body, type=dict[str, object])
|
|
247
|
+
except msgspec.ValidationError as not_an_object:
|
|
248
|
+
raise RejectWebhookError(_UNACCEPTABLE, _MALFORMED) from not_an_object
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""The parser for webhooks this library's own sender signs, in any of its three formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import re
|
|
8
|
+
from typing import TYPE_CHECKING, Final, final
|
|
9
|
+
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
from xtr_clock import Clock
|
|
12
|
+
from xtr_http_foundation import ChainRequestMatcher, IsJsonRequestMatcher, MethodRequestMatcher
|
|
13
|
+
from xtr_remote_event import RemoteEvent
|
|
14
|
+
|
|
15
|
+
from xtr_webhook._standard_webhooks_signature import sign
|
|
16
|
+
from xtr_webhook.exception import InvalidArgumentError, RejectWebhookError
|
|
17
|
+
from xtr_webhook.server.signature_format import SignatureFormat
|
|
18
|
+
|
|
19
|
+
from .abstract_request_parser import AbstractRequestParser
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from xtr_clock import ClockInterface
|
|
23
|
+
from xtr_http_foundation import RequestMatcherInterface
|
|
24
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
25
|
+
|
|
26
|
+
__all__ = ["RequestParser"]
|
|
27
|
+
|
|
28
|
+
_UNACCEPTABLE: Final = 406
|
|
29
|
+
|
|
30
|
+
_NUMERIC: Final = re.compile(r"^-?\d{1,19}$")
|
|
31
|
+
"""What a timestamp this parser can bound looks like: an integer, nothing else.
|
|
32
|
+
|
|
33
|
+
Bounded in length as well as in shape, because this runtime refuses to read a
|
|
34
|
+
long enough digit string as an int at all — and a sender that sent one is owed
|
|
35
|
+
the same refusal as any other unacceptable timestamp, not a server error.
|
|
36
|
+
Nineteen digits reach far past any time the window can accept.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@final
|
|
41
|
+
class RequestParser(AbstractRequestParser):
|
|
42
|
+
"""Reads webhooks signed the way this library's own sender signs them.
|
|
43
|
+
|
|
44
|
+
The mirror of the sender's configurators: the same two signature schemes,
|
|
45
|
+
under the same default header names, chosen by the same
|
|
46
|
+
:class:`SignatureFormat <xtr_webhook.server.SignatureFormat>`. Under the
|
|
47
|
+
transitional format either scheme authenticates a request, which is what
|
|
48
|
+
lets the two ends migrate on different days.
|
|
49
|
+
|
|
50
|
+
The signature header may carry several whitespace-separated entries — one
|
|
51
|
+
per secret during a rotation — and any one that verifies is enough. When a
|
|
52
|
+
Standard Webhooks entry verifies, the timestamp it covers is then held to a
|
|
53
|
+
window around now, so a delivery captured once cannot be fed back later.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__( # noqa: PLR0913, PLR0917 — every name and bound has a default both ends share.
|
|
57
|
+
self,
|
|
58
|
+
algorithm: str = "sha256",
|
|
59
|
+
signature_header_name: str = "Webhook-Signature",
|
|
60
|
+
event_header_name: str = "Webhook-Event",
|
|
61
|
+
id_header_name: str = "Webhook-Id",
|
|
62
|
+
timestamp_header_name: str = "Webhook-Timestamp",
|
|
63
|
+
signature_format: SignatureFormat | str = SignatureFormat.LEGACY,
|
|
64
|
+
timestamp_tolerance: int = 300,
|
|
65
|
+
clock: ClockInterface | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Configure what to verify against, under which names and bounds.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
algorithm: The digest the legacy signature is recomputed with. The
|
|
71
|
+
Standard Webhooks signature is always SHA-256; the scheme fixes it.
|
|
72
|
+
signature_header_name: The header the signature entries arrive in.
|
|
73
|
+
event_header_name: The header the event's name arrives in, where the
|
|
74
|
+
format sends one.
|
|
75
|
+
id_header_name: The header the event's id arrives in.
|
|
76
|
+
timestamp_header_name: The header the signed timestamp arrives in.
|
|
77
|
+
signature_format: Which scheme a request must carry. The string a
|
|
78
|
+
configuration carries is accepted.
|
|
79
|
+
timestamp_tolerance: How many seconds either side of now a signed
|
|
80
|
+
timestamp may be. Zero or less turns the bound off — for a
|
|
81
|
+
replayed capture to matter less than clocks that cannot be kept
|
|
82
|
+
close enough.
|
|
83
|
+
clock: What now is measured by. ``None`` reads whichever clock is in
|
|
84
|
+
force when a request arrives, so a test can freeze it without
|
|
85
|
+
rebuilding the parser.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
InvalidArgumentError: When this runtime cannot make ``algorithm``, or
|
|
89
|
+
when ``signature_format`` names no format. Both are caught here
|
|
90
|
+
rather than at the first webhook: a receiver that cannot
|
|
91
|
+
recompute a signature is misconfigured, and finding out when one
|
|
92
|
+
arrives means refusing a sender for this side's mistake.
|
|
93
|
+
"""
|
|
94
|
+
if algorithm not in hashlib.algorithms_available:
|
|
95
|
+
raise InvalidArgumentError(f'The signing algorithm "{algorithm}" is not available.')
|
|
96
|
+
|
|
97
|
+
self._algorithm = algorithm
|
|
98
|
+
self._signature_header_name = signature_header_name
|
|
99
|
+
self._event_header_name = event_header_name
|
|
100
|
+
self._id_header_name = id_header_name
|
|
101
|
+
self._timestamp_header_name = timestamp_header_name
|
|
102
|
+
self._signature_format = SignatureFormat.parse(signature_format)
|
|
103
|
+
self._timestamp_tolerance = timestamp_tolerance
|
|
104
|
+
self._clock = clock
|
|
105
|
+
|
|
106
|
+
@override
|
|
107
|
+
def get_request_matcher(self) -> RequestMatcherInterface:
|
|
108
|
+
"""Claim only a ``POST`` whose body reads as JSON, as the sender's side emits.
|
|
109
|
+
|
|
110
|
+
The body itself decides, not the ``Content-Type`` header: a sender that
|
|
111
|
+
announces nothing still sent JSON, and one that announces JSON and sent
|
|
112
|
+
something else did not.
|
|
113
|
+
"""
|
|
114
|
+
return ChainRequestMatcher([MethodRequestMatcher("POST"), IsJsonRequestMatcher()])
|
|
115
|
+
|
|
116
|
+
@override
|
|
117
|
+
async def do_parse(self, request: ServerRequestInterface, secret: str) -> RemoteEvent:
|
|
118
|
+
"""Check that the request is genuine and read the one event it carries.
|
|
119
|
+
|
|
120
|
+
In that order, and over one read of the body: nothing decodes it until a
|
|
121
|
+
signature over those exact bytes has verified, so a request that is not
|
|
122
|
+
the sender's is answered on its signature rather than on whatever its
|
|
123
|
+
body turned out to be — which is both what the sender needs told and the
|
|
124
|
+
answer that says least about what this side does with a payload.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
request: The request that arrived, already claimed by the matcher.
|
|
128
|
+
secret: What the signature entries are checked against.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The event, named by the payload's ``type`` key when the Standard
|
|
132
|
+
Webhooks entry authenticated the request — that scheme signs no
|
|
133
|
+
header, so a name sent as one cannot be trusted — and by the event
|
|
134
|
+
header otherwise, which the legacy scheme does sign.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
InvalidArgumentError: When ``secret`` is empty. That is this side's
|
|
138
|
+
configuration, not the sender's doing, so it surfaces as a
|
|
139
|
+
server error rather than as a refusal.
|
|
140
|
+
RejectWebhookError: When a required header is missing, no signature
|
|
141
|
+
entry verifies, the signed timestamp is outside the window, the
|
|
142
|
+
body is not a JSON object, or the payload names no event.
|
|
143
|
+
"""
|
|
144
|
+
if not secret:
|
|
145
|
+
raise InvalidArgumentError("A non-empty secret is required.")
|
|
146
|
+
|
|
147
|
+
body = await self.read_body(request)
|
|
148
|
+
|
|
149
|
+
required = [self._signature_header_name, self._id_header_name]
|
|
150
|
+
if self._signature_format is SignatureFormat.LEGACY:
|
|
151
|
+
required.append(self._event_header_name)
|
|
152
|
+
elif self._signature_format is SignatureFormat.STANDARD:
|
|
153
|
+
required.append(self._timestamp_header_name)
|
|
154
|
+
|
|
155
|
+
for name in required:
|
|
156
|
+
if not request.has_header(name):
|
|
157
|
+
raise RejectWebhookError(
|
|
158
|
+
_UNACCEPTABLE, f'Missing "{name}" HTTP request signature header.'
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
verified = self._verify_signature(request, body, secret)
|
|
162
|
+
payload = self._decode_json(body)
|
|
163
|
+
|
|
164
|
+
return RemoteEvent(
|
|
165
|
+
self._event_name(payload)
|
|
166
|
+
if verified is SignatureFormat.STANDARD
|
|
167
|
+
else request.get_header_line(self._event_header_name),
|
|
168
|
+
request.get_header_line(self._id_header_name),
|
|
169
|
+
payload,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _verify_signature(
|
|
173
|
+
self, request: ServerRequestInterface, body: bytes, secret: str
|
|
174
|
+
) -> SignatureFormat:
|
|
175
|
+
"""Return the scheme of the entry that authenticated the request.
|
|
176
|
+
|
|
177
|
+
The header may carry several whitespace-separated entries, one per
|
|
178
|
+
secret during a rotation. Each expectation is computed once, before its
|
|
179
|
+
loop, so the number of entries a request carries does not drive the CPU
|
|
180
|
+
cost of rejecting it; every comparison is :func:`hmac.compare_digest`,
|
|
181
|
+
so the time one takes says nothing about how much of it was right.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
request: The request that arrived.
|
|
185
|
+
body: Its body, whole — the exact bytes the sender signed.
|
|
186
|
+
secret: What the entries are checked against.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
RejectWebhookError: When no entry verifies, or when the one that did
|
|
190
|
+
covers a timestamp outside the window.
|
|
191
|
+
"""
|
|
192
|
+
candidates = request.get_header_line(self._signature_header_name).split()
|
|
193
|
+
message_id = request.get_header_line(self._id_header_name)
|
|
194
|
+
|
|
195
|
+
# The Standard Webhooks entry is tried first, so that a request carrying
|
|
196
|
+
# both schemes is replay-bounded whatever order its entries arrive in.
|
|
197
|
+
if self._signature_format is not SignatureFormat.LEGACY and request.has_header(
|
|
198
|
+
self._timestamp_header_name
|
|
199
|
+
):
|
|
200
|
+
timestamp = request.get_header_line(self._timestamp_header_name)
|
|
201
|
+
expected = sign(message_id, timestamp, body, secret).encode()
|
|
202
|
+
|
|
203
|
+
for candidate in candidates:
|
|
204
|
+
if hmac.compare_digest(expected, candidate.encode()):
|
|
205
|
+
self._check_timestamp(timestamp)
|
|
206
|
+
|
|
207
|
+
return SignatureFormat.STANDARD
|
|
208
|
+
|
|
209
|
+
if self._signature_format is not SignatureFormat.STANDARD and request.has_header(
|
|
210
|
+
self._event_header_name
|
|
211
|
+
):
|
|
212
|
+
event = request.get_header_line(self._event_header_name)
|
|
213
|
+
digest = hmac.new(
|
|
214
|
+
secret.encode(), event.encode() + message_id.encode() + body, self._algorithm
|
|
215
|
+
)
|
|
216
|
+
expected = f"{self._algorithm}={digest.hexdigest()}".encode()
|
|
217
|
+
|
|
218
|
+
for candidate in candidates:
|
|
219
|
+
if hmac.compare_digest(expected, candidate.encode()):
|
|
220
|
+
return SignatureFormat.LEGACY
|
|
221
|
+
|
|
222
|
+
raise RejectWebhookError(_UNACCEPTABLE, "Signature is wrong.")
|
|
223
|
+
|
|
224
|
+
def _check_timestamp(self, timestamp: str) -> None:
|
|
225
|
+
"""Refuse a signed timestamp outside the window around now.
|
|
226
|
+
|
|
227
|
+
Run only after a Standard Webhooks entry verified, because only then is
|
|
228
|
+
the timestamp known to be the sender's rather than an attacker's.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
timestamp: What the timestamp header carried, already signed.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
RejectWebhookError: When the timestamp does not read as an integer
|
|
235
|
+
this runtime will convert — a value that cannot be bounded is
|
|
236
|
+
not accepted on faith, and one too long to read is refused as
|
|
237
|
+
such rather than raised over — or lies further than the
|
|
238
|
+
tolerance from now.
|
|
239
|
+
"""
|
|
240
|
+
if self._timestamp_tolerance <= 0:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
now = int((self._clock or Clock.get()).now().timestamp())
|
|
244
|
+
|
|
245
|
+
if (
|
|
246
|
+
_NUMERIC.match(timestamp) is None
|
|
247
|
+
or abs(now - int(timestamp)) > self._timestamp_tolerance
|
|
248
|
+
):
|
|
249
|
+
raise RejectWebhookError(
|
|
250
|
+
_UNACCEPTABLE,
|
|
251
|
+
f'The "{self._timestamp_header_name}" HTTP request header is outside the '
|
|
252
|
+
"accepted window.",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _event_name(self, payload: dict[str, object]) -> str:
|
|
256
|
+
"""Return the event's name from the payload's ``type`` key.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
payload: The body, already decoded.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
RejectWebhookError: When the key is missing or not a string — the
|
|
263
|
+
scheme that signed this request puts the name there, so a
|
|
264
|
+
payload without one names no event.
|
|
265
|
+
"""
|
|
266
|
+
name = payload.get("type")
|
|
267
|
+
if not isinstance(name, str):
|
|
268
|
+
raise RejectWebhookError(
|
|
269
|
+
_UNACCEPTABLE, 'The payload is missing the "type" key holding the event name.'
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return name
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""What reads an incoming webhook into events, and answers its sender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, TypeVar, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from xtr_http_message_contracts import ResponseInterface
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
13
|
+
from xtr_remote_event import RemoteEvent
|
|
14
|
+
|
|
15
|
+
__all__ = ["RequestParserInterface"]
|
|
16
|
+
|
|
17
|
+
R = TypeVar("R", bound=ResponseInterface)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class RequestParserInterface(Protocol):
|
|
22
|
+
"""Reads an incoming webhook into events, and refuses the ones that are not genuine.
|
|
23
|
+
|
|
24
|
+
One parser speaks for one sender: it knows which requests are that sender's,
|
|
25
|
+
how that sender signs them, and what its payload means. Everything a sender
|
|
26
|
+
can get wrong — a signature that does not match, a timestamp outside the
|
|
27
|
+
window, a body that is not readable — is one refusal, so the endpoint in
|
|
28
|
+
front of it has a single thing to answer with.
|
|
29
|
+
|
|
30
|
+
The endpoint owns the response: an HTTP message is immutable, so a parser
|
|
31
|
+
cannot build one out of nothing and still be sure it carries what the
|
|
32
|
+
application puts on every response. It is handed one and gives back the one
|
|
33
|
+
it changed.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def parse(
|
|
37
|
+
self,
|
|
38
|
+
request: ServerRequestInterface,
|
|
39
|
+
secret: str,
|
|
40
|
+
) -> RemoteEvent | Sequence[RemoteEvent] | None:
|
|
41
|
+
"""Read a request into the events it carries.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
request: The request that arrived.
|
|
45
|
+
secret: What the sender's signature is checked against.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The event the request carried, every event when it carried
|
|
49
|
+
several, or ``None`` when it is one to accept and ignore — a
|
|
50
|
+
sender's keep-alive, an event kind this parser does not speak for.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
RejectWebhookError: When the request is refused, whatever the
|
|
54
|
+
reason: a signature that does not match, a replayed timestamp,
|
|
55
|
+
an unreadable payload.
|
|
56
|
+
"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
async def create_successful_response(self, request: ServerRequestInterface, response: R) -> R:
|
|
60
|
+
"""Fill in the answer to a webhook that was read.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
request: The request the endpoint received, for a parser whose
|
|
64
|
+
sender wants something of its own echoed back.
|
|
65
|
+
response: The response the endpoint built, to change and give back.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The response to send, which is ``response`` with the answer on it.
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
async def create_rejected_response(
|
|
73
|
+
self,
|
|
74
|
+
reason: str,
|
|
75
|
+
request: ServerRequestInterface,
|
|
76
|
+
response: R,
|
|
77
|
+
) -> R:
|
|
78
|
+
"""Fill in the answer to a webhook that was refused.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
reason: What to tell the sender. A sender is not debugged by the
|
|
82
|
+
receiver, so this is the refusal itself, never what gave it
|
|
83
|
+
away.
|
|
84
|
+
request: The request the endpoint received.
|
|
85
|
+
response: The response the endpoint built, to change and give back.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The response to send, which is ``response`` with the refusal on it.
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The endpoint every incoming webhook arrives at.
|
|
2
|
+
|
|
3
|
+
One endpoint for every subscriber: the type in the URL picks the parser that reads
|
|
4
|
+
the webhook and the secret it is checked against, so the events it carried reach a
|
|
5
|
+
worker and the sender is answered straight away.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .webhook_controller import WebhookController
|
|
11
|
+
|
|
12
|
+
__all__ = ["WebhookController"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Parsers already built, handed over by the type of webhook each reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import TYPE_CHECKING, final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
from xtr_service_contracts import ServiceProviderInterface
|
|
10
|
+
|
|
11
|
+
from xtr_webhook.exception import LogicError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Hashable, Mapping
|
|
15
|
+
|
|
16
|
+
from xtr_webhook.client import RequestParserInterface
|
|
17
|
+
|
|
18
|
+
__all__ = ["BuiltParsers"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@final
|
|
22
|
+
class BuiltParsers(ServiceProviderInterface["RequestParserInterface"]):
|
|
23
|
+
"""Hands over parsers from a mapping — what stands in where there is no container.
|
|
24
|
+
|
|
25
|
+
The controller asks for a parser by type and does not care where it came
|
|
26
|
+
from: a container's locator, which builds each parser with its own
|
|
27
|
+
dependencies the first time a webhook for it arrives, or a mapping of
|
|
28
|
+
parsers built by hand. This is the second of those, so the controller has
|
|
29
|
+
one thing to ask rather than two.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("_parsers",)
|
|
33
|
+
|
|
34
|
+
def __init__(self, parsers: Mapping[str, RequestParserInterface]) -> None:
|
|
35
|
+
"""Hand over ``parsers``, by the type of webhook each reads.
|
|
36
|
+
|
|
37
|
+
The mapping is copied: which parser speaks for a type is fixed once
|
|
38
|
+
the endpoint is built, so a caller still writing to its own dict
|
|
39
|
+
cannot change who reads a subscriber's webhooks halfway through a
|
|
40
|
+
deploy's life.
|
|
41
|
+
"""
|
|
42
|
+
self._parsers = dict(parsers)
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
async def get(self, name: Hashable, /) -> RequestParserInterface:
|
|
46
|
+
"""Return the parser reading webhooks of the type ``name``.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
LogicError: If there is none. The endpoint asks :meth:`has` first
|
|
50
|
+
and answers 404 itself, so reaching this means the two were
|
|
51
|
+
asked different questions — a mistake in the code, not a
|
|
52
|
+
webhook naming a type nobody subscribed to.
|
|
53
|
+
"""
|
|
54
|
+
parser = self._parsers.get(str(name))
|
|
55
|
+
if parser is None:
|
|
56
|
+
raise LogicError(f'Unable to find a parser for the webhook type "{name}".')
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
def has(self, name: Hashable, /) -> bool:
|
|
61
|
+
return str(name) in self._parsers
|
|
62
|
+
|
|
63
|
+
@override
|
|
64
|
+
def provided_services(self) -> Mapping[Hashable, type[object]]:
|
|
65
|
+
return MappingProxyType({name: type(parser) for name, parser in self._parsers.items()})
|