Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
251cb3d3d3 |
@@ -7,13 +7,6 @@ services, durably stores them, and delivers them to configured targets
|
|||||||
with retry support, logging, and observability. Category: infrastructure
|
with retry support, logging, and observability. Category: infrastructure
|
||||||
/ web service. License: MIT.
|
/ web service. License: MIT.
|
||||||
|
|
||||||
Each entrypoint is a version 4 UUID served at `/webhook/{uuid}`, and
|
|
||||||
that UUID is the entrypoint's only credential. webhooker does not use
|
|
||||||
shared secrets, HMAC signatures or token headers on the receiver, and
|
|
||||||
will not add them — read
|
|
||||||
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)
|
|
||||||
before deploying one.
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -1156,38 +1149,14 @@ backups at rest and restrict who can read them.
|
|||||||
|
|
||||||
## The entrypoint URL is the authentication secret
|
## The entrypoint URL is the authentication secret
|
||||||
|
|
||||||
**The entrypoint UUID is the credential, and it is the only one.**
|
The receiver verifies nothing about an inbound request. The UUID in an
|
||||||
webhooker mints a version 4 UUID per entrypoint and serves it at
|
entrypoint's URL is its credential: anyone who holds that URL can
|
||||||
`/webhook/{uuid}`. Possession of that URL is the authentication:
|
submit events to it, and the receiver checks nothing else about the
|
||||||
anyone who holds it can submit events to the entrypoint, and the
|
sender. Treat an entrypoint URL the way you would treat an API token.
|
||||||
receiver verifies nothing else about the sender.
|
|
||||||
|
|
||||||
There is no shared secret, no HMAC signature, no bearer token and no
|
There is no way to rotate the UUID in place. To retire one, delete the
|
||||||
second factor on the receiver, and none will be added. This was
|
entrypoint (or deactivate it, which answers `410`) and create a new
|
||||||
considered and rejected; the implementation that existed was removed
|
one, then point the sender at the new URL.
|
||||||
in [PR #279](https://git.eeqj.de/sneak/webhooker/pulls/279), closing
|
|
||||||
[issue #67](https://git.eeqj.de/sneak/webhooker/issues/67) and
|
|
||||||
[issue #241](https://git.eeqj.de/sneak/webhooker/issues/241). A
|
|
||||||
proposal to reintroduce any of them — including as "defence in depth"
|
|
||||||
alongside the UUID — is answered by this section. Inbound signature
|
|
||||||
headers a sender sends anyway (`X-Hub-Signature` and its
|
|
||||||
per-provider equivalents) are stored and forwarded as ordinary
|
|
||||||
headers; nothing checks them.
|
|
||||||
|
|
||||||
What that means for an operator:
|
|
||||||
|
|
||||||
- **The URL is a capability, so treat it as a secret.** Keep it out of
|
|
||||||
logs, ticket bodies, chat messages and screenshots. Anyone who reads
|
|
||||||
it anywhere can post events as that sender.
|
|
||||||
- **Rotating means minting a new entrypoint, not changing a key.**
|
|
||||||
There is no way to rotate the UUID in place. To retire one, delete
|
|
||||||
the entrypoint (or deactivate it, which answers `410`) and create a
|
|
||||||
new one, then point the sender at the new URL.
|
|
||||||
- **A sender that cannot be given a secret URL is a constraint on that
|
|
||||||
integration, not a reason to change this.** If a service only
|
|
||||||
supports signed payloads to a well-known URL, raise it as its own
|
|
||||||
problem — pick a different integration path, or accept that it
|
|
||||||
cannot be used. It is not grounds to reintroduce shared secrets.
|
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
@@ -1507,7 +1476,7 @@ events should be forwarded.
|
|||||||
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
||||||
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
||||||
| `config` | JSON text | Type-specific configuration |
|
| `config` | JSON text | Type-specific configuration |
|
||||||
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets |
|
| `max_retries` | integer | Maximum retry attempts for `http` and `slack` targets (0 = fire-and-forget, >0 = retries with backoff and a circuit breaker). Ignored by `database` and `log` targets |
|
||||||
| `max_queue_size` | integer | Stored and shown on the target's detail view, but not enforced anywhere yet: nothing in the delivery engine consults it. Queue depth is set by the two fixed 10,000-entry channels |
|
| `max_queue_size` | integer | Stored and shown on the target's detail view, but not enforced anywhere yet: nothing in the delivery engine consults it. Queue depth is set by the two fixed 10,000-entry channels |
|
||||||
|
|
||||||
**Relations:** Belongs to Webhook. Has many Deliveries.
|
**Relations:** Belongs to Webhook. Has many Deliveries.
|
||||||
@@ -1515,12 +1484,12 @@ events should be forwarded.
|
|||||||
**Target types:**
|
**Target types:**
|
||||||
|
|
||||||
- **`http`** — Forward the event as an HTTP POST to a configured URL.
|
- **`http`** — Forward the event as an HTTP POST to a configured URL.
|
||||||
`max_retries` is the total number of delivery attempts, not retries on
|
Behavior depends on `max_retries`: when `max_retries` is 0 (the
|
||||||
top of the first: when `max_retries` is 0 (the default), the target
|
default), the target operates in fire-and-forget mode — a single
|
||||||
operates in fire-and-forget mode, a single attempt with no retries and
|
attempt with no retries and no circuit breaker. When `max_retries` is
|
||||||
no circuit breaker; a value of N makes up to N attempts in all,
|
greater than 0, failed deliveries are retried with exponential backoff
|
||||||
retrying failed deliveries with exponential backoff and protecting them
|
up to `max_retries` attempts, protected by a per-target circuit
|
||||||
with a per-target circuit breaker.
|
breaker.
|
||||||
- **`slack`** — Post the event as a formatted message to a
|
- **`slack`** — Post the event as a formatted message to a
|
||||||
Slack-compatible incoming webhook URL (`webhookUrl` in `config`). It
|
Slack-compatible incoming webhook URL (`webhookUrl` in `config`). It
|
||||||
is built on the same HTTP core as `http` and honours `max_retries`
|
is built on the same HTTP core as `http` and honours `max_retries`
|
||||||
@@ -2898,10 +2867,6 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- **Webhook receiver:** the entrypoint UUID in the URL, and nothing
|
|
||||||
else. No shared secret, no HMAC signature, no token header, and none
|
|
||||||
will be added — see
|
|
||||||
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret).
|
|
||||||
- **Web UI:** Cookie-based sessions using gorilla/sessions with
|
- **Web UI:** Cookie-based sessions using gorilla/sessions with
|
||||||
encrypted cookies. Sessions are configured with HttpOnly, SameSite
|
encrypted cookies. Sessions are configured with HttpOnly, SameSite
|
||||||
Lax, and Secure whenever the request is on TLS — the flag follows the
|
Lax, and Secure whenever the request is on TLS — the flag follows the
|
||||||
@@ -2941,8 +2906,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
mode
|
mode
|
||||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||||
about an inbound request is verified; possession of the UUID
|
about an inbound request is verified; possession of the UUID
|
||||||
authorises submission, and no shared secret or signature check will
|
authorises submission (see
|
||||||
be added alongside it (see
|
|
||||||
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret))
|
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret))
|
||||||
- **SSRF prevention** for HTTP delivery targets: private/reserved IP
|
- **SSRF prevention** for HTTP delivery targets: private/reserved IP
|
||||||
ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked
|
ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked
|
||||||
|
|||||||
@@ -170,8 +170,7 @@ func TestDelivery_CrossOriginRedirectDropsOriginScopedHeaders(
|
|||||||
// Stripping must not fire within the configured origin, or every
|
// Stripping must not fire within the configured origin, or every
|
||||||
// destination that redirects its own path would lose its
|
// destination that redirects its own path would lose its
|
||||||
// credential and start answering 401 — and would lose the inbound
|
// credential and start answering 401 — and would lose the inbound
|
||||||
// signature header the target endpoint verifies. webhooker's own
|
// signature the receiver verifies.
|
||||||
// receiver verifies no signature; it only forwards the header.
|
|
||||||
func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders(
|
func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -300,80 +300,3 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
"the page must render to completion, not abort partway",
|
"the page must render to completion, not abort partway",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxRetriesHelp is the wording both target forms must carry. The
|
|
||||||
// delivery core makes max_retries attempts in total, not that many
|
|
||||||
// retries on top of a first try (a fresh delivery starts at attempt 1
|
|
||||||
// and target_http gives up once the attempt number reaches
|
|
||||||
// max_retries), and 0 is special-cased to a single fire-and-forget
|
|
||||||
// attempt with no circuit breaker.
|
|
||||||
const maxRetriesHelp = "This is the total number of delivery attempts, " +
|
|
||||||
"not retries on top of the first: a value of 3 makes three attempts " +
|
|
||||||
"in all. 0 means a single attempt with no retries and no circuit " +
|
|
||||||
"breaker."
|
|
||||||
|
|
||||||
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
|
||||||
// help text on both the create form (the add-target form on the webhook
|
|
||||||
// detail page) and the edit form, so the copy cannot drift back to
|
|
||||||
// calling the number a retry count.
|
|
||||||
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
var sess *session.Session
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
|
||||||
webhook.ID = testWebhookID
|
|
||||||
|
|
||||||
entrypoint := database.Entrypoint{Path: "abc123"}
|
|
||||||
entrypoint.ID = "ep-1"
|
|
||||||
|
|
||||||
createBody := renderPage(
|
|
||||||
t, h, sess, "source_detail.html", map[string]any{
|
|
||||||
dataKeyWebhook: webhook,
|
|
||||||
"Entrypoints": handlers.NewEntrypointViews(
|
|
||||||
[]database.Entrypoint{entrypoint},
|
|
||||||
),
|
|
||||||
"Targets": delivery.NewTargetViews(nil),
|
|
||||||
"Events": []database.Event{},
|
|
||||||
"BaseURL": "https://hooks.example.com",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, createBody, maxRetriesHelp,
|
|
||||||
"the add-target form must explain max_retries as total attempts",
|
|
||||||
)
|
|
||||||
|
|
||||||
// A slack target exercises the same max_retries field while needing
|
|
||||||
// only Config.URL from the edit template, so the test data stays
|
|
||||||
// minimal. The Target key mirrors the field names the template reads
|
|
||||||
// off the handler's view value.
|
|
||||||
editBody := renderPage(
|
|
||||||
t, h, sess, "target_edit.html", map[string]any{
|
|
||||||
dataKeyWebhook: webhook,
|
|
||||||
"Target": map[string]any{
|
|
||||||
"ID": "tg-1",
|
|
||||||
"Name": "t",
|
|
||||||
"Type": "slack",
|
|
||||||
"Active": true,
|
|
||||||
"MaxRetries": 3,
|
|
||||||
"Config": map[string]any{
|
|
||||||
"URL": "https://hooks.slack.com/services/x",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dataKeyError: "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, editBody, maxRetriesHelp,
|
|
||||||
"the target edit form must explain max_retries as total attempts",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -120,12 +120,9 @@
|
|||||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||||
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'http'">
|
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
||||||
<div class="flex gap-2 items-center">
|
<label class="text-sm text-gray-700">Max retries (0 = fire-and-forget):</label>
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'slack'">
|
<div x-show="targetType === 'slack'">
|
||||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="max_retries" class="label">Max retries</label>
|
<label for="max_retries" class="label">Max retries</label>
|
||||||
<input type="number" id="max_retries" name="max_retries" value="{{.Target.MaxRetries}}" min="0" max="20" class="input">
|
<input type="number" id="max_retries" name="max_retries" value="{{.Target.MaxRetries}}" min="0" max="20" class="input">
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">0 is fire-and-forget: one attempt, no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user