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,350 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-webhook
|
|
3
|
+
description: How to receive and send webhooks with xtr-webhook — verify an incoming request against a subscriber secret, bound it to a replay window, turn it into a remote event, and send signed webhooks through a message bus in the legacy or the Standard Webhooks format. Use when an application must accept a provider callback, verify an HMAC signature header, mount a webhook endpoint, write a parser for one sender, rotate a signing secret, choose between the legacy and Standard Webhooks signature, send signed webhooks to subscribers with retries, stop an outgoing request from reaching a private address or cloud metadata endpoint, test a parser against recorded payloads, or add WebhookBundle to an application.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-webhook
|
|
7
|
+
|
|
8
|
+
A webhook is an HTTP request from someone who is not a user, so there is no session to trust,
|
|
9
|
+
the body is the only thing signed, and a request captured once can be sent again tomorrow. This
|
|
10
|
+
package verifies one, bounds it to a window, and turns it into an
|
|
11
|
+
[xtr-remote-event](https://github.com/xterr/python-xtr) event so the code that handles it never
|
|
12
|
+
sees HTTP. Sending is the same problem from the other side.
|
|
13
|
+
|
|
14
|
+
## Quick reference
|
|
15
|
+
|
|
16
|
+
- Receive — `setup(app, kernel)` adds `POST /webhook/{type}` (route resource `webhook`, movable
|
|
17
|
+
in `<app>/config/routes/webhook.py`); the type picks the parser and the secret.
|
|
18
|
+
`WebhookController(parsers, secrets, bus)` is what answers.
|
|
19
|
+
- Parse — subclass `AbstractRequestParser` and fill in `get_request_matcher()` and
|
|
20
|
+
`async do_parse(request, secret)`. `RequestParser` is the built-in one. The matchers are
|
|
21
|
+
`xtr_http_foundation`'s, and `IsJsonRequestMatcher` reads the body rather than the
|
|
22
|
+
`Content-Type` header.
|
|
23
|
+
- Send — `await bus.dispatch(SendWebhookMessage(Subscriber(url, secret), event))`. Needs the
|
|
24
|
+
`client` extra, which brings xtr-http-client; `Transport` posts through any
|
|
25
|
+
`HttpClientInterface`.
|
|
26
|
+
- Formats — `SignatureFormat.LEGACY` (default), `STANDARD`, `TRANSITIONAL`. Members are their
|
|
27
|
+
strings. Only `STANDARD` signs the timestamp, so only it bounds replays.
|
|
28
|
+
- Guard — `NoPrivateNetworkConfig(enabled=True)` in the bundle, or
|
|
29
|
+
`NoPrivateNetworkHttpClient(client)` by hand. Opt-in, off by default.
|
|
30
|
+
- Answers — `202` read, `404` unknown type, `406` refused, `5xx` something behind the endpoint
|
|
31
|
+
failed.
|
|
32
|
+
- Test — `parser_cases`, `ParserCase`, `assert_parses` from `xtr_webhook.testing`.
|
|
33
|
+
- Every error derives from `WebhookError`.
|
|
34
|
+
|
|
35
|
+
## Receive a webhook
|
|
36
|
+
|
|
37
|
+
With `xtr-webhook[di]`, call `setup` — it adds the endpoint — and route each sender's type:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from fastapi import FastAPI
|
|
41
|
+
from xtr_dependency_injection import Kernel
|
|
42
|
+
from xtr_http_kernel import setup
|
|
43
|
+
|
|
44
|
+
app = FastAPI()
|
|
45
|
+
setup(app, Kernel("app"))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# app/config/webhook.py
|
|
50
|
+
from xtr_dependency_injection import configure, env
|
|
51
|
+
|
|
52
|
+
from xtr_webhook.bundle import RouteConfig, WebhookConfig
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@configure
|
|
56
|
+
def webhook() -> WebhookConfig:
|
|
57
|
+
return WebhookConfig(
|
|
58
|
+
routing={
|
|
59
|
+
"acme": RouteConfig(secret=env("WEBHOOK_SECRET")),
|
|
60
|
+
"stripe": RouteConfig(parser=StripeRequestParser, secret=env("STRIPE_SECRET")),
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- One endpoint serves every sender, so a new sender is configuration, not another route. Give
|
|
66
|
+
each subscriber the URL its type is served at, built from the route's name `"webhook"`.
|
|
67
|
+
- `routes("/hooks")` moves the endpoint to `/hooks/webhook/{type}`.
|
|
68
|
+
- A route with no `parser` uses the bundle's own `RequestParser`, configured by the surrounding
|
|
69
|
+
`WebhookConfig`. Two routes naming one parser class share one instance.
|
|
70
|
+
- Secrets go through `env(...)`. A placeholder is read only when the endpoint is built, and no
|
|
71
|
+
container report prints it.
|
|
72
|
+
- The sender is answered before the events are handled — each event goes out as a
|
|
73
|
+
`ConsumeRemoteEventMessage` on the bus — because a sender waiting on a handler times out and
|
|
74
|
+
sends the webhook again.
|
|
75
|
+
- Routing keys are kept verbatim, dashes included.
|
|
76
|
+
|
|
77
|
+
Without the container, build the controller by hand over a mapping of parsers, a mapping of
|
|
78
|
+
secrets and a bus. See the package README's Quick start for a runnable one.
|
|
79
|
+
|
|
80
|
+
## Write a parser
|
|
81
|
+
|
|
82
|
+
One parser speaks for one sender:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import hmac
|
|
86
|
+
|
|
87
|
+
from xtr_http_foundation import (
|
|
88
|
+
ChainRequestMatcher,
|
|
89
|
+
IsJsonRequestMatcher,
|
|
90
|
+
MethodRequestMatcher,
|
|
91
|
+
RequestMatcherInterface,
|
|
92
|
+
)
|
|
93
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
94
|
+
from xtr_remote_event import RemoteEvent
|
|
95
|
+
|
|
96
|
+
from xtr_webhook.client import AbstractRequestParser
|
|
97
|
+
from xtr_webhook.exception import RejectWebhookError
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AcmeRequestParser(AbstractRequestParser):
|
|
101
|
+
def get_request_matcher(self) -> RequestMatcherInterface:
|
|
102
|
+
return ChainRequestMatcher([MethodRequestMatcher("POST"), IsJsonRequestMatcher()])
|
|
103
|
+
|
|
104
|
+
async def do_parse(self, request: ServerRequestInterface, secret: str) -> RemoteEvent:
|
|
105
|
+
body = await self.read_body(request)
|
|
106
|
+
expected = hmac.new(secret.encode(), body, "sha256").hexdigest()
|
|
107
|
+
if not hmac.compare_digest(expected, request.get_header_line("X-Acme-Signature")):
|
|
108
|
+
raise RejectWebhookError(406, "Signature is wrong.")
|
|
109
|
+
payload = await self.read_json(request)
|
|
110
|
+
return RemoteEvent("acme", str(payload["id"]), payload)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The base class already does what every parser would otherwise rewrite: the matcher runs before
|
|
114
|
+
anything is read out of the request, a body that will not decode comes back as a `406` rather
|
|
115
|
+
than a `500`, and both answers are the ones the sender's side waits for.
|
|
116
|
+
|
|
117
|
+
- `await self.read_body(request)` returns every byte from the first one, rewinding the stream
|
|
118
|
+
where it can — a signature covers exactly what arrived. Reading a body is awaited.
|
|
119
|
+
- `await self.read_json(request)` decodes a JSON object. An array or a bare value is refused;
|
|
120
|
+
bytes that are not JSON raise, and `parse()` turns that into the same `406`.
|
|
121
|
+
- Return one event, a sequence, or `None` for a webhook to accept and ignore. `None` and an
|
|
122
|
+
empty sequence are answered as unparsable, not accepted.
|
|
123
|
+
- `RejectWebhookError(status_code=406, message="", headers=None)` carries the whole answer. The
|
|
124
|
+
message reaches the sender as plain text, so name the check that refused and nothing more.
|
|
125
|
+
- Override `create_successful_response` — an `async def`, so it can write a body — for a sender
|
|
126
|
+
that wants something echoed back; `create_rejected_response` is its counterpart for a refusal.
|
|
127
|
+
The response is handed in and given back modified; an HTTP message is immutable.
|
|
128
|
+
- Matchers come from `xtr_http_foundation`, not from this package: `MethodRequestMatcher(["POST",
|
|
129
|
+
"PUT"])` (one method or a sequence), `IsJsonRequestMatcher()`, `ChainRequestMatcher([...])`
|
|
130
|
+
(all must match), plus matchers over the path, host, port, scheme, a header, a query parameter
|
|
131
|
+
and the address it came from. `matches()` is awaited, so a rule may be about the body.
|
|
132
|
+
- `IsJsonRequestMatcher` reads the body, not the `Content-Type` header: a JSON body carrying no
|
|
133
|
+
header is claimed, and an `application/json` request whose body is not JSON is refused with
|
|
134
|
+
`Request does not match.` before the parser sees it.
|
|
135
|
+
|
|
136
|
+
## Send a webhook
|
|
137
|
+
|
|
138
|
+
With `xtr-webhook[client]` installed:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from xtr_remote_event import RemoteEvent
|
|
142
|
+
|
|
143
|
+
from xtr_webhook.messenger import SendWebhookMessage
|
|
144
|
+
from xtr_webhook.subscriber import Subscriber
|
|
145
|
+
|
|
146
|
+
_ = await bus.dispatch(
|
|
147
|
+
SendWebhookMessage(
|
|
148
|
+
Subscriber("https://example.com/hook", secret),
|
|
149
|
+
RemoteEvent("order.paid", "evt_1", {"total": 4200}),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- `Subscriber(url, secret)` is frozen and keeps the secret out of its `repr`. An empty secret
|
|
155
|
+
raises `InvalidArgumentError` — it would still sign, over the empty key.
|
|
156
|
+
- The chain is `HeadersConfigurator` → `JsonBodyConfigurator` → `HeaderSignatureConfigurator`,
|
|
157
|
+
and the signer runs last because it signs the other two's output. Replace or add a step for a
|
|
158
|
+
receiver that disagrees.
|
|
159
|
+
- `Transport(client, headers, body, signer)` posts through any `HttpClientInterface` — the
|
|
160
|
+
bundle's choice is the client `WebhookConfig.http_client` names, the default one when unset.
|
|
161
|
+
It asks for no redirect and passes no timeout: a delivery's timeout is the client's
|
|
162
|
+
(`default_options.timeout`, or a scoped client of its own).
|
|
163
|
+
- Any answer outside 200-299 raises `DeliveryError`. Nothing is caught, so the bus retries and,
|
|
164
|
+
once the attempts are spent, keeps the message. A redirect counts as refused and is never
|
|
165
|
+
followed. A delivery nothing answered raises the client's own `TransportError`, retried alike.
|
|
166
|
+
- Without the `client` extra no transport and no send handler are registered, and the dispatch
|
|
167
|
+
fails with the bus's no-handler error.
|
|
168
|
+
- The subscriber secret travels inside the queued message body, so the queue storage must be
|
|
169
|
+
trusted. The bus logs the message type, transport, id, handler name and stamps, never the body.
|
|
170
|
+
|
|
171
|
+
## Choose a signature format
|
|
172
|
+
|
|
173
|
+
| Format | Header value | Signed over | Event name in | Replay bounded |
|
|
174
|
+
| --- | --- | --- | --- | --- |
|
|
175
|
+
| `LEGACY` | `sha256=<hex>` | name + id + body | the `Webhook-Event` header | no |
|
|
176
|
+
| `STANDARD` | `v1,<base64>` | `<id>.<timestamp>.` + body | the payload's `type` key | yes |
|
|
177
|
+
| `TRANSITIONAL` | both, space separated | both | both | for the Standard entry only |
|
|
178
|
+
|
|
179
|
+
- Both ends must have made the same choice. `TRANSITIONAL` is the migration window, so they need
|
|
180
|
+
not change on the same day.
|
|
181
|
+
- `LEGACY` carries no signed timestamp, so `timestamp_tolerance` protects only `STANDARD` and
|
|
182
|
+
`TRANSITIONAL`. Under `LEGACY` the timestamp header is sent but nothing signs it.
|
|
183
|
+
- Under `STANDARD` the `Webhook-Event` header is not sent at all; the name goes in the payload,
|
|
184
|
+
because that scheme's signature covers no header.
|
|
185
|
+
- A rotation works because the signature header may carry several whitespace-separated entries
|
|
186
|
+
and any one that verifies is enough.
|
|
187
|
+
- `SignatureFormat.parse("standard")` is the single door for a string; anything else raises
|
|
188
|
+
`InvalidArgumentError`.
|
|
189
|
+
|
|
190
|
+
Depth — the exact bytes signed, the `whsec_` secret encoding, the rotation sequence and the
|
|
191
|
+
literal requests each format emits — is in
|
|
192
|
+
[references/signature-formats.md](references/signature-formats.md).
|
|
193
|
+
|
|
194
|
+
## Guard outbound requests
|
|
195
|
+
|
|
196
|
+
Opt-in, off by default. A subscriber URL is data, so without it a webhook is a way to make this
|
|
197
|
+
process fetch an address only this process can see.
|
|
198
|
+
|
|
199
|
+
In the bundle, around the client `http_client` names (the default one when unset):
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from xtr_webhook.bundle import NoPrivateNetworkConfig, WebhookConfig
|
|
203
|
+
|
|
204
|
+
WebhookConfig(no_private_network=NoPrivateNetworkConfig(enabled=True, allow_list=("10.1.2.3",)))
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
By hand, wrap the client the transport is given:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from xtr_http_client import HttpClient, NoPrivateNetworkHttpClient
|
|
211
|
+
|
|
212
|
+
from xtr_webhook.server import (
|
|
213
|
+
HeadersConfigurator,
|
|
214
|
+
HeaderSignatureConfigurator,
|
|
215
|
+
JsonBodyConfigurator,
|
|
216
|
+
JsonPayloadSerializer,
|
|
217
|
+
Transport,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
transport = Transport(
|
|
221
|
+
NoPrivateNetworkHttpClient(HttpClient.create()),
|
|
222
|
+
HeadersConfigurator(),
|
|
223
|
+
JsonBodyConfigurator(JsonPayloadSerializer()),
|
|
224
|
+
HeaderSignatureConfigurator(),
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- The check runs on what the host *resolves to*, and the request is then pinned to the address
|
|
229
|
+
that was checked, so DNS rebinding connects to what was vetted or not at all.
|
|
230
|
+
- With no `subnets` it refuses the networks only this one can reach — loopback, private,
|
|
231
|
+
link-local, carrier-grade NAT, unique-local, multicast, reserved, unspecified.
|
|
232
|
+
- `subnets` replaces that set; `allow_list` opens one receiver without opening its network. Both
|
|
233
|
+
take IP networks or single addresses, never hostnames.
|
|
234
|
+
- A refusal is the client's `TransportError`, before anything is sent:
|
|
235
|
+
`Host "<host>" is blocked for "<url>".` or `IP "<ip>" is blocked for "<url>".`. Retrying
|
|
236
|
+
changes nothing.
|
|
237
|
+
- `subnets` or a non-empty `allow_list` with `enabled=False` is refused. Turning the guard on
|
|
238
|
+
without xtr-http-client installed raises `LogicError` when the container is built.
|
|
239
|
+
|
|
240
|
+
## Testing
|
|
241
|
+
|
|
242
|
+
`xtr_webhook.testing` turns a directory of recorded payloads into tests. Before using it, install
|
|
243
|
+
the `testing` extra — the helpers build requests with xtr-http-message:
|
|
244
|
+
`uv add --dev "xtr-webhook[testing]"`.
|
|
245
|
+
|
|
246
|
+
Each `*.json` fixture has a sibling `*.py` defining a module-level `EXPECTED`, the event or list of
|
|
247
|
+
events it must parse to:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from pathlib import Path
|
|
251
|
+
|
|
252
|
+
import pytest
|
|
253
|
+
|
|
254
|
+
from xtr_webhook.testing import ParserCase, assert_parses, parser_cases
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.anyio
|
|
258
|
+
@pytest.mark.parametrize(
|
|
259
|
+
"case", parser_cases(Path(__file__).parent / "fixtures"), ids=lambda case: case.id
|
|
260
|
+
)
|
|
261
|
+
async def test_the_parser_reads_every_recorded_payload(case: ParserCase) -> None:
|
|
262
|
+
await assert_parses(AcmeRequestParser(), case, secret="the-secret")
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- `parser_cases(fixtures_dir, extension="json")` walks recursively and returns a
|
|
266
|
+
`ParserCase(id, payload, expected)` per fixture, sorted by id, so dropping a payload in the
|
|
267
|
+
directory adds a test. A fixture with no sibling raises `InvalidArgumentError` naming both
|
|
268
|
+
paths.
|
|
269
|
+
- `assert_parses(parser, case, *, secret="", headers=None, method="POST")` builds the request
|
|
270
|
+
with `Content-Type: application/json` plus `headers`, parses it, and asserts the events match.
|
|
271
|
+
Pass the signature headers a real delivery carries through `headers`.
|
|
272
|
+
- Nothing there imports pytest, so the helpers work under any runner.
|
|
273
|
+
- For the whole endpoint, build a `WebhookController` over a mapping of parsers and a recording
|
|
274
|
+
bus and call `index_action(type, request, Response())`; assert the status and what reached the
|
|
275
|
+
bus.
|
|
276
|
+
- For the sending side, give `Transport` a `MockHttpClient` from xtr-http-client over a
|
|
277
|
+
`MockResponse`, and read what was sent off the response: `get_request_url()`,
|
|
278
|
+
`get_request_options()["headers"]` and `["body"]`. `MockResponse(info={"http_code": 500})`
|
|
279
|
+
scripts a refusal. In an application, `HttpClientConfig(mock_response_factory=True)` makes the
|
|
280
|
+
`MockHttpClient` service the transport; script it with `set_response_factory`.
|
|
281
|
+
- Freeze time by passing a `MockClock` as `clock` to `HeadersConfigurator` or `RequestParser`, so
|
|
282
|
+
a timestamp and its window are decided rather than raced.
|
|
283
|
+
|
|
284
|
+
## Use in an application
|
|
285
|
+
|
|
286
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
|
|
287
|
+
`WebhookBundle`, writes a starting `<app>/config/webhook.py` with one route,
|
|
288
|
+
`<app>/config/routes/webhook.py`, and `WEBHOOK_SECRET` (commented out) in `.env`. It prints the
|
|
289
|
+
steps it cannot make — calling `setup` and naming your own types.
|
|
290
|
+
|
|
291
|
+
1. **Install** — `uv add "xtr-webhook[client]"`; add `di` for the bundle. The endpoint comes
|
|
292
|
+
with the package; `client` sends, bringing xtr-http-client and its bundle.
|
|
293
|
+
2. **Activate** — `WebhookBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`
|
|
294
|
+
(`from xtr_webhook.bundle import WebhookBundle`).
|
|
295
|
+
3. **Brings along** — the remote-event bundle, the messenger bundle through it, and the HTTP
|
|
296
|
+
kernel's bundle, always; the HTTP client bundle when xtr-http-client is installed.
|
|
297
|
+
4. **Configure** — needed to receive anything: with no routing the endpoint knows no type and
|
|
298
|
+
answers every webhook `404`. `<app>/config/webhook.py`, a `@configure` function returning
|
|
299
|
+
`WebhookConfig` — `routing`, `signature_format`, `timestamp_tolerance`, `signing_algorithm`,
|
|
300
|
+
the four header names, `http_client`, `no_private_network`. A delivery's timeout is set on
|
|
301
|
+
the client, in `<app>/config/http_client.py`.
|
|
302
|
+
5. **Environment** — `WEBHOOK_SECRET`, the secret the written route verifies against, read when
|
|
303
|
+
the endpoint is built; one per sender in practice. **Ignore** — nothing.
|
|
304
|
+
6. **Load** — call `setup(app, kernel)`; it adds `POST /webhook/{type}`, arranged by
|
|
305
|
+
`<app>/config/routes/webhook.py` (`routes.import_("webhook").prefix("/hooks")` moves it).
|
|
306
|
+
7. **Use** — declare a consumer for each type with
|
|
307
|
+
`@as_remote_event_consumer("<type>")`, and inject the bundle's parser where you need it with
|
|
308
|
+
`Annotated[RequestParserInterface, Target("webhook")]`.
|
|
309
|
+
8. **Check** — `debug:bundles` shows `webhook` and `remote_event` as `listed` and `active`;
|
|
310
|
+
`debug:config webhook` shows the resolved routing, header names and signature format.
|
|
311
|
+
9. **Remove** — drop the `BUNDLES` entry, delete `<app>/config/webhook.py` and
|
|
312
|
+
`<app>/config/routes/webhook.py`, then `uv remove xtr-webhook`.
|
|
313
|
+
|
|
314
|
+
## Errors
|
|
315
|
+
|
|
316
|
+
Everything derives from `WebhookError` and carries typed attributes.
|
|
317
|
+
|
|
318
|
+
| Error | Raised when | Carries |
|
|
319
|
+
| --- | --- | --- |
|
|
320
|
+
| `InvalidArgumentError` | a value cannot be used — an unknown format, an empty secret, an unavailable algorithm; also a `ValueError` | — |
|
|
321
|
+
| `LogicError` | the library was used in a way that cannot work — a chain that wrote no body, a signer before the timestamp; also a `RuntimeError` | — |
|
|
322
|
+
| `RejectWebhookError` | an incoming webhook is refused; it carries the answer the sender gets | `.status_code`, `.headers` |
|
|
323
|
+
| `WebhookRuntimeError` | something a webhook depends on failed while it was handled | — |
|
|
324
|
+
| `DeliveryError` | a receiver answered outside 200-299 | `.status_code`, `.url` |
|
|
325
|
+
|
|
326
|
+
An `InvalidArgumentError` is configuration to fix, never an answer to a request. A
|
|
327
|
+
`RejectWebhookError` is the answer to a request and nothing else. A delivery nothing answered,
|
|
328
|
+
and one the private-network guard refused, raise the client's `TransportError`
|
|
329
|
+
(xtr-http-client-contracts) unwrapped.
|
|
330
|
+
|
|
331
|
+
## Do not
|
|
332
|
+
|
|
333
|
+
- Do not compare a signature with `==`. Use `hmac.compare_digest`, so the time a refusal takes
|
|
334
|
+
says nothing about how much of it was right.
|
|
335
|
+
- Do not put the reason a check failed into a refusal message. Name the check; a sender is not
|
|
336
|
+
debugged by its receiver.
|
|
337
|
+
- Do not read the body with anything but `read_body`/`read_json`, and do not re-encode a payload
|
|
338
|
+
before verifying — the signature covers the bytes that arrived.
|
|
339
|
+
- Do not rely on `timestamp_tolerance` under `LEGACY`; that format signs no timestamp. Use
|
|
340
|
+
`STANDARD` where replay protection matters.
|
|
341
|
+
- Do not trust the `Webhook-Event` header under `STANDARD`. That signature covers no header; the
|
|
342
|
+
name is in the payload's `type`.
|
|
343
|
+
- Do not handle a webhook before answering its sender, and do not swallow a `DeliveryError` in a
|
|
344
|
+
handler — the error is what makes the bus retry.
|
|
345
|
+
- Do not follow redirects when sending, and do not bound a delivery anywhere but on its client —
|
|
346
|
+
set the timeout in the HTTP client configuration, not around `Transport`.
|
|
347
|
+
- Do not run with an empty route secret in production, and do not write a secret as a literal in
|
|
348
|
+
configuration — pass `env("...")`.
|
|
349
|
+
- Do not queue a webhook through storage you do not trust: the subscriber secret is in the
|
|
350
|
+
message body.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Signature formats, byte for byte
|
|
2
|
+
|
|
3
|
+
What each format signs, what it puts on the wire, and how to move between them. Both ends of a
|
|
4
|
+
webhook must have made the same choice, which is why `SignatureFormat` is taken by the sender's
|
|
5
|
+
configurators and by the receiver's parser alike.
|
|
6
|
+
|
|
7
|
+
`SignatureFormat` is a string enum, so the members *are* their strings: a configuration file, an
|
|
8
|
+
option mapping or an environment variable carries `"legacy"`, `"standard"` or `"transitional"`
|
|
9
|
+
and is understood with no conversion step. `SignatureFormat.parse(value)` is the single door for
|
|
10
|
+
anything arriving from outside, and raises `InvalidArgumentError` with
|
|
11
|
+
`"<value>" is not a signature format; use "legacy", "standard" or "transitional".`
|
|
12
|
+
|
|
13
|
+
## Legacy
|
|
14
|
+
|
|
15
|
+
The historical format, and the default, so an existing pair of endpoints keeps working.
|
|
16
|
+
|
|
17
|
+
Signed bytes are the three run together with nothing between them:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
event.name + event.id + body
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The header value names the digest, so a receiver knows what to recompute:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Webhook-Signature: sha256=<hexdigest>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Recomputing it by hand:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import hmac
|
|
33
|
+
|
|
34
|
+
expected = (
|
|
35
|
+
"sha256="
|
|
36
|
+
+ hmac.new(
|
|
37
|
+
secret.encode(), event_name.encode() + event_id.encode() + body, "sha256"
|
|
38
|
+
).hexdigest()
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A full delivery:
|
|
43
|
+
|
|
44
|
+
```http
|
|
45
|
+
POST /hook HTTP/1.1
|
|
46
|
+
Content-Type: application/json
|
|
47
|
+
Webhook-Id: evt_1
|
|
48
|
+
Webhook-Timestamp: 1767225600
|
|
49
|
+
Webhook-Event: order.paid
|
|
50
|
+
Webhook-Signature: sha256=0f8c...
|
|
51
|
+
|
|
52
|
+
{"total":4200}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`signing_algorithm` picks the digest; anything `hashlib` cannot make is refused where it is
|
|
56
|
+
configured rather than at the first delivery. The event name is in `Webhook-Event`, which this
|
|
57
|
+
signature covers, so the receiver may act on it.
|
|
58
|
+
|
|
59
|
+
**No timestamp is signed.** `Webhook-Timestamp` is sent, but nothing covers it, so refusing on
|
|
60
|
+
it would mean trusting a value an attacker sets. There is no replay protection in this format.
|
|
61
|
+
|
|
62
|
+
## Standard Webhooks
|
|
63
|
+
|
|
64
|
+
The [standardwebhooks.com](https://www.standardwebhooks.com/) scheme. Always HMAC-SHA256 — the
|
|
65
|
+
scheme fixes the digest, so `signing_algorithm` does not apply to it.
|
|
66
|
+
|
|
67
|
+
Signed bytes, with the id and the timestamp dot-separated ahead of the body:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
f"{message_id}.{timestamp}.".encode() + body
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The header value is the version marker and the base64 of the digest:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Webhook-Signature: v1,<base64>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A full delivery. There is no `Webhook-Event` header at all, because this signature covers no
|
|
80
|
+
header, so a name sent as one is a value the receiver cannot trust. The name goes in the
|
|
81
|
+
payload's `type` key instead:
|
|
82
|
+
|
|
83
|
+
```http
|
|
84
|
+
POST /hook HTTP/1.1
|
|
85
|
+
Content-Type: application/json
|
|
86
|
+
Webhook-Id: evt_1
|
|
87
|
+
Webhook-Timestamp: 1767225600
|
|
88
|
+
Webhook-Signature: v1,dGhlLXNpZ25hdHVyZQ==
|
|
89
|
+
|
|
90
|
+
{"total":4200,"type":"order.paid"}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A payload that already carries `type` keeps what it says: the application knows its own events
|
|
94
|
+
better than their source name does. The payload is copied before the key is added, so sending a
|
|
95
|
+
webhook never edits the dict the caller is still holding. A payload whose `type` is missing or
|
|
96
|
+
not a string names no event and is refused with
|
|
97
|
+
`The payload is missing the "type" key holding the event name.`
|
|
98
|
+
|
|
99
|
+
### The secret's encoding
|
|
100
|
+
|
|
101
|
+
The scheme writes a secret as base64 behind a `whsec_` prefix, so the key need not be text:
|
|
102
|
+
|
|
103
|
+
- `whsec_<base64>` — the key is those bytes, decoded.
|
|
104
|
+
- Anything else, including a `whsec_` prefix over something that is not valid base64, is its own
|
|
105
|
+
key. A receiver configured with a plain string must keep working, and a secret that happens to
|
|
106
|
+
start with those six characters is not a mistake to raise over.
|
|
107
|
+
- A prefix over nothing decodes to an empty key, which is no secret at all, so that falls back to
|
|
108
|
+
the secret itself too.
|
|
109
|
+
|
|
110
|
+
### The replay window
|
|
111
|
+
|
|
112
|
+
Because the timestamp is signed, it can be held to a window around now: `timestamp_tolerance`,
|
|
113
|
+
300 seconds by default, either side. Zero or less turns the bound off, for when clocks that
|
|
114
|
+
cannot be kept close enough matter more than a replayed capture.
|
|
115
|
+
|
|
116
|
+
The check runs only after a Standard Webhooks entry verified, which is when the timestamp is
|
|
117
|
+
known to be the sender's rather than an attacker's. A timestamp that does not read as an integer
|
|
118
|
+
is refused as well — a value that cannot be bounded is not accepted on faith. The refusal is
|
|
119
|
+
`The "Webhook-Timestamp" HTTP request header is outside the accepted window.`
|
|
120
|
+
|
|
121
|
+
## Transitional
|
|
122
|
+
|
|
123
|
+
Both signatures, space separated in one header, with the event name in both the header and the
|
|
124
|
+
payload:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
Webhook-Signature: sha256=0f8c... v1,dGhlLXNpZ25hdHVyZQ==
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A receiver on this format accepts either entry, which is what lets the two ends migrate on
|
|
131
|
+
different days. Only the Standard Webhooks entry bounds replays, and a request carrying both is
|
|
132
|
+
tried Standard-first, so it is replay-bounded whatever order its entries arrive in.
|
|
133
|
+
|
|
134
|
+
### Migrating
|
|
135
|
+
|
|
136
|
+
Legacy to Standard, with nothing refused in between:
|
|
137
|
+
|
|
138
|
+
1. Set the **receiver** to `transitional`. It now accepts both, and still accepts everything the
|
|
139
|
+
sender sends today.
|
|
140
|
+
2. Set the **sender** to `transitional`. Every delivery now carries both entries.
|
|
141
|
+
3. Set the **receiver** to `standard`. Legacy entries stop being accepted; the sender is still
|
|
142
|
+
sending the Standard one too.
|
|
143
|
+
4. Set the **sender** to `standard`. The legacy entry stops being emitted, and the
|
|
144
|
+
`Webhook-Event` header with it — so anything downstream reading that header must already read
|
|
145
|
+
the payload's `type`.
|
|
146
|
+
|
|
147
|
+
Backwards is the same four steps read upwards.
|
|
148
|
+
|
|
149
|
+
## Rotating a secret
|
|
150
|
+
|
|
151
|
+
The signature header may carry several whitespace-separated entries, one per secret in play, and
|
|
152
|
+
**any one that verifies is enough**. So:
|
|
153
|
+
|
|
154
|
+
1. The sender signs with both the old and the new secret, sending both entries.
|
|
155
|
+
2. The receiver's configured secret is changed to the new one.
|
|
156
|
+
3. The sender drops the old secret.
|
|
157
|
+
|
|
158
|
+
Each expectation is computed once, before its loop, so the number of entries a request carries
|
|
159
|
+
does not drive the CPU cost of rejecting it, and every comparison is `hmac.compare_digest`.
|
|
160
|
+
|
|
161
|
+
## Header names
|
|
162
|
+
|
|
163
|
+
All four are configurable, on `WebhookConfig` and on each configurator and the parser:
|
|
164
|
+
|
|
165
|
+
| Option | Default | Read by |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| `id_header_name` | `Webhook-Id` | both signatures |
|
|
168
|
+
| `timestamp_header_name` | `Webhook-Timestamp` | the Standard signature |
|
|
169
|
+
| `event_header_name` | `Webhook-Event` | the legacy signature |
|
|
170
|
+
| `signature_header_name` | `Webhook-Signature` | the entries themselves |
|
|
171
|
+
|
|
172
|
+
`HeadersConfigurator` writes the timestamp under the same name `HeaderSignatureConfigurator`
|
|
173
|
+
reads it back from, so changing one means changing both. The signer runs last; a format that
|
|
174
|
+
signs the timestamp, running before whatever wrote it, raises `LogicError` naming the header.
|
|
175
|
+
|
|
176
|
+
## Which headers must be present
|
|
177
|
+
|
|
178
|
+
`RequestParser` refuses a request missing a header its format needs, with
|
|
179
|
+
`Missing "<name>" HTTP request signature header.`
|
|
180
|
+
|
|
181
|
+
| Format | Required |
|
|
182
|
+
| --- | --- |
|
|
183
|
+
| `LEGACY` | signature, id, event |
|
|
184
|
+
| `STANDARD` | signature, id, timestamp |
|
|
185
|
+
| `TRANSITIONAL` | signature, id |
|
|
186
|
+
|
|
187
|
+
Under `TRANSITIONAL` only the two shared headers are required, because which entry will verify
|
|
188
|
+
is not known yet; whichever scheme's own headers are absent simply cannot be the one that
|
|
189
|
+
authenticates the request.
|
xtr_webhook/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Webhooks received as events, and sent signed.
|
|
2
|
+
|
|
3
|
+
An incoming request is checked against the secret of the subscriber it claims to come from,
|
|
4
|
+
bounded to a window so a captured one cannot be replayed, and turned into an event. An
|
|
5
|
+
outgoing one is signed the same way and sent through a message bus, so a refusal is retried.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Where the endpoint receiving webhooks lives, and what its route is called.
|
|
2
|
+
|
|
3
|
+
The path and the name are written once, here, rather than at the one place that
|
|
4
|
+
mounts them: an application builds a URL to give a sender by the route's name, and
|
|
5
|
+
what it hands out has to be the path actually served. A sender that was told a URL
|
|
6
|
+
keeps sending to it long after the deploy that changed it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
__all__ = ["ROUTE_NAME", "WEBHOOK_PATH"]
|
|
14
|
+
|
|
15
|
+
#: Where a webhook arrives. The segment is the type the webhook is for — which
|
|
16
|
+
#: subscriber sent it, and therefore which parser reads it and which secret it
|
|
17
|
+
#: is checked against.
|
|
18
|
+
WEBHOOK_PATH: Final = "/webhook/{type}"
|
|
19
|
+
|
|
20
|
+
#: What the route is called, so an application can build the URL it gives a
|
|
21
|
+
#: sender rather than writing the path out again.
|
|
22
|
+
ROUTE_NAME: Final = "webhook"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The Standard Webhooks signature scheme, shared by the sender and the receiver.
|
|
2
|
+
|
|
3
|
+
One module rather than one on each side, because the two must agree byte for byte:
|
|
4
|
+
https://www.standardwebhooks.com/. It only makes a signature. Checking one against the
|
|
5
|
+
header a request carries is the receiver's job, and it compares with
|
|
6
|
+
:func:`hmac.compare_digest`, never ``==``, so the time the comparison takes says nothing
|
|
7
|
+
about how much of the signature was right.
|
|
8
|
+
|
|
9
|
+
Private to this library: the scheme is an implementation detail of the configurator that
|
|
10
|
+
emits it and the parser that reads it, and nothing outside has a reason to produce one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import binascii
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
from typing import Final
|
|
20
|
+
|
|
21
|
+
__all__ = ["PREFIX", "sign"]
|
|
22
|
+
|
|
23
|
+
PREFIX: Final = "v1,"
|
|
24
|
+
"""The scheme's version marker, which every signature it makes starts with.
|
|
25
|
+
|
|
26
|
+
A receiver reads it to tell this scheme's entry apart from the others a header may
|
|
27
|
+
carry, and a later version of the scheme changes this one constant.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_WHSEC_PREFIX: Final = "whsec_"
|
|
31
|
+
"""How the scheme spells a secret whose key is base64 rather than the text itself."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sign(message_id: str, timestamp: str, body: bytes, secret: str) -> str:
|
|
35
|
+
"""Return the signature for one webhook, prefixed with the scheme's version.
|
|
36
|
+
|
|
37
|
+
The id, the timestamp and the body are signed together, each separated by a dot,
|
|
38
|
+
so neither moving a signed body to another delivery nor replaying yesterday's
|
|
39
|
+
delivery keeps the signature valid.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
message_id: The webhook's id, as the ``Webhook-Id`` header carries it.
|
|
43
|
+
timestamp: Seconds since the epoch, as the ``Webhook-Timestamp`` header
|
|
44
|
+
carries it — a string, so the signature covers exactly what is sent.
|
|
45
|
+
body: The bytes of the body, signed as they go out: re-encoding a payload
|
|
46
|
+
can reorder keys or respace them, and the receiver signs what arrived.
|
|
47
|
+
secret: The subscriber's secret.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
``"v1,"`` followed by the base64 of the HMAC-SHA256 digest.
|
|
51
|
+
"""
|
|
52
|
+
signed = hmac.new(_key(secret), f"{message_id}.{timestamp}.".encode() + body, hashlib.sha256)
|
|
53
|
+
|
|
54
|
+
return PREFIX + base64.b64encode(signed.digest()).decode()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _key(secret: str) -> bytes:
|
|
58
|
+
"""Return the key ``secret`` stands for.
|
|
59
|
+
|
|
60
|
+
The scheme writes a secret as base64 behind a ``whsec_`` prefix, so the key is
|
|
61
|
+
bytes that need not be text. A secret written any other way is its own key: a
|
|
62
|
+
receiver configured with a plain string must keep working, and a prefix over
|
|
63
|
+
something that is not base64 is a secret that happens to start with those six
|
|
64
|
+
characters, not a mistake to raise over. A prefix over nothing decodes to an empty
|
|
65
|
+
key, and an empty HMAC key is no secret at all, so that case falls back to the
|
|
66
|
+
secret itself as well.
|
|
67
|
+
"""
|
|
68
|
+
if not secret.startswith(_WHSEC_PREFIX):
|
|
69
|
+
return secret.encode()
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
decoded = base64.b64decode(secret[len(_WHSEC_PREFIX) :], validate=True)
|
|
73
|
+
except binascii.Error:
|
|
74
|
+
return secret.encode()
|
|
75
|
+
|
|
76
|
+
return decoded or secret.encode()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The xtr-dependency-injection bundle for xtr-webhook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .no_private_network_config import NoPrivateNetworkConfig
|
|
6
|
+
from .route_config import RouteConfig
|
|
7
|
+
from .webhook_bundle import WebhookBundle
|
|
8
|
+
from .webhook_config import WebhookConfig
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"NoPrivateNetworkConfig",
|
|
12
|
+
"RouteConfig",
|
|
13
|
+
"WebhookBundle",
|
|
14
|
+
"WebhookConfig",
|
|
15
|
+
]
|