Author SHA1 Message Date
clawbot e5b68b2df7 Say how to allow a refused private target address (closes #398)
check / check (push) Waiting to run
Adding or editing an http or slack target whose address is private or
reserved was refused with no hint that the refusal is deliberate or
that it can be lifted. The refusal now adds that such addresses are
refused by default and that the server's ALLOWED_EGRESS_CIDRS setting
allows named networks, naming the README section "Allowing egress to
your own network". Metadata refusals do not get it: link-local and the
other unconditional metadata addresses cannot be opened, and Azure's
WireServer, which listing does open, serves VM credentials.

The delivery package refuses WireServer with its own error and exports
the private-or-reserved one as ErrBlockedIP, so the handler can tell
them apart.

Model: opus-5-5
2026-10-01 22:06:38 +00:00
49 changed files with 375 additions and 351 deletions
+38 -53
View File
@@ -7,7 +7,7 @@ services, durably stores them, and delivers them to configured targets
with retry support, logging, and observability. Category: infrastructure
/ web service. License: MIT.
Each entrypoint is a version 4 UUID served at `/h/{uuid}`, and
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
@@ -157,21 +157,6 @@ public cloud metadata addresses: currently only `168.63.129.16`, Azure's
WireServer, which serves an Azure VM its credentials. Because it is a
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
That is all the default blocklist covers: the IPv4 private and reserved
ranges; of IPv6, only loopback (`::1`), unique local addresses
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
addresses. A public address belongs on the default blocklist only if it
hands credentials, user data or bootstrap material to whatever can reach
it, without the caller presenting anything. A provider's other public
addresses are not refused. IBM Cloud, for example, serves its package
mirrors, time servers and object storage on `161.26.0.0/16`, and the
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
range hands out credentials that way: the token service among those
endpoints issues a token only in exchange for something the caller
presents, such as an API key. Reaching these services can be a
legitimate delivery, and every cloud has some, so a partial list would
promise coverage it does not give.
That default is also inconvenient for the thing webhooker is mostly
for: taking a public webhook and forwarding it to something on your own
network. A container on the same Docker network, a box on `10.x`, a
@@ -1193,7 +1178,7 @@ backups at rest and restrict who can read them.
**The entrypoint UUID is the credential, and it is the only one.**
webhooker mints a version 4 UUID per entrypoint and serves it at
`/h/{uuid}`. Possession of that URL is the authentication:
`/webhook/{uuid}`. Possession of that URL is the authentication:
anyone who holds it can submit events to the entrypoint, and the
receiver verifies nothing else about the sender.
@@ -1527,7 +1512,7 @@ the full request and creates an Event.
| -------------- | ------- | ----------- |
| `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook |
| `path` | string | Unique bare UUID, generated at creation. The `/h/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
| `path` | string | Unique bare UUID, generated at creation. The `/webhook/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
| `description` | string | Optional description |
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
@@ -1909,7 +1894,7 @@ runtime, though CGO is required at build time due to the transitive
```
External Service
│
│ POST /h/{uuid}
│ POST /webhook/{uuid}
▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ chi Router │────►│ Middleware │────►│ Webhook │
@@ -2135,7 +2120,7 @@ The middleware records three more on the same registry:
Two of those labels are written once per request from bytes the client
chose, so both are bounded to something this service registers:
- `handler` is the chi route pattern — `/h/{uuid}`, never the
- `handler` is the chi route pattern — `/webhook/{uuid}`, never the
concrete path. A request matching no route carries `(unmatched)`,
and no entrypoint UUID ever reaches a label.
- `method` is the request method when the router can route it, and
@@ -2165,7 +2150,7 @@ unpredictable rates, and blanket limits shared with other routes would
cause legitimate deliveries to be dropped.
The receiver instead has its own dedicated abuse limit, scoped to the
`/h/{uuid}` route only and keyed per client IP per request path
`/webhook/{uuid}` route only and keyed per client IP per request path
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
affecting other senders of the same entrypoint or the same sender's
other entrypoints. Keying on the path rather than on the entrypoint
@@ -2202,7 +2187,7 @@ log spends. The access log is bounded by neither limit: every request
is recorded once at `INFO`, served or rejected alike.
What the access log does bound is the _content_ of those lines. A 3xx
or 4xx response logs the chi route pattern — `/h/{uuid}`,
or 4xx response logs the chi route pattern — `/webhook/{uuid}`,
`/user/{username}//`, or the literal `(unmatched)` when the request hit
no route at all — in place of the concrete URL. Those are the outcomes
an unauthenticated client can drive for free: 404 and 429 on any
@@ -2236,12 +2221,12 @@ reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
The same hook rewrites the request URL. The SDK builds it as
`scheme://host/path` from the concrete path, which on the receiver
route is `/h/<uuid>` in full — and that UUID is a write
route is `/webhook/<uuid>` in full — and that UUID is a write
capability, not an identifier: anyone holding it can post events this
service accepts and its targets then deliver. A tracker has its own
retention, access control and deletion policy, so the rule the access
log follows above does not carry across that boundary. What is sent is
the chi route pattern instead: `http://host/h/{uuid}`.
the chi route pattern instead: `http://host/webhook/{uuid}`.
The scheme and the host are kept, and everything else in the URL is
discarded rather than edited, so a future SDK version that starts
@@ -2285,8 +2270,8 @@ fallback is never the concrete path. The path becomes the literal
rewrite cannot parse into a scheme is withheld whole. A transaction
event additionally carries the SDK's own `METHOD /path` name, built
from the concrete path as well; it is rewritten on the same terms, to
`POST /h/{uuid}` where the pattern is known and `POST /(redacted)`
where it is not.
`POST /webhook/{uuid}` where the pattern is known and `POST
/(redacted)` where it is not.
The headers are an allowlist for the same reason the rules above are
unconditional: the SDK's own filter removes four names and passes
@@ -2421,7 +2406,7 @@ logger printed the fully interpolated SQL — parameters and all — to
standard output on every statement that returned an error, including a
plain record-not-found, at a level no operator setting reached. Two of
this service's lookups miss by design on unauthenticated routes: the
entrypoint lookup behind `/h/{uuid}` and the user lookup behind
entrypoint lookup behind `/webhook/{uuid}` and the user lookup behind
the login form, whose path segment and submitted username the client
picks outright. Every
`gorm.Open` in the service now installs the adapter in
@@ -2699,10 +2684,10 @@ abuse limit later; they are tracked as future work.
| Method | Path | Description |
| ------ | --------------------------- | ----------- |
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
| `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) |
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
| `POST` | `/webhook/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
#### Authentication Endpoints
@@ -2718,25 +2703,25 @@ abuse limit later; they are tracked as future work.
| ------ | ------------------------ | ----------- |
| `GET` | `/user/{username}` | User profile page |
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
| `GET` | `/hooks` | List user's webhooks |
| `GET` | `/hooks/new` | Create webhook form |
| `POST` | `/hooks/new` | Create webhook submission |
| `GET` | `/hook/{id}` | Webhook detail view |
| `GET` | `/hook/{id}/edit` | Edit webhook form |
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
| `POST` | `/hook/{id}/delete` | Delete webhook |
| `GET` | `/hook/{id}/events` | Full Event Log |
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
| `POST` | `/hook/{id}/targets` | Add target to webhook |
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
| `GET` | `/sources` | List user's webhooks |
| `GET` | `/sources/new` | Create webhook form |
| `POST` | `/sources/new` | Create webhook submission |
| `GET` | `/source/{id}` | Webhook detail view |
| `GET` | `/source/{id}/edit` | Edit webhook form |
| `POST` | `/source/{id}/edit` | Edit webhook submission |
| `POST` | `/source/{id}/delete` | Delete webhook |
| `GET` | `/source/{id}/logs` | Webhook event logs |
| `GET` | `/source/{id}/logs/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
| `POST` | `/source/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
| `POST` | `/source/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
| `POST` | `/source/{id}/targets` | Add target to webhook |
| `GET` | `/source/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
| `POST` | `/source/{id}/targets/{targetID}/edit` | Edit target submission |
| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target |
| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target |
#### Infrastructure Endpoints
@@ -2937,8 +2922,8 @@ local record instead of nothing. What that placement gives up is
recovery of a panic in the six entries above it, none of which does
more than set a header or start a timer.
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
`/hook/*`) apply a **MaxBodySize** middleware that limits
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
`/source/*`) apply a **MaxBodySize** middleware that limits
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
CSRF middleware in every one of those route groups, because
gorilla/csrf parses the form; if the cap were installed after it, form
@@ -2962,7 +2947,7 @@ Those same four route groups then apply **CSRF** and **NoCache**
`/pages` applies **RequireAuth**. The rate limiters are per-route
rather than global: **PasswordChangeRateLimit** on
`/user/{username}/password` and **ReceiverRateLimit** on
`/h/{uuid}`. There is deliberately none on `/pages/login` — that
`/webhook/{uuid}`. There is deliberately none on `/pages/login` — that
endpoint counts failures inside the handler, after the credential
check, see [The login endpoint](#the-login-endpoint).
@@ -3003,8 +2988,8 @@ check, see [The login endpoint](#the-login-endpoint).
by middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
on all state-changing forms (cookie-based double-submit tokens with
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
`/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and
`/api` (stateless API). The middleware detects TLS per-request through
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
to set appropriate cookie security flags and Origin/Referer validation
+22 -12
View File
@@ -17,6 +17,10 @@ const (
// dnsResolutionTimeout is the maximum time to wait for
// DNS resolution during SSRF validation.
dnsResolutionTimeout = 5 * time.Second
// azureWireServer is Azure's WireServer, a public address that
// serves VM credentials.
azureWireServer = "168.63.129.16"
)
// Sentinel errors for SSRF validation.
@@ -25,8 +29,14 @@ var (
errNoIPs = errors.New(
"hostname resolved to no IP addresses",
)
errBlockedIP = errors.New(
"blocked private, reserved or cloud metadata address",
// ErrBlockedIP reports a private or reserved address the
// default blocklist refuses, one that ALLOWED_EGRESS_CIDRS
// can open.
ErrBlockedIP = errors.New(
"blocked private or reserved address",
)
errBlockedWireServer = errors.New(
"blocked cloud metadata address",
)
errBlockedMetadata = errors.New(
"blocked link-local or cloud instance metadata " +
@@ -43,13 +53,6 @@ var (
// permit specific blocks out of this set with
// ALLOWED_EGRESS_CIDRS; see Guard.
//
// A public address belongs on the default blocklist only if it
// hands credentials, user data or bootstrap material to whatever
// can reach it, without the caller presenting anything. A
// provider's other public addresses are not refused, since
// reaching them can be legitimate and no list of them could be
// complete.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet
@@ -130,8 +133,7 @@ func init() {
"::1/128",
"fc00::/7",
"fe80::/10",
// Azure WireServer, a public address that serves VM credentials.
"168.63.129.16/32",
azureWireServer + "/32",
})
// Every entry is named. The set must not grow or shrink
@@ -345,9 +347,17 @@ func (g *Guard) checkIP(ip net.IP) error {
return nil
}
// WireServer is on the default blocklist but is a public
// address, so its refusal does not call it private or reserved.
if ip.Equal(net.ParseIP(azureWireServer)) {
return fmt.Errorf(
"target IP %s: %w", ip, errBlockedWireServer,
)
}
if isBlockedIP(ip) {
return fmt.Errorf(
"target IP %s: %w", ip, errBlockedIP,
"target IP %s: %w", ip, ErrBlockedIP,
)
}
+1 -1
View File
@@ -7,7 +7,7 @@
// SQL — parameters and all — for every statement that returns an
// error, including gorm.ErrRecordNotFound. Two of this service's
// lookups miss by design on unauthenticated routes: the entrypoint
// lookup on /h/{uuid}, whose path segment the client picks
// lookup on /webhook/{uuid}, whose path segment the client picks
// outright, and the user lookup behind the login form, whose username
// the client picks outright. Under the default logger each of those
// misses printed an unbounded, attacker-chosen string, at no level the
+1 -1
View File
@@ -362,7 +362,7 @@ func (h *Handlers) finishReplay(
webhook database.Webhook,
code replayOutcomeCode,
) {
dest := "/hook/" + webhook.ID + "/events?" +
dest := "/source/" + webhook.ID + "/logs?" +
replayOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string:
+8 -8
View File
@@ -138,7 +138,7 @@ func postReplay(
t.Helper()
req := postRequest(
"/hook/"+webhookID+"/deliveries/"+
"/source/"+webhookID+"/deliveries/"+
deliveryID+"/replay",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=queued",
"/source/"+wh.ID+"/logs?replay=queued",
w.Header().Get("Location"),
)
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=target-deleted",
"/source/"+wh.ID+"/logs?replay=target-deleted",
w.Header().Get("Location"),
)
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, missing.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=target-missing",
"/source/"+wh.ID+"/logs?replay=target-missing",
missing.Header().Get("Location"),
)
}
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, first.Code)
require.Equal(
t,
"/hook/"+wh.ID+"/events?replay=queued",
"/source/"+wh.ID+"/logs?replay=queued",
first.Header().Get("Location"),
)
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, second.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=in-flight",
"/source/"+wh.ID+"/logs?replay=in-flight",
second.Header().Get("Location"),
)
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, pending.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=not-terminal",
"/source/"+wh.ID+"/logs?replay=not-terminal",
pending.Header().Get("Location"),
)
}
@@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains(
t, body,
`action="/hook/`+wh.ID+`/deliveries/`+
`action="/source/`+wh.ID+`/deliveries/`+
original.ID+`/replay"`,
)
assert.Contains(t, body, `method="POST"`)
+4 -4
View File
@@ -64,8 +64,8 @@ func fetchEventBody(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+url.PathEscape(sourceID)+
"/events/"+url.PathEscape(eventID)+"/body",
"/source/"+url.PathEscape(sourceID)+
"/logs/"+url.PathEscape(eventID)+"/body",
nil,
)
@@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
page := renderSourceLogsPage(t, h, sess, big.ID)
assert.Contains(
t, page,
"/hook/"+big.ID+"/events/"+bigEvt.ID+"/body",
"/source/"+big.ID+"/logs/"+bigEvt.ID+"/body",
)
small := seedWebhook(t, db)
@@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
page = renderSourceLogsPage(t, h, sess, small.ID)
assert.NotContains(
t, page,
"/hook/"+small.ID+"/events/"+smallEvt.ID+"/body",
"/source/"+small.ID+"/logs/"+smallEvt.ID+"/body",
)
}
+1 -1
View File
@@ -257,7 +257,7 @@ func (h *Handlers) finishResubmit(
webhook database.Webhook,
code resubmitOutcomeCode,
) {
dest := "/hook/" + webhook.ID + "/events?" +
dest := "/source/" + webhook.ID + "/logs?" +
resubmitOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string:
+6 -6
View File
@@ -65,7 +65,7 @@ func postResubmit(
t.Helper()
req := postRequest(
"/hook/"+webhookID+"/events/"+eventID+"/resubmit",
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?resubmit=queued",
"/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"),
)
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?resubmit=queued",
"/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"),
"a resubmit must not be refused while an earlier "+
"one is in flight",
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?resubmit=queued",
"/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"),
"an inactive target is skipped, not an error",
)
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?resubmit=no-targets",
"/source/"+wh.ID+"/logs?resubmit=no-targets",
w.Header().Get("Location"),
)
@@ -598,7 +598,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
)
assert.Contains(
t, body,
"/hook/"+wh.ID+"/events/"+original.ID+"/resubmit",
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
"the log must offer the resubmit action per event",
)
}
+1 -1
View File
@@ -306,7 +306,7 @@ func postWebhook(
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x",
context.Background(), http.MethodPost, "/webhook/x",
strings.NewReader("{}"),
)
+1 -1
View File
@@ -176,7 +176,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
assert.Equal(t, http.StatusSeeOther, w2.Code)
assert.Equal(
t, "/hooks", w2.Header().Get("Location"),
t, "/sources", w2.Header().Get("Location"),
)
}
+2 -2
View File
@@ -5,13 +5,13 @@ import (
)
// HandleIndex returns a handler for the root path that redirects
// based on authentication state: authenticated users go to /hooks
// based on authentication state: authenticated users go to /sources
// (the dashboard), unauthenticated users go to the login page.
func (s *Handlers) HandleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.Get(r)
if err == nil && s.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
http.Redirect(w, r, "/sources", http.StatusSeeOther)
return
}
+5 -5
View File
@@ -4,7 +4,7 @@ package handlers_test
// this package reach a value an UNAUTHENTICATED client picks outright
// and of a length it picks outright:
//
// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose
// path segment matched no stored entrypoint and so is bounded by
// nothing;
// - the failed-login DEBUG lines, whose username is a form field.
@@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) {
// route pattern.
func receiverRouter(h *handlers.Handlers) *chi.Mux {
router := chi.NewRouter()
router.Post("/h/{uuid}", h.HandleWebhook())
router.Post("/webhook/{uuid}", h.HandleWebhook())
return router
}
// postReceiver sends one POST at /h/<segment>.
// postReceiver sends one POST at /webhook/<segment>.
//
// RawPath is cleared after parsing so chi routes on the decoded path
// and the handler sees the raw bytes rather than their percent-escaped
@@ -210,7 +210,7 @@ func postReceiver(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/h/"+url.PathEscape(segment),
"/webhook/"+url.PathEscape(segment),
strings.NewReader(""),
)
req.URL.RawPath = ""
@@ -507,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
http.StatusServiceUnavailable,
postLoginAtPath(
t, h,
"/hook/"+url.PathEscape(
"/source/"+url.PathEscape(
oversizedFill(fill),
)+"/login",
),
+1 -1
View File
@@ -313,7 +313,7 @@ func TestHandleWebhook_RecordsBodySize(t *testing.T) {
body := strings.Repeat("é", 1024)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x",
context.Background(), http.MethodPost, "/webhook/x",
strings.NewReader(body),
)
+9 -9
View File
@@ -220,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
"/source/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -267,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
"/source/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -323,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
"/source/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -337,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
)
assert.Empty(
t, w.Header().Get("Location"),
"a failed deletion must not redirect to /hooks",
"a failed deletion must not redirect to /sources",
)
assert.Equal(
@@ -402,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
"/source/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -411,7 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
h.HandleSourceDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/hooks", w.Header().Get("Location"))
assert.Equal(t, "/sources", w.Header().Get("Location"))
assert.Equal(
t, int64(0),
@@ -465,7 +465,7 @@ func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
@@ -515,7 +515,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
@@ -563,7 +563,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+f.webhook,
"/source/"+f.webhook,
nil,
)
req.Host = host
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
assert.Equal(
t,
tc.scheme+"://"+host+"/h/"+fixture.path,
tc.scheme+"://"+host+"/webhook/"+fixture.path,
fixture.entrypointURL(
t, host, forwardedProto(tc.header),
),
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
assert.Equal(
t,
"https://"+host+"/h/"+fixture.path,
"https://"+host+"/webhook/"+fixture.path,
got,
"a connection this process terminated with TLS "+
"outranks a header claiming plaintext",
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
assert.Equal(
t,
"https://"+host+"/h/"+fixture.path,
"https://"+host+"/webhook/"+fixture.path,
fixture.entrypointURL(
t, host, forwardedProto("HTTPS"),
),
+1 -1
View File
@@ -82,7 +82,7 @@ func serveSourceDetailPage(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+webhookID,
"/source/"+webhookID,
nil,
)
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
t.Helper()
req := postRequest(
"/hook/"+webhookID+"/targets/"+targetID+"/delete",
"/source/"+webhookID+"/targets/"+targetID+"/delete",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
+1 -1
View File
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+webhookID+"/events"+query,
"/source/"+webhookID+"/logs"+query,
nil,
)
+25 -14
View File
@@ -330,7 +330,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
)
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
@@ -588,7 +588,7 @@ func (h *Handlers) applyWebhookEdit(
}
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
@@ -671,7 +671,7 @@ func (h *Handlers) deleteWebhookResources(
return
}
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
http.Redirect(w, r, "/sources", http.StatusSeeOther)
}
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
@@ -1264,7 +1264,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
}
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
}
@@ -1320,7 +1320,7 @@ func (h *Handlers) processTargetCreate(
//
// Every field here is read with PostFormValue, not FormValue.
// FormValue falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and
// the request line, unlike the body, is what logs, proxies,
// Referer headers and error trackers record.
@@ -1377,7 +1377,7 @@ func (h *Handlers) processTargetCreate(
}
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
@@ -1435,7 +1435,7 @@ type targetFormInput struct {
//
// Every field is read with PostFormValue, not FormValue. FormValue
// falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and the
// request line, unlike the body, is what logs, proxies, Referer
// headers and error trackers record. The headers field is under the
@@ -1577,11 +1577,22 @@ func (h *Handlers) validateTargetURL(
"url", delivery.MaskURL(targetURL),
"error", err,
)
http.Error(
w,
"Invalid target URL: "+err.Error(),
http.StatusBadRequest,
)
msg := "Invalid target URL: " + err.Error()
// Only a private or reserved address's refusal says how
// to allow it. Metadata refusals never do: link-local and
// the other unconditional metadata addresses cannot be
// opened, and Azure's WireServer, which listing does
// open, hands out VM credentials.
if errors.Is(err, delivery.ErrBlockedIP) {
msg += ". Private and reserved addresses are refused " +
"by default; the server's ALLOWED_EGRESS_CIDRS " +
"setting allows named networks (see \"Allowing " +
"egress to your own network\" in the README)."
}
http.Error(w, msg, http.StatusBadRequest)
return err
}
@@ -1714,7 +1725,7 @@ func (h *Handlers) deleteChildResource(
http.Redirect(
w, r,
"/hook/"+webhook.ID,
"/source/"+webhook.ID,
http.StatusSeeOther,
)
}
@@ -1811,7 +1822,7 @@ func (h *Handlers) toggleChildResource(
http.Redirect(
w, r,
"/hook/"+webhook.ID,
"/source/"+webhook.ID,
http.StatusSeeOther,
)
}
+7 -7
View File
@@ -105,7 +105,7 @@ func submitCreate(
form.Set("retention_days", *retention)
}
req := formRequest("/hooks/new", cookies, form, nil)
req := formRequest("/sources/new", cookies, form, nil)
w := httptest.NewRecorder()
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
@@ -265,7 +265,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
w := httptest.NewRecorder()
env.handlers.HandleSourceCreate().ServeHTTP(
w, getRequest(t, "/hooks/new", env.cookies, nil),
w, getRequest(t, "/sources/new", env.cookies, nil),
)
require.Equal(t, http.StatusOK, w.Code)
@@ -402,7 +402,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
form.Set("description", description)
form.Set("retention_days", "nonsense")
req := formRequest("/hooks/new", env.cookies, form, nil)
req := formRequest("/sources/new", env.cookies, form, nil)
w := httptest.NewRecorder()
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
@@ -430,7 +430,7 @@ func submitEdit(
form.Set("retention_days", retention)
req := formRequest(
"/hook/"+wh.ID+"/edit",
"/source/"+wh.ID+"/edit",
env.cookies,
form,
map[string]string{sourceIDParam: wh.ID},
@@ -512,7 +512,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
)
req := getRequest(
t, "/hook/"+wh.ID+"/edit", env.cookies,
t, "/source/"+wh.ID+"/edit", env.cookies,
map[string]string{sourceIDParam: wh.ID},
)
w := httptest.NewRecorder()
@@ -567,7 +567,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
listW := httptest.NewRecorder()
env.handlers.HandleSourceList().ServeHTTP(
listW, getRequest(t, "/hooks", env.cookies, nil),
listW, getRequest(t, "/sources", env.cookies, nil),
)
require.Equal(t, http.StatusOK, listW.Code)
@@ -578,7 +578,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
env.handlers.HandleSourceDetail().ServeHTTP(
detailW,
getRequest(
t, "/hook/"+wh.ID, env.cookies,
t, "/source/"+wh.ID, env.cookies,
map[string]string{sourceIDParam: wh.ID},
),
)
@@ -76,11 +76,11 @@ func postTargetCreate(
router := chi.NewRouter()
router.Use(mw.Logging())
router.Post(
"/hook/{sourceID}/targets",
"/source/{sourceID}/targets",
env.handlers.HandleTargetCreate(),
)
target := "/hook/" + webhookID + "/targets"
target := "/source/" + webhookID + "/targets"
if query != "" {
target += "?" + query
}
@@ -114,7 +114,7 @@ func postTargetCreate(
// regression test for the ingress leak. r.FormValue falls back to the
// query string when a field is absent from the POST body, so
//
// POST /hook/{id}/targets?url=https://hooks.slack.com/services/...
// POST /source/{id}/targets?url=https://hooks.slack.com/services/...
//
// with an empty url field used to create a working target from a value
// carried on the request line — where logs, proxies, Referer headers
+2 -2
View File
@@ -47,7 +47,7 @@ type targetEditView struct {
//
// This page is the one place the full destination URL and header
// values are shown. It is reachable only through the
// /hook/{sourceID} route group, which supplies RequireAuth and
// /source/{sourceID} route group, which supplies RequireAuth and
// NoCache, and only for a target of a webhook the session's user
// owns; masking (delivery.TargetView) is unchanged everywhere else.
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
@@ -163,7 +163,7 @@ func (h *Handlers) applyTargetEdit(
}
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
+9 -9
View File
@@ -42,15 +42,15 @@ const (
func targetRouter(env *sourceTestEnv) *chi.Mux {
router := chi.NewRouter()
router.Post(
"/hook/{sourceID}/targets",
"/source/{sourceID}/targets",
env.handlers.HandleTargetCreate(),
)
router.Get(
"/hook/{sourceID}/targets/{targetID}/edit",
"/source/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEdit(),
)
router.Post(
"/hook/{sourceID}/targets/{targetID}/edit",
"/source/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEditSubmit(),
)
@@ -117,7 +117,7 @@ func seedHTTPTarget(
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
"/source/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
@@ -188,7 +188,7 @@ func submitTargetEdit(
) *httptest.ResponseRecorder {
return serveTarget(
env, http.MethodPost,
"/hook/"+webhookID+"/targets/"+targetID+"/edit",
"/source/"+webhookID+"/targets/"+targetID+"/edit",
form,
)
}
@@ -401,7 +401,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
"/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil,
)
require.Equal(t, http.StatusOK, w.Code)
@@ -508,7 +508,7 @@ func assertEditIgnoresQueryString(
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets/"+target.ID+
"/source/"+webhook.ID+"/targets/"+target.ID+
"/edit?url="+url.QueryEscape(editReplacedURL)+
"&headers="+url.QueryEscape(editAuthHeader),
form,
@@ -592,7 +592,7 @@ func assertTargetOfAnotherWebhook404s(
get := serveTarget(
env, http.MethodGet,
"/hook/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
"/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
)
assert.Equal(t, http.StatusNotFound, get.Code)
@@ -630,7 +630,7 @@ func assertWebhookOfAnotherUser404s(
w := serveTarget(
env, http.MethodGet,
"/hook/"+other.ID+"/targets/"+target.ID+"/edit", nil,
"/source/"+other.ID+"/targets/"+target.ID+"/edit", nil,
)
assert.Equal(t, http.StatusNotFound, w.Code)
@@ -0,0 +1,116 @@
package handlers_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// privateRefusalHint is the sentence that tells an operator a private
// destination is refused on purpose, and how to allow one.
const privateRefusalHint = "Private and reserved addresses are " +
"refused by default; the server's ALLOWED_EGRESS_CIDRS setting " +
"allows named networks (see \"Allowing egress to your own " +
"network\" in the README)."
// TestTargetRefusal_PrivateDestinationSaysHowToAllowIt covers both
// target types that take a URL, on add and on edit.
func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
targetTypes := []database.TargetType{
database.TargetTypeHTTP,
database.TargetTypeSlack,
}
for _, targetType := range targetTypes {
t.Run(string(targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
targetsPath := "/source/" + webhook.ID + "/targets"
form := url.Values{}
form.Set("name", "private")
form.Set("type", string(targetType))
form.Set("url", editBlockedURL)
added := serveTarget(
env, http.MethodPost, targetsPath, form,
)
assert.Equal(t, http.StatusBadRequest, added.Code)
assert.Contains(
t, added.Body.String(), privateRefusalHint,
)
form.Set("url", editOriginalURL)
created := serveTarget(
env, http.MethodPost, targetsPath, form,
)
require.Equal(
t, http.StatusSeeOther, created.Code,
created.Body.String(),
)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
form.Set("url", editBlockedURL)
edited := submitTargetEdit(
env, webhook.ID, targets[0].ID, form,
)
assert.Equal(t, http.StatusBadRequest, edited.Code)
assert.Contains(
t, edited.Body.String(), privateRefusalHint,
)
})
}
}
// TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt: no
// setting opens a link-local address, and Azure's WireServer hands out
// VM credentials, so neither refusal points at the setting.
func TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
metadataURLs := map[string]string{
"link-local": "http://169.254.169.254/latest/meta-data/",
"wireserver": "http://168.63.129.16/?comp=versions",
}
for name, metadataURL := range metadataURLs {
t.Run(name, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "metadata")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", metadataURL)
w := serveTarget(
env, http.MethodPost,
"/source/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.NotContains(
t, w.Body.String(), privateRefusalHint,
)
})
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ func createWithRetries(
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets",
"/source/"+webhook.ID+"/targets",
createRetriesForm(retries),
)
+10 -55
View File
@@ -54,7 +54,8 @@ func renderPage(
}
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
// label to "Webhooks" and its link to the webhook list at /hooks.
// label to "Webhooks". The /sources route is deliberately unchanged, so
// the assertion targets the link text rather than the href.
func TestNavbarUsesWebhookTerminology(t *testing.T) {
t.Parallel()
@@ -94,11 +95,15 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
t, body, ">Sources<",
"no user-visible element may still be labelled Sources",
)
assert.Contains(t, body, `href="/hooks"`)
assert.Contains(
t, body, `href="/sources"`,
"the /sources route itself must not change",
)
}
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
// its back link to the webhook page at /hook/{id}.
// its back link. The link's href still points at /source/{id}, which is
// intentional: only user-visible copy changes.
func TestEditPageUsesWebhookTerminology(t *testing.T) {
t.Parallel()
@@ -125,57 +130,7 @@ func TestEditPageUsesWebhookTerminology(t *testing.T) {
assert.Contains(t, body, "Edit Webhook")
assert.NotContains(t, body, ">Sources<")
assert.Contains(t, body, `href="/hook/wh-1"`)
}
// TestEventLogPageIsCalledFullEventLog pins the one name the event log
// page at /hook/{id}/events goes by: both links to it on the webhook
// page, and its own heading, read "Full Event Log".
func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
// A pointer, as in the handlers: source_detail.html calls
// Webhook.RetentionLabel, a pointer method. Both pages only range
// over their lists, and a list left out renders as empty, so the
// lists are left out.
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
webhook.ID = testWebhookID
detailBody := renderPage(
t, h, sess, "source_detail.html", map[string]any{
dataKeyWebhook: webhook,
},
)
assert.Contains(
t, detailBody,
`<a href="/hook/wh-1/events" class="btn-secondary">Full Event Log</a>`,
"the button at the top of the webhook page",
)
assert.Contains(
t, detailBody,
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`,
"the link under recent events",
)
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
dataKeyWebhook: webhook,
"TotalEvents": int64(0),
})
assert.Contains(
t, logBody,
`<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>`,
)
assert.Contains(t, body, `href="/source/wh-1"`)
}
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
@@ -328,7 +283,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
t, body,
`<code id="entrypoint-url-ep-1"`,
)
assert.Contains(t, body, "https://hooks.example.com/h/abc123")
assert.Contains(t, body, "https://hooks.example.com/webhook/abc123")
assert.Contains(
t, body,
`hidden data-copy-target="entrypoint-url-ep-1"`,
+1 -1
View File
@@ -131,7 +131,7 @@ func (h *Handlers) lookupEntrypoint(
"path = ?", entrypointUUID,
).First(&entrypoint)
if result.Error != nil {
// The receiver is unauthenticated and /h/{uuid}
// The receiver is unauthenticated and /webhook/{uuid}
// matches any single segment, so this value is entirely
// client-chosen on exactly the branch where the lookup
// failed. DEBUG is off by default; the cap is what keeps
+1 -1
View File
@@ -201,7 +201,7 @@ func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
t.Parallel()
for _, s := range []string{
"", "GET", "/hook/abc/edit", "Mozilla/5.0 (X11)",
"", "GET", "/source/abc/edit", "Mozilla/5.0 (X11)",
} {
assert.Equal(t, s, logfield.Truncate(s, budget))
}
+8 -8
View File
@@ -119,7 +119,7 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
)
router.HandleFunc(
"/h/{uuid}",
"/webhook/{uuid}",
func(w http.ResponseWriter, r *http.Request) {
// Stands in for the real handler: an unknown entrypoint
// UUID 404s, a known one succeeds.
@@ -271,11 +271,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
assertFloodIsBounded(
t,
func(i int) string {
return "/h/" + attackerMarker +
return "/webhook/" + attackerMarker +
strings.Repeat("x", i) + "?q=" + attackerMarker
},
http.StatusNotFound,
"/h/{uuid}",
"/webhook/{uuid}",
)
}
@@ -346,10 +346,10 @@ type sizeCase struct {
func lineSizeCases() map[string]sizeCase {
cases := map[string]sizeCase{
"oversized path segment": {
target: "/h/" + attackerMarker +
target: "/webhook/" + attackerMarker +
strings.Repeat("x", oversizedSegmentBytes),
wantStatus: http.StatusNotFound,
wantURL: "/h/{uuid}",
wantURL: "/webhook/{uuid}",
bound: maxLineBytes,
},
// /.well-known/healthcheck answers 200 to anyone and has no
@@ -605,14 +605,14 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
router := accessLogRouter(m)
assert.Equal(
t, http.StatusOK, get(t, router, "/h/known?src=ci"),
t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
)
// The path resolved against a stored entrypoint, so it stays. The
// query never does: see TestAccessLog_UnauthenticatedSuccess...
entries := accessLogEntries(t, buf)
require.Len(t, entries, 1)
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
assert.NotContains(t, buf.String(), "src=ci")
}
@@ -640,7 +640,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
assert.Equal(
t,
http.StatusNotFound,
get(t, router, "/h/"+attackerMarker),
get(t, router, "/webhook/"+attackerMarker),
)
entries := accessLogEntries(t, buf)
+1 -1
View File
@@ -41,7 +41,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
// CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an
// unauthenticated client: a POST with no token to
// /hook/<any length of any text>/edit lands here. The
// /source/<any length of any text>/edit lands here. The
// method and path are capped against the same budgets as
// the access log. remote_addr is set by net/http from the
// accepted connection rather than by the client, and
+3 -3
View File
@@ -383,7 +383,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) {
t, newHandler,
)
path := "/hook/" +
path := "/source/" +
oversizedPathSegment(fill) + "/edit"
assert.Equal(
@@ -434,7 +434,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/hook/"+
"/source/"+
oversizedPathSegment(fill)+"/login",
nil,
)
@@ -499,7 +499,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
http.StatusRequestEntityTooLarge,
postOversize(
h,
"/hook/"+segment(i)+"/edit",
"/source/"+segment(i)+"/edit",
),
)
}
+1 -1
View File
@@ -40,7 +40,7 @@ const unmatchedMethod = unmatchedRoute
//
// The pattern is what bounds the label's domain to the routes the
// service registers. The path does not bound it at all — every byte
// after /h/ is client-chosen, so labelling by path lets any
// after /webhook/ is client-chosen, so labelling by path lets any
// unauthenticated client mint permanent series at will, and publishes
// the entrypoint UUID (the receiver's only credential) in the scrape
// while doing it.
+1 -1
View File
@@ -50,7 +50,7 @@ func realMethods() []string {
// dimension varying, so any series growth a probe produces is the
// method label's and nothing else's.
func methodProbePath() string {
return "/h/" + uuid.NewString()
return "/webhook/" + uuid.NewString()
}
// inventedMethods returns n distinct RFC 9110 method tokens that no
+6 -6
View File
@@ -28,7 +28,7 @@ const (
// receiverRoutePattern is the one handler label every receiver
// request must produce, however the client varies the path.
receiverRoutePattern = "/h/{uuid}"
receiverRoutePattern = "/webhook/{uuid}"
// okRoute is a static route used to pin that the response-writer
// interceptor still reports status and size after the handler id
@@ -143,13 +143,13 @@ func drivePaths(
return drive(t, h, probes)
}
// receiverPaths returns n distinct /h/ paths, each naming a
// receiverPaths returns n distinct /webhook/ paths, each naming a
// fresh UUID exactly as an unauthenticated flood would.
func receiverPaths(n int) []string {
paths := make([]string, 0, n)
for range n {
paths = append(paths, "/h/"+uuid.NewString())
paths = append(paths, "/webhook/"+uuid.NewString())
}
return paths
@@ -220,7 +220,7 @@ func keys(set map[string]struct{}) []string {
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
// assertion the issue asks for: N requests to N distinct
// /h/<uuid> paths must produce exactly ONE handler label, the
// /webhook/<uuid> paths must produce exactly ONE handler label, the
// route pattern. Before the fix this produced N of them.
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
t.Parallel()
@@ -250,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
// The scrape must not republish the UUIDs it was driven with.
// They are the receiver's only credential.
for _, p := range paths {
id := strings.TrimPrefix(p, "/h/")
id := strings.TrimPrefix(p, "/webhook/")
for label := range labels {
assert.NotContains(
t, label, id,
@@ -354,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
if i%2 == 0 {
paths = append(paths, "/"+id)
} else {
paths = append(paths, "/h/"+id+"/"+id)
paths = append(paths, "/webhook/"+id+"/"+id)
}
}
+2 -2
View File
@@ -257,7 +257,7 @@ func concreteLogURL(r *http.Request) string {
//
// 3xx and 4xx responses get the chi route pattern instead. Those are
// the outcomes an unauthenticated client drives for free: 404 or 429
// on any invented /h/ path, 303 to the login page on any
// on any invented /webhook/ path, 303 to the login page on any
// invented /user/ path. Logging the concrete URL there lets a flood
// write attacker-chosen text, of attacker-chosen length, into the
// operator's log at one line per request. The pattern comes from the
@@ -560,7 +560,7 @@ func (s *Middleware) MaxBodySize(
// internal/server/routes.go), so an
// unauthenticated client reaches it with a path
// of its own choosing and its own length —
// POST /hook/<8 KB>/edit with an oversize
// POST /source/<8 KB>/edit with an oversize
// declared Content-Length costs nothing to
// send. At WARN, on by default, that is a
// write into the operator's log sized by the
+1 -1
View File
@@ -640,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/hooks", nil,
http.MethodGet, "/sources", nil,
)
w := httptest.NewRecorder()
+2 -2
View File
@@ -63,7 +63,7 @@ const (
// receiverAggregateMultiplier scales the configured
// per-entrypoint receiver limit into the aggregate limit one
// client IP may spend across the whole /h/* route. Ten
// client IP may spend across the whole /webhook/* route. Ten
// entrypoints' worth lets a single sender address drive several
// entrypoints at their full rate, while still capping what one
// address costs the unauthenticated receiver.
@@ -390,7 +390,7 @@ func (m *Middleware) postRateLimit(
// It is Config.ReceiverRateLimit requests per minute.
//
// That limit alone bounds nothing in aggregate. The route pattern
// /h/{uuid} matches any single segment, so a client that
// /webhook/{uuid} matches any single segment, so a client that
// invents a fresh path per request mints a fresh bucket per request
// and never refills one — and every such request still reaches the
// handler's entrypoint lookup before it 404s. The outer limit is
+12 -12
View File
@@ -275,7 +275,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// pass.
for i := range limit {
w := receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-a",
handler, "9.9.9.9:1234", "/webhook/uuid-a",
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -286,7 +286,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// The next request over the limit is rejected with a 429
// carrying a Retry-After header.
w := receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-a",
handler, "9.9.9.9:1234", "/webhook/uuid-a",
)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.NotEmpty(
@@ -296,7 +296,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// The same IP is not limited on a different entrypoint.
w = receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-b",
handler, "9.9.9.9:1234", "/webhook/uuid-b",
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -305,7 +305,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// A different IP is not limited on the same entrypoint.
w = receiverPost(
handler, "8.8.8.8:1234", "/h/uuid-a",
handler, "8.8.8.8:1234", "/webhook/uuid-a",
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -322,7 +322,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
const (
limit = 2
ip = "7.7.7.7:1234"
path = "/h/uuid-c"
path = "/webhook/uuid-c"
)
handler := receiverLimitedHandler(t, limit)
@@ -715,7 +715,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
// none of them shares a per-entrypoint bucket with another.
for i := range aggregate {
w := receiverPost(
handler, ip, fmt.Sprintf("/h/invented-%d", i),
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -724,17 +724,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
}
w := receiverPost(
handler, ip, fmt.Sprintf("/h/invented-%d", aggregate),
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
)
assert.Equal(
t, http.StatusTooManyRequests, w.Code,
"a client must not be able to raise its aggregate rate "+
"against /h/* by varying the path",
"against /webhook/* by varying the path",
)
// The aggregate limit is still per client IP: exhausting one
// address must not throttle another.
w = receiverPost(handler, "6.6.6.7:1234", "/h/invented-0")
w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
assert.Equal(
t, http.StatusOK, w.Code,
"a different client IP must not be affected",
@@ -771,7 +771,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
// limit requests are served; the rest are rejected by the
// per-entrypoint limiter but still count against the aggregate.
for i := range aggregate {
w := receiverPost(handler, ip, "/h/exhausted")
w := receiverPost(handler, ip, "/webhook/exhausted")
want := http.StatusTooManyRequests
if i < limit {
@@ -784,7 +784,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
)
}
w := receiverPost(handler, ip, "/h/never-used")
w := receiverPost(handler, ip, "/webhook/never-used")
assert.Equal(
t, http.StatusTooManyRequests, w.Code,
"requests rejected per entrypoint must still count "+
@@ -823,7 +823,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
const (
limit = 3
peer = "203.0.113.10:44444"
path = "/h/uuid-d"
path = "/webhook/uuid-d"
)
handler := receiverLimitedHandler(t, limit)
+5 -5
View File
@@ -182,7 +182,7 @@ func (s *Server) setupUserRoutes() {
}
func (s *Server) setupSourceRoutes() {
s.router.Route("/hooks", func(r chi.Router) {
s.router.Route("/sources", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
@@ -194,7 +194,7 @@ func (s *Server) setupSourceRoutes() {
r.Post("/new", s.h.HandleSourceCreateSubmit())
})
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
s.router.Route("/source/{sourceID}", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
@@ -205,14 +205,14 @@ func (s *Server) setupSourceRoutes() {
r.Get("/edit", s.h.HandleSourceEdit())
r.Post("/edit", s.h.HandleSourceEditSubmit())
r.Post("/delete", s.h.HandleSourceDelete())
r.Get("/events", s.h.HandleSourceLogs())
r.Get("/logs", s.h.HandleSourceLogs())
// The log page renders each body only up to its cap, so
// this is the only route that serves a whole one. It
// belongs to this group for its RequireAuth and
// NoCache; see HandleEventBodyDownload for the headers
// that keep the bytes it returns inert.
r.Get(
"/events/{eventID}/body",
"/logs/{eventID}/body",
s.h.HandleEventBodyDownload(),
)
// Replay is the one page action that queues outbound work:
@@ -279,7 +279,7 @@ func (s *Server) setupSourceRoutes() {
func (s *Server) setupWebhookRoutes() {
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
"/h/{uuid}",
"/webhook/{uuid}",
s.h.HandleWebhook(),
)
}
+8 -61
View File
@@ -674,7 +674,7 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
require.NotNil(t, fresh, "login must set a session cookie")
assert.Equal(
t, "/hooks",
t, "/sources",
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
"the new session cookie must authenticate",
)
@@ -741,7 +741,7 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
)
}
// --- /hook/{sourceID} group ---
// --- /source/{sourceID} group ---
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
// feature the way a user does: render the event log page through
@@ -769,11 +769,11 @@ func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
wh := env.seedWebhook(t, userID)
env.seedEvent(t, wh.ID, stored)
page := env.get("/hook/"+wh.ID+"/events", cookies)
page := env.get("/source/"+wh.ID+"/logs", cookies)
require.Equal(t, http.StatusOK, page.Code)
link := regexp.MustCompile(
`href="(/hook/[^"]+/body)"`,
`href="(/source/[^"]+/body)"`,
).FindStringSubmatch(page.Body.String())
require.Len(
t, link, 2,
@@ -819,7 +819,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
const payload = "OWNERS-PAYLOAD-77c1"
evt := env.seedEvent(t, wh.ID, payload)
path := "/hook/" + wh.ID + "/events/" + evt.ID + "/body"
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
intruder := env.authCookies(t, intruderID, "intruder")
@@ -853,7 +853,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
path := "/hook/" + wh.ID + "/deliveries/" + dlv.ID +
path := "/source/" + wh.ID + "/deliveries/" + dlv.ID +
"/replay"
assert.Equal(
@@ -879,7 +879,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
// The token and the action URL both come out of the rendered
// page, so a typo in either the route pattern or the template
// fails here.
logsPath := "/hook/" + wh.ID + "/events"
logsPath := "/source/" + wh.ID + "/logs"
token, cookies := env.csrfFrom(t, logsPath, cookies)
@@ -887,7 +887,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
require.Equal(t, http.StatusOK, page.Code)
action := regexp.MustCompile(
`action="(/hook/[^"]+/replay)"`,
`action="(/source/[^"]+/replay)"`,
).FindStringSubmatch(page.Body.String())
require.Len(
t, action, 2,
@@ -912,59 +912,6 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
)
}
// --- /h/{uuid} receiver ---
// TestReceiver_EntrypointURLIsRateLimited takes the entrypoint URL
// the webhook page shows and posts to it through the production
// router until the receiver rate limit refuses it. The URL has to
// reach the receiver, and the limit has to apply to it.
func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) {
t.Parallel()
const limit = 2
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: limit,
})
userID, _ := env.seedUser(t, "receiver", "somepassword")
cookies := env.authCookies(t, userID, "receiver")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: wh.ID,
Path: "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60",
Active: true,
},
).Error)
page := env.get("/hook/"+wh.ID, cookies)
require.Equal(t, http.StatusOK, page.Code)
shown := regexp.MustCompile(`(/h/[^<]+)</code>`).
FindStringSubmatch(page.Body.String())
require.Len(
t, shown, 2, "the webhook page should show the entrypoint URL",
)
for i := range limit {
assert.Equal(
t, http.StatusOK,
env.post(shown[1], url.Values{}, nil).Code,
"request %d should reach the receiver", i,
)
}
assert.Equal(
t, http.StatusTooManyRequests,
env.post(shown[1], url.Values{}, nil).Code,
"the receiver rate limit must apply to the entrypoint URL",
)
}
// metricsConfig is a Config differing from the routing default only
// in the two /metrics credentials.
func metricsConfig(
+1 -1
View File
@@ -55,7 +55,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions {
//
// URL is the third such field. NewRequest builds it as
// scheme://host/path (interfaces.go:183), and on the receiver route
// that path is /h/<uuid> in full — a write capability, not an
// that path is /webhook/<uuid> in full — a write capability, not an
// identifier. It is rebuilt here from the chi route pattern, on every
// route, keeping the scheme and the host.
//
+7 -7
View File
@@ -153,7 +153,7 @@ func (c sentryCase) router() http.Handler {
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
)
router.HandleFunc("/pages/login", handler)
router.HandleFunc("/h/{uuid}", handler)
router.HandleFunc("/webhook/{uuid}", handler)
return router
}
@@ -191,7 +191,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request {
// concrete path carries the entrypoint capability.
func sentryReceiverRequest(client *sentry.Client) *http.Request {
return sentryRequest(
client, "/h/"+sentryReceiverUUID, "payload=hello",
client, "/webhook/"+sentryReceiverUUID, "payload=hello",
)
}
@@ -316,7 +316,7 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
t, marshalEvent(t, event), sentryReceiverUUID,
)
assert.Equal(
t, "http://example.com/h/{uuid}", event.Request.URL,
t, "http://example.com/webhook/{uuid}", event.Request.URL,
)
}
@@ -401,7 +401,7 @@ func TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook(
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
t.Parallel()
concrete := "https://example.com/h/" + sentryReceiverUUID
concrete := "https://example.com/webhook/" + sentryReceiverUUID
// A request with no chi routing context on it at all, which is
// what an event captured outside the router would carry.
@@ -426,7 +426,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
event := sentry.NewEvent()
event.Request = &sentry.Request{URL: concrete}
event.Transaction = "POST /h/" +
event.Transaction = "POST /webhook/" +
sentryReceiverUUID
scrubbed := server.ScrubSentryRequestForTest(
@@ -459,9 +459,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) {
event := sentry.NewEvent()
event.Request = &sentry.Request{
URL: "/h/" + sentryReceiverUUID,
URL: "/webhook/" + sentryReceiverUUID,
}
event.Transaction = "/h/" + sentryReceiverUUID
event.Transaction = "/webhook/" + sentryReceiverUUID
scrubbed := server.ScrubSentryRequestForTest(event, nil)
require.NotNil(t, scrubbed)
+2 -2
View File
@@ -16,7 +16,7 @@
<!-- Desktop navigation -->
<div class="hidden md:flex items-center gap-4">
{{if .User}}
<a href="/hooks" class="btn-text">Webhooks</a>
<a href="/sources" class="btn-text">Webhooks</a>
<a href="/user/{{.User.Username}}" class="btn-text">
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
@@ -38,7 +38,7 @@
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
<div class="flex flex-col gap-2">
{{if .User}}
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
<form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
+13 -13
View File
@@ -5,7 +5,7 @@
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex justify-between items-center mt-2">
<div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
@@ -14,9 +14,9 @@
{{end}}
</div>
<div class="flex gap-2">
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger">Delete</button>
</form>
@@ -39,7 +39,7 @@
<!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
<button type="submit" class="btn-primary text-sm">Add</button>
@@ -57,20 +57,20 @@
{{else}}
<span class="badge-error">Inactive</span>
{{end}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
</form>
</div>
</div>
<div class="flex items-start gap-2 mt-1">
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code>
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
<!-- Hidden until app.js reveals it; without the
script the URL above stays selectable. -->
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
@@ -98,7 +98,7 @@
<!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
@@ -151,14 +151,14 @@
{{else}}
<span class="badge-error">Inactive</span>
{{end}}
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
<a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
</form>
@@ -182,7 +182,7 @@
<div class="card mt-6">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a>
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
</div>
<div class="divide-y divide-gray-100">
{{range .Events}}
+3 -3
View File
@@ -5,7 +5,7 @@
{{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
</div>
@@ -14,7 +14,7 @@
<div class="alert-error">{{.Error}}</div>
{{end}}
<form method="POST" action="/hook/{{.Webhook.ID}}/edit" class="space-y-6">
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="name" class="label">Name</label>
@@ -34,7 +34,7 @@
<div class="flex gap-3">
<button type="submit" class="btn-primary">Save Changes</button>
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
</div>
</form>
</div>
+8 -8
View File
@@ -1,13 +1,13 @@
{{template "base" .}}
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
</div>
</div>
@@ -55,7 +55,7 @@
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
</div>
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
<form method="POST" action="/source/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
@@ -63,7 +63,7 @@
</div>
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
{{if .BodyTruncated}}
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
{{end}}
{{if .Deliveries}}
@@ -79,7 +79,7 @@
</div>
<div class="flex items-center gap-3">
{{if .Status.Terminal}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
<form method="POST" action="/source/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
@@ -139,11 +139,11 @@
{{if or .HasPrev .HasNext}}
<div class="flex justify-center gap-2 mt-6">
{{if .HasPrev}}
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">&larr; Previous</a>
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">&larr; Previous</a>
{{end}}
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
{{if .HasNext}}
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next &rarr;</a>
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next &rarr;</a>
{{end}}
</div>
{{end}}
+3 -3
View File
@@ -6,7 +6,7 @@
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
<a href="/hooks/new" class="btn-primary">
<a href="/sources/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
@@ -17,7 +17,7 @@
{{if .Webhooks}}
<div class="grid gap-4">
{{range .Webhooks}}
<a href="/hook/{{.ID}}" class="card-elevated p-6 block">
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
<div class="flex justify-between items-start">
<div>
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
@@ -42,7 +42,7 @@
</svg>
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
<a href="/hooks/new" class="btn-primary">Create Webhook</a>
<a href="/sources/new" class="btn-primary">Create Webhook</a>
</div>
{{end}}
</div>
+3 -3
View File
@@ -5,7 +5,7 @@
{{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
</div>
@@ -14,7 +14,7 @@
<div class="alert-error">{{.Error}}</div>
{{end}}
<form method="POST" action="/hooks/new" class="space-y-6">
<form method="POST" action="/sources/new" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="name" class="label">Name</label>
@@ -34,7 +34,7 @@
<div class="flex gap-3">
<button type="submit" class="btn-primary">Create Webhook</button>
<a href="/hooks" class="btn-secondary">Cancel</a>
<a href="/sources" class="btn-secondary">Cancel</a>
</div>
</form>
</div>
+3 -3
View File
@@ -5,7 +5,7 @@
{{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
</div>
@@ -21,7 +21,7 @@
</div>
{{end}}
<form method="POST" action="/hook/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
<form method="POST" action="/source/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
@@ -75,7 +75,7 @@
<div class="flex gap-3">
<button type="submit" class="btn-primary">Save Changes</button>
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
</div>
</form>
</div>