Compare commits
11
Commits
0ef53a4e15
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
147d58dd96 | ||
|
|
c7c4ea3340 | ||
|
|
8c05ec4378 | ||
|
|
872e4eb4a1 | ||
|
|
dfe8f3138f | ||
|
|
0d38aea12c | ||
|
|
c0f7cc29fd | ||
|
|
f14fe02011 | ||
|
|
573f7f986a | ||
|
|
373a2f828d | ||
|
|
267d5b12f2 |
+14
-18
@@ -76,8 +76,6 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md'
|
|||||||
# depends on it.
|
# depends on it.
|
||||||
#
|
#
|
||||||
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
||||||
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
|
||||||
# compile on Alpine musl (off64_t is a glibc type).
|
|
||||||
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -167,9 +165,7 @@ RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \
|
|||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
# The image ships git and make, which the version step below uses.
|
||||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image
|
|
||||||
# ships git and make, which the version step below uses.
|
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||||
|
|
||||||
# Nothing is wanted from the lint and test phases or from the stylesheet and
|
# Nothing is wanted from the lint and test phases or from the stylesheet and
|
||||||
@@ -211,20 +207,11 @@ RUN version="$(make version VERSION="$VERSION")"; \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Builds through the Makefile's build target, which runs script/assets
|
# Builds through the Makefile's build target, which runs script/assets
|
||||||
# (Alpine.js, extracted from its tarball in 3p/) first.
|
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds
|
||||||
|
# with cgo off, so the binary is statically linked and runs on the Alpine
|
||||||
|
# runtime image below as it is.
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
# Rebuild with static linking for Alpine runtime.
|
|
||||||
# make build already verified compilation.
|
|
||||||
# The CGO binary from `make build` is dynamically linked against glibc,
|
|
||||||
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
|
||||||
# the binary runs on Alpine without glibc.
|
|
||||||
#
|
|
||||||
# The static flags go in through GO_LDFLAGS rather than a -ldflags of
|
|
||||||
# their own: the build target composes them with the -X that stamps the
|
|
||||||
# version, so this relink cannot silently drop the stamp.
|
|
||||||
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'
|
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-03-17
|
# alpine:3.21, 2026-03-17
|
||||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
@@ -268,8 +255,17 @@ EXPOSE 8080
|
|||||||
# port.
|
# port.
|
||||||
ENV BIND_ADDRESS=0.0.0.0
|
ENV BIND_ADDRESS=0.0.0.0
|
||||||
|
|
||||||
|
# The probe requests the container's own address, not loopback, so a
|
||||||
|
# listener that answers only on loopback (an empty or loopback
|
||||||
|
# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the
|
||||||
|
# container's IPv4 address, which 0.0.0.0 and :: both answer. On an
|
||||||
|
# IPv6-only network it prints none, and the probe requests the IPv6
|
||||||
|
# address /etc/hosts lists under the container's hostname instead, which
|
||||||
|
# only :: answers.
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \
|
||||||
|
[ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \
|
||||||
|
wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
CMD ["/app/webhooker"]
|
CMD ["/app/webhooker"]
|
||||||
|
|||||||
@@ -16,15 +16,6 @@ VERSION ?= $(shell script/version)
|
|||||||
# exists to correct.
|
# exists to correct.
|
||||||
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
||||||
|
|
||||||
# Extra linker flags for the build target. The static relink in the
|
|
||||||
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
|
|
||||||
# so composing flags cannot drop the version stamp.
|
|
||||||
#
|
|
||||||
# The build target itself always passes -trimpath and -s -w, as the Go
|
|
||||||
# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or
|
|
||||||
# debug information in the binary.
|
|
||||||
GO_LDFLAGS ?=
|
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
|
|
||||||
@@ -52,8 +43,12 @@ fmt-check:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
|
# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in
|
||||||
|
# REPO_POLICIES.md does: the binary carries no build paths, symbol table or
|
||||||
|
# debug information, and on Linux, the image's included, it is statically
|
||||||
|
# linked.
|
||||||
build: assets
|
build: assets
|
||||||
go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/webhooker
|
./bin/webhooker
|
||||||
|
|||||||
@@ -310,16 +310,17 @@ the namespace, whatever the process bound. Exposure is decided at the publish
|
|||||||
flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is
|
flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is
|
||||||
the operator's to choose and is what [Running with Docker](#running-with-docker)
|
the operator's to choose and is what [Running with Docker](#running-with-docker)
|
||||||
shows. A loopback bind inside a container buys nothing and makes the process
|
shows. A loopback bind inside a container buys nothing and makes the process
|
||||||
unreachable through its own published port.
|
unreachable through its own published port. A container on an IPv6-only Docker
|
||||||
|
network has no IPv4 address for `0.0.0.0` to answer on, so there it needs
|
||||||
|
`BIND_ADDRESS=::`.
|
||||||
|
|
||||||
The value must be an IP address literal:
|
The value must be an IP address literal:
|
||||||
|
|
||||||
- `127.0.0.1` — loopback only (the binary's default). Use this with a reverse
|
- `127.0.0.1` — loopback only (the binary's default). Use this with a reverse
|
||||||
proxy on the same host.
|
proxy on the same host.
|
||||||
- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, only
|
- `0.0.0.0` — every IPv4 address and no IPv6 address. The image's default; on a
|
||||||
behind a firewall on the port.
|
bare host, only behind a firewall on the port.
|
||||||
- `::` — every address, IPv6 and (on Linux, with the default
|
- `::` — every IPv6 address and every IPv4 address.
|
||||||
`net.ipv6.bindv6only=0`) IPv4 as well.
|
|
||||||
- A specific address such as `10.0.0.5` — that interface only.
|
- A specific address such as `10.0.0.5` — that interface only.
|
||||||
|
|
||||||
An **empty** value is treated as unset, as everywhere else here, and takes the
|
An **empty** value is treated as unset, as everywhere else here, and takes the
|
||||||
@@ -340,7 +341,8 @@ machine that has no such interface — is a valid literal, so it reaches the
|
|||||||
listener and fails there. The process logs the bind error and exits non-zero
|
listener and fails there. The process logs the bind error and exits non-zero
|
||||||
rather than staying up with nothing listening. The effective value is in the
|
rather than staying up with nothing listening. The effective value is in the
|
||||||
`bindAddress` field of the startup log line, which is the way to check what a
|
`bindAddress` field of the startup log line, which is the way to check what a
|
||||||
running deployment actually bound.
|
running deployment actually bound. On a bare host, `ss -ltn` lists the socket
|
||||||
|
itself as `0.0.0.0:8080` for a `0.0.0.0` bind and as `*:8080` for `::`.
|
||||||
|
|
||||||
#### Metrics credentials
|
#### Metrics credentials
|
||||||
|
|
||||||
@@ -668,9 +670,9 @@ Either set a literal or leave the variable out entirely.
|
|||||||
Overriding `BIND_ADDRESS` to a loopback address in a container — by that route
|
Overriding `BIND_ADDRESS` to a loopback address in a container — by that route
|
||||||
or deliberately — makes the container unreachable from outside its namespace
|
or deliberately — makes the container unreachable from outside its namespace
|
||||||
even with `-p`. The published port answers nothing, and the health check fails
|
even with `-p`. The published port answers nothing, and the health check fails
|
||||||
too: it requests `http://localhost:8080`, `localhost` resolves to `::1` first,
|
too: it requests the container's own address (its IPv4 address, or its IPv6
|
||||||
and a `127.0.0.1` bind is not listening there. The container then goes
|
address on an IPv6-only network), where a loopback bind is not listening. The
|
||||||
`unhealthy` about **65 seconds** after start — from
|
container then goes `unhealthy` about **65 seconds** after start — from
|
||||||
`HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at
|
`HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at
|
||||||
5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during
|
5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during
|
||||||
the start period has changed between versions; re-derive from those three values
|
the start period has changed between versions; re-derive from those three values
|
||||||
@@ -726,8 +728,8 @@ the credentials.
|
|||||||
address (directly, or through anything that can rewrite source addresses,
|
address (directly, or through anything that can rewrite source addresses,
|
||||||
such as NAT or a published container port). The `remoteIP` field of the
|
such as NAT or a published container port). The `remoteIP` field of the
|
||||||
`http request` log line for a request that came through the proxy shows
|
`http request` log line for a request that came through the proxy shows
|
||||||
that address; the health check's own lines show `::1`. See
|
that address; the health check's own lines show the container's own
|
||||||
[Trusted proxies](#trusted-proxies).
|
address. See [Trusted proxies](#trusted-proxies).
|
||||||
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS`
|
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS`
|
||||||
to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`.
|
to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`.
|
||||||
- Everything else is optional; see [Configuration](#configuration).
|
- Everything else is optional; see [Configuration](#configuration).
|
||||||
@@ -802,7 +804,12 @@ comes up, serves pages, and is broken in a way nothing reports.
|
|||||||
proxy's log names it regardless. nginx's default `combined` format already
|
proxy's log names it regardless. nginx's default `combined` format already
|
||||||
logs `$remote_addr`; do not replace it with one that drops the client
|
logs `$remote_addr`; do not replace it with one that drops the client
|
||||||
address, and retain those logs as long as you would want to answer a question
|
address, and retain those logs as long as you would want to answer a question
|
||||||
about traffic.
|
about traffic. That log also records the full request path, so it holds the
|
||||||
|
entrypoint UUID of every event a sender posts. webhooker's own log does not,
|
||||||
|
apart from the line a `log` target writes. Protect the proxy's log as you
|
||||||
|
would the entrypoint URLs themselves: anyone who can read it can post events
|
||||||
|
to every entrypoint in it (see
|
||||||
|
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)).
|
||||||
|
|
||||||
### nginx
|
### nginx
|
||||||
|
|
||||||
@@ -828,7 +835,9 @@ server {
|
|||||||
client_max_body_size 1m;
|
client_max_body_size 1m;
|
||||||
|
|
||||||
# $remote_addr is the client. webhooker's own log names it, as
|
# $remote_addr is the client. webhooker's own log names it, as
|
||||||
# clientIP, only while TRUSTED_PROXIES covers this proxy.
|
# clientIP, only while TRUSTED_PROXIES covers this proxy. The
|
||||||
|
# request path in this log holds the entrypoint UUID, so protect
|
||||||
|
# the file like the entrypoint URL itself.
|
||||||
access_log /var/log/nginx/webhooker.access.log combined;
|
access_log /var/log/nginx/webhooker.access.log combined;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -1160,7 +1169,16 @@ What that means for an operator:
|
|||||||
|
|
||||||
- **The URL is a capability, so treat it as a secret.** Keep it out of logs,
|
- **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
|
ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can
|
||||||
post events as that sender.
|
post events as that sender. Apart from a `log` target's line, webhooker's own
|
||||||
|
log lines never carry the UUID: they name a request to the receiver by the
|
||||||
|
route pattern `/h/{uuid}`, the `webhook request received` line names the
|
||||||
|
entrypoint by its `entrypoint_id`, and the access log keeps only the scheme
|
||||||
|
and host of a `Referer`. A `log` target writes the request's headers as
|
||||||
|
received, so a `Referer` naming the entrypoint URL appears in its line, as it
|
||||||
|
does when a sender has followed a redirect from `http://` to `https://`. The
|
||||||
|
reverse proxy's access log does carry the UUID, in the request path, so it
|
||||||
|
must be protected like the URL itself (see
|
||||||
|
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
||||||
- **Rotating means minting a new entrypoint, not changing a key.** There is no
|
- **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
|
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
|
deactivate it, which answers `410`) and create a new one, then point the
|
||||||
@@ -1235,34 +1253,37 @@ markup. The CSP build runs no expressions, so every Alpine directive in
|
|||||||
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
||||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
`x-data="{ open: false }"` or `@click="open = !open"`.
|
||||||
|
|
||||||
A browser test in `internal/server` loads the webhook page, its edit pages and
|
A browser test in `internal/server` loads the sign-in page, the webhook page,
|
||||||
the event log under the real policy and checks that: the add entrypoint form
|
its edit pages, the event log, the webhook list, the settings page, the profile
|
||||||
stays hidden until Add is clicked; for every target type, the targets section's
|
page and the `404` error page shown for a path that does not exist, under the
|
||||||
Add shows only a choice of type with Next and Cancel, Next shows only that
|
real policy and checks that: the add entrypoint form stays hidden until Add is
|
||||||
type's fields (no url field for `database` or `log`), Cancel at either step
|
clicked; for every target type, the targets section's Add shows only a choice of
|
||||||
closes the form, and saving adds the target; a refused target comes back with
|
type with Next and Cancel, Next shows only that type's fields (no url field for
|
||||||
its form open, the values entered and the reason, and after Cancel the next Add
|
`database` or `log`), Cancel at either step closes the form, and saving adds the
|
||||||
starts with an empty form and no reason; a refused save on the target edit page
|
target; a refused target comes back with its form open, the values entered and
|
||||||
and on the webhook edit page comes back with the reason and every value entered;
|
the reason, and after Cancel the next Add starts with an empty form and no
|
||||||
the Copy button beside an entrypoint URL reads "Copied" once clicked; an
|
reason; a refused save on the target edit page and on the webhook edit page
|
||||||
entrypoint's Edit button shows its edit form in place of its description and
|
comes back with the reason and every value entered; the Copy button beside an
|
||||||
hides until the form closes, Cancel hides the form and drops what was typed, as
|
entrypoint URL reads "Copied" once clicked; an entrypoint's Edit button shows
|
||||||
does leaving the page and going back to it, and Save changes the description; of
|
its edit form in place of its description and hides until the form closes,
|
||||||
the recent events on the webhook page only the newest starts expanded, each
|
Cancel hides the form and drops what was typed, as does leaving the page and
|
||||||
expands and collapses, and Open leads to the event's own page; of the events in
|
going back to it, and Save changes the description; of the recent events on the
|
||||||
the event log only the newest starts expanded, and an event there expands and
|
webhook page only the newest starts expanded, each expands and collapses, and
|
||||||
collapses when its row's caret or its ID is clicked, and from the keyboard, but
|
Open leads to the event's own page; of the events in the event log only the
|
||||||
not when its ID is selected with the mouse, and a delivery's attempts inside it
|
newest starts expanded, and an event there expands and collapses when its row's
|
||||||
expand and collapse; and at phone width the menu button opens and closes the
|
caret or its ID is clicked, and from the keyboard, but not when its ID is
|
||||||
mobile menu, and neither the webhook page nor the event log, with a delivery's
|
selected with the mouse, and a delivery's attempts inside it expand and
|
||||||
attempts open, scrolls sideways or cuts anything off at the page's or a card's
|
collapse; at phone width the menu button opens and closes the mobile menu, and
|
||||||
edge. It also fails if the browser reports a console warning or error, an
|
neither the webhook page nor the event log, with a delivery's attempts open,
|
||||||
uncaught exception, or anything the policy refused. `make check` and the image
|
scrolls sideways or cuts anything off at the page's or a card's edge; and a
|
||||||
build lint it but do not run it, and `make test` leaves it out (its file is
|
target's Delete asks first, naming the target, sends nothing when the answer is
|
||||||
built only with the `browser` build tag). Run it with `make test-browser` after
|
no and deletes the target when it is yes. It also fails if the browser reports a
|
||||||
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
|
console warning or error, an uncaught exception, or anything the policy refused.
|
||||||
runs the test in a digest-pinned headless browser image, so the host needs no
|
`make check` and the image build lint it but do not run it, and `make test`
|
||||||
browser.
|
leaves it out (its file is built only with the `browser` build tag). Run it with
|
||||||
|
`make test-browser` after changing `templates/` or `static/js/`: that builds
|
||||||
|
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
|
||||||
|
image, so the host needs no browser.
|
||||||
|
|
||||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
||||||
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||||
@@ -1295,7 +1316,9 @@ and commit the regenerated file with the change. Tailwind takes classes only
|
|||||||
from the files that `input.css` names in its `@source` lines; a class written in
|
from the files that `input.css` names in its `@source` lines; a class written in
|
||||||
any other file is not generated until that file is named there too. `make check`
|
any other file is not generated until that file is named there too. `make check`
|
||||||
and the image build fail when the committed file differs from what `make css`
|
and the image build fail when the committed file differs from what `make css`
|
||||||
generates. `static/css/style.css` is hand-written and is not generated.
|
generates. `static/css/style.css` is hand-written and is not generated. The
|
||||||
|
pages' Content-Security-Policy refuses inline styles, so a template styles an
|
||||||
|
element with a class, never a `style` attribute.
|
||||||
|
|
||||||
`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256
|
`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256
|
||||||
pinned in the Dockerfile's stylesheet stages; it is never installed on the host.
|
pinned in the Dockerfile's stylesheet stages; it is never installed on the host.
|
||||||
@@ -1317,20 +1340,22 @@ webhooker solves this by acting as a durable intermediary:
|
|||||||
nothing is lost if downstream targets are unavailable.
|
nothing is lost if downstream targets are unavailable.
|
||||||
|
|
||||||
2. **Guaranteed delivery** — Events are queued for delivery to each configured
|
2. **Guaranteed delivery** — Events are queued for delivery to each configured
|
||||||
target. Failed deliveries are retried with configurable backoff. Every
|
target. Failed deliveries are retried with configurable backoff. Delivery
|
||||||
delivery attempt is logged with status codes, response bodies, and timing.
|
attempts are logged with status codes, response bodies, and timing.
|
||||||
|
|
||||||
**That guarantee is at-least-once, not exactly-once.** When a send reaches
|
**That guarantee is at-least-once, not exactly-once.** When a send reaches
|
||||||
its target but the write recording that outcome fails, the delivery is
|
its target but the write recording that outcome fails, the delivery is
|
||||||
deliberately left in a recoverable state rather than marked done — losing a
|
deliberately left in a recoverable state rather than marked done — losing a
|
||||||
delivery is the worse failure — so the pending sweep picks it up about
|
delivery is the worse failure — so the pending sweep picks it up about
|
||||||
fifteen minutes later, or the next restart does, and the target receives a
|
fifteen minutes later, or the next restart does, and the target receives a
|
||||||
payload it already got. webhooker adds no delivery identifier of its own to
|
payload it already got. A send still in flight when the service stops is
|
||||||
an outbound request, so **make your receiver idempotent** against whatever
|
left the same way: its attempt is not recorded, and the next start sends it
|
||||||
the payload itself carries.
|
again (see Shutdown). webhooker adds no delivery identifier of its own to an
|
||||||
|
outbound request, so **make your receiver idempotent** against whatever the
|
||||||
|
payload itself carries.
|
||||||
|
|
||||||
3. **Observability** — Full request/response logging for every webhook received
|
3. **Observability** — Full request/response logging for every webhook received
|
||||||
and every delivery attempted. Prometheus metrics expose volume, latency, and
|
and for delivery attempts. Prometheus metrics expose volume, latency, and
|
||||||
error rates. The web UI provides real-time visibility into event flow.
|
error rates. The web UI provides real-time visibility into event flow.
|
||||||
|
|
||||||
4. **Fan-out** — A single incoming webhook can be delivered to multiple targets
|
4. **Fan-out** — A single incoming webhook can be delivered to multiple targets
|
||||||
@@ -1368,10 +1393,10 @@ It uses:
|
|||||||
management
|
management
|
||||||
- **[go-chi](https://github.com/go-chi/chi)** for HTTP routing
|
- **[go-chi](https://github.com/go-chi/chi)** for HTTP routing
|
||||||
- **[GORM](https://gorm.io)** for database access with
|
- **[GORM](https://gorm.io)** for database access with
|
||||||
**[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as the runtime
|
**[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)**, which is pure
|
||||||
SQLite driver. Note: `gorm.io/driver/sqlite` transitively depends on
|
Go, as the SQLite driver. `gorm.io/driver/sqlite` also imports
|
||||||
`mattn/go-sqlite3`, which requires CGO at build time (see [Docker](#docker)
|
`mattn/go-sqlite3`, whose stub for builds without cgo is linked into the
|
||||||
section)
|
binary but never used
|
||||||
- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with
|
- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with
|
||||||
TTY detection (text for dev, JSON for prod)
|
TTY detection (text for dev, JSON for prod)
|
||||||
- **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted
|
- **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted
|
||||||
@@ -1768,7 +1793,7 @@ is what bounds it.
|
|||||||
#### DeliveryResult
|
#### DeliveryResult
|
||||||
|
|
||||||
The result of a single delivery attempt. Every attempt (including retries) is
|
The result of a single delivery attempt. Every attempt (including retries) is
|
||||||
individually logged for full observability.
|
individually logged, except one cut off by a stop (see Shutdown).
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| --------------- | ------- | ----------------------------------------- |
|
| --------------- | ------- | ----------------------------------------- |
|
||||||
@@ -2110,9 +2135,7 @@ endpoint. That is the JSON key; the error text for a missing one reads
|
|||||||
`webhook_url is required`, which is the message, not the key.
|
`webhook_url is required`, which is the message, not the key.
|
||||||
|
|
||||||
The database uses the
|
The database uses the
|
||||||
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime,
|
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver.
|
||||||
though CGO is required at build time due to the transitive `mattn/go-sqlite3`
|
|
||||||
dependency from `gorm.io/driver/sqlite`.
|
|
||||||
|
|
||||||
#### Schema
|
#### Schema
|
||||||
|
|
||||||
@@ -2206,12 +2229,16 @@ This means:
|
|||||||
buffer rather than spawning more goroutines.
|
buffer rather than spawning more goroutines.
|
||||||
- **Independent results** — each worker records its own delivery result in the
|
- **Independent results** — each worker records its own delivery result in the
|
||||||
per-webhook database without coordination.
|
per-webhook database without coordination.
|
||||||
- **Graceful shutdown** — cancel the context, workers finish their current task
|
- **Graceful shutdown** — the stop hook cancels the workers' context. A worker
|
||||||
and exit. The stop hook waits for the pool via `lifecycle.WaitForShutdown`,
|
may still take tasks already queued before it exits. An `http` or `slack`
|
||||||
which bounds that wait by fx's stop timeout rather than blocking forever on a
|
request in flight is cut off, and one started after the cancel fails at once.
|
||||||
wedged worker. On timeout it logs at `ERROR` and returns an error, and the
|
Neither is recorded or counted in the metrics, and the next start sends the
|
||||||
goroutines that did not finish are still running — an unclean shutdown is
|
delivery again (see Shutdown). A `database` or `log` task runs as normal. The
|
||||||
reported rather than hidden.
|
stop hook waits for the pool via `lifecycle.WaitForShutdown`, which bounds
|
||||||
|
that wait by fx's stop timeout rather than blocking forever on a wedged
|
||||||
|
worker. On timeout it logs at `ERROR` and returns an error, and the goroutines
|
||||||
|
that did not finish are still running — an unclean shutdown is reported rather
|
||||||
|
than hidden.
|
||||||
|
|
||||||
**Recovery paths:**
|
**Recovery paths:**
|
||||||
|
|
||||||
@@ -2317,7 +2344,7 @@ getting anywhere.
|
|||||||
| Metric | Type | Meaning |
|
| Metric | Type | Meaning |
|
||||||
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard |
|
| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard |
|
||||||
| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` |
|
| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying`. An attempt cut off by a stop is not counted (see Shutdown) |
|
||||||
| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` |
|
| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` |
|
||||||
| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried |
|
| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried |
|
||||||
| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` |
|
| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` |
|
||||||
@@ -2416,27 +2443,32 @@ log volume it caps rather than eliminates. A path that names no entrypoint is
|
|||||||
recorded by the handler at `DEBUG`, and the aggregate limiter logs its own
|
recorded by the handler at `DEBUG`, and the aggregate limiter logs its own
|
||||||
rejections at `DEBUG` and without the path, so neither appears at all under the
|
rejections at `DEBUG` and without the path, so neither appears at all under the
|
||||||
default level. The per-entrypoint limiter is the loud one: it logs every
|
default level. The per-entrypoint limiter is the loud one: it logs every
|
||||||
rejection at `WARN` with the request path, which on this route is
|
rejection at `WARN`, naming the route pattern `/h/{uuid}` rather than the
|
||||||
attacker-controlled text. A client hammering a single invented path is served
|
request path. A client hammering a single invented path is served
|
||||||
`RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected
|
`RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected
|
||||||
there, so the aggregate limit is what bounds the _number_ of those `WARN` lines
|
there, so the aggregate limit is what bounds the _number_ of those `WARN` lines
|
||||||
— to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the
|
— to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the
|
||||||
defaults, where before it there was no bound at all. Their _width_ is bounded by
|
defaults, where before it there was no bound at all. The access log is bounded
|
||||||
the field budgets below, the same ones the access log spends. The access log is
|
by neither limit: every request is recorded once at `INFO`, served or rejected
|
||||||
bounded by neither limit: every request is recorded once at `INFO`, served or
|
alike.
|
||||||
rejected alike.
|
|
||||||
|
|
||||||
What the access log does bound is the _content_ of those lines. A 3xx or 4xx
|
What the access log does bound is the _content_ of those lines. On the receiver
|
||||||
response logs the chi route pattern — `/h/{uuid}`, `/user/{username}//`, or the
|
route it logs the route pattern `/h/{uuid}` whatever the status, because the
|
||||||
literal `(unmatched)` when the request hit no route at all — in place of the
|
concrete path carries the entrypoint UUID, which is the entrypoint's credential
|
||||||
concrete URL. Those are the outcomes an unauthenticated client can drive for
|
(see
|
||||||
free: 404 and 429 on any invented receiver path, a login redirect on any
|
[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)).
|
||||||
invented profile path. Logging the URL there would let a flood write text of its
|
A request chi never routed, because it matched no route or because a middleware
|
||||||
own choosing, at a length of its own choosing, into the log. 2xx and 5xx
|
answered it first, logs the literal `(unmatched)` whatever the status. In the
|
||||||
responses keep the concrete path — a success resolved against a static route or
|
`dev` environment the CORS middleware answers a preflight that way, so a
|
||||||
against the operator's own data (on the receiver, against a stored entrypoint
|
preflight to the receiver does not log its UUID either. On every other route, a
|
||||||
UUID), and a 5xx is a bug in this service, where the exact path is the evidence
|
3xx or 4xx response logs the chi route pattern, such as `/user/{username}//`, in
|
||||||
and no client can provoke one at will.
|
place of the concrete URL. Those are the outcomes an unauthenticated client can
|
||||||
|
drive for free: a 404 on any invented path, a login redirect on any invented
|
||||||
|
profile path. Logging the URL there would let a flood write text of its own
|
||||||
|
choosing, at a length of its own choosing, into the log. 2xx and 5xx responses
|
||||||
|
keep the concrete path — a success resolved against a static route or against
|
||||||
|
the operator's own data, and a 5xx is a bug in this service, where the exact
|
||||||
|
path is the evidence and no client can provoke one at will.
|
||||||
|
|
||||||
The query string is never logged; it is replaced by the fixed marker
|
The query string is never logged; it is replaced by the fixed marker
|
||||||
`?(redacted)`. It is client-chosen on every route, and
|
`?(redacted)`. It is client-chosen on every route, and
|
||||||
@@ -2448,6 +2480,11 @@ to, `notice`, which names the line a page shows after an action, and the event
|
|||||||
log's `show`, which picks the events it lists. A query string sent to an
|
log's `show`, which picks the events it lists. A query string sent to an
|
||||||
entrypoint is not lost either: the event stores it, and the event log shows it.
|
entrypoint is not lost either: the event stores it, and the event log shows it.
|
||||||
|
|
||||||
|
The `referer` field keeps only the scheme and host of the request's `Referer`,
|
||||||
|
and logs `(redacted)` for one without them, because its path can be an
|
||||||
|
entrypoint URL: a sender that follows the `http://` to `https://` redirect in
|
||||||
|
the nginx example sends the URL it was redirected from.
|
||||||
|
|
||||||
Client-supplied request content does not leave the host by the other route
|
Client-supplied request content does not leave the host by the other route
|
||||||
either. The Sentry SDK attaches the request to every event it captures,
|
either. The Sentry SDK attaches the request to every event it captures,
|
||||||
independently of the access log, and `SendDefaultPII=false` does not cover all
|
independently of the access log, and `SendDefaultPII=false` does not cover all
|
||||||
@@ -2457,14 +2494,18 @@ are both taken unconditionally, the body precisely because these handlers call
|
|||||||
body with `(redacted)`, drops cookies and the remote-address environment, and
|
body with `(redacted)`, drops cookies and the remote-address environment, and
|
||||||
reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
||||||
`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`.
|
`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`.
|
||||||
|
`Referer` is sent as its scheme and host only, because its path can be an
|
||||||
|
entrypoint URL; `internal/referer.SchemeAndHost` does that reduction for both
|
||||||
|
the access log's `referer` field and this hook.
|
||||||
|
|
||||||
The same hook rewrites the request URL. The SDK builds it as
|
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
|
`scheme://host/path` from the concrete path, which on the receiver route is
|
||||||
`/h/<uuid>` in full — and that UUID is a write capability, not an identifier:
|
`/h/<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
|
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
|
deliver. What is sent is the chi route pattern instead: `http://host/h/{uuid}`.
|
||||||
the rule the access log follows above does not carry across that boundary. What
|
A tracker has its own retention, access control and deletion policy, so the
|
||||||
is sent is the chi route pattern instead: `http://host/h/{uuid}`.
|
pattern is sent on every route, including the 2xx and 5xx responses outside the
|
||||||
|
receiver for which the access log keeps the concrete path.
|
||||||
|
|
||||||
The scheme and the host are kept, and everything else in the URL is discarded
|
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 appending a query string
|
rather than edited, so a future SDK version that starts appending a query string
|
||||||
@@ -2526,8 +2567,9 @@ receiver route. What survives still names the failing route — scheme, host,
|
|||||||
route pattern, method — and `X-Request-Id` ties the event to the local access
|
route pattern, method — and `X-Request-Id` ties the event to the local access
|
||||||
log line that holds the rest. Nothing dropped is needed for the likeliest use,
|
log line that holds the rest. Nothing dropped is needed for the likeliest use,
|
||||||
debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and
|
debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and
|
||||||
`Referer`; the latter two are kept, and the first is the scheme of the retained
|
`Referer`; the latter two are kept (`Referer` as its scheme and host, which is
|
||||||
URL, because the SDK derives that scheme from
|
all the check compares), and the first is the scheme of the retained URL,
|
||||||
|
because the SDK derives that scheme from
|
||||||
`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate
|
`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate
|
||||||
is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this
|
is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this
|
||||||
service now uses everywhere it decides transport: the SDK reports `http` for the
|
service now uses everywhere it decides transport: the SDK reports `http` for the
|
||||||
@@ -2593,9 +2635,7 @@ than it:
|
|||||||
| ------------------------------------------ | ------- | ------------------- | ------------------------------------------ |
|
| ------------------------------------------ | ------- | ------------------- | ------------------------------------------ |
|
||||||
| `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` |
|
| `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` |
|
||||||
| `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` |
|
| `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` |
|
||||||
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
|
|
||||||
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
|
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
|
||||||
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
|
|
||||||
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
|
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
|
||||||
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
|
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
|
||||||
| `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
|
| `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
|
||||||
@@ -2623,13 +2663,12 @@ wider of the two handlers spends more on than the client spent sending them.
|
|||||||
Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is
|
Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is
|
||||||
what the figure above states, and every row establishes it.
|
what the figure above states, and every row establishes it.
|
||||||
|
|
||||||
Three of the sites go further and bound the whole flood's output — the total
|
Two of the sites go further and bound the whole flood's output — the total bytes
|
||||||
bytes a run of distinct invented values wrote, which is the shape an operator
|
a run of distinct invented values wrote, which is the shape an operator sizing
|
||||||
sizing storage cares about. They are `request body exceeds limit`
|
storage cares about. They are `request body exceeds limit`
|
||||||
(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`),
|
(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`) and `user not found`
|
||||||
`entrypoint not found` and `user not found` (the last two through
|
(through `assertBoundedFlood`). The other rows carry no aggregate assertion; the
|
||||||
`assertBoundedFlood`). The other rows carry no aggregate assertion; the per-line
|
per-line ceiling is what they establish.
|
||||||
ceiling is what they establish.
|
|
||||||
|
|
||||||
`internal/logfield/logfield_test.go` measures the per-rune charge against what
|
`internal/logfield/logfield_test.go` measures the per-rune charge against what
|
||||||
the handlers really emit, over roughly 3,000 code points on each, so an
|
the handlers really emit, over roughly 3,000 code points on each, so an
|
||||||
@@ -2647,7 +2686,7 @@ everything else, so its lines take the level the operator set and the handler
|
|||||||
`internal/logger` selected, and every value it emits is spent through the same
|
`internal/logger` selected, and every value it emits is spent through the same
|
||||||
`internal/logfield` budget. A record-not-found is not logged as an error: it is
|
`internal/logfield` budget. A record-not-found is not logged as an error: it is
|
||||||
the expected outcome on both of those paths, and each handler already records
|
the expected outcome on both of those paths, and each handler already records
|
||||||
its own miss at `DEBUG` — bounded, per the table above — without the SQL. Slow
|
its own miss at `DEBUG`, without the SQL and within the ceiling above. Slow
|
||||||
statements are kept, at `WARN`, above the same 200 ms threshold GORM used and
|
statements are kept, at `WARN`, above the same 200 ms threshold GORM used and
|
||||||
with the statement bounded, because that report is the one thing GORM's logger
|
with the statement bounded, because that report is the one thing GORM's logger
|
||||||
gave an operator that nothing else here does. The adapter orders its cases
|
gave an operator that nothing else here does. The adapter orders its cases
|
||||||
@@ -3062,6 +3101,8 @@ webhooker/
|
|||||||
│ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate)
|
│ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate)
|
||||||
│ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore
|
│ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore
|
||||||
│ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages
|
│ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages
|
||||||
|
│ ├── referer/
|
||||||
|
│ │ └── referer.go # SchemeAndHost: cuts a Referer down to its scheme and host, for the access log and the Sentry hook
|
||||||
│ ├── reqtls/
|
│ ├── reqtls/
|
||||||
│ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto
|
│ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto
|
||||||
│ ├── server/
|
│ ├── server/
|
||||||
@@ -3076,8 +3117,8 @@ webhooker/
|
|||||||
│ ├── static.go # //go:embed directive
|
│ ├── static.go # //go:embed directive
|
||||||
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
||||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||||
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline
|
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline, the rule hiding x-cloak elements
|
||||||
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
|
│ ├── js/app.js # Copy-to-clipboard, the delete prompts, and the Alpine.js components
|
||||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
@@ -3131,8 +3172,9 @@ Applied to all routes in this order:
|
|||||||
|
|
||||||
1. **RequestID** — Generate unique request IDs (chi built-in)
|
1. **RequestID** — Generate unique request IDs (chi built-in)
|
||||||
2. **SecurityHeaders** — Production security headers on every response (HSTS
|
2. **SecurityHeaders** — Production security headers on every response (HSTS
|
||||||
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP,
|
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP
|
||||||
Referrer-Policy, Permissions-Policy)
|
`default-src 'self'; script-src 'self'; style-src 'self'`, Referrer-Policy,
|
||||||
|
Permissions-Policy)
|
||||||
3. **Logging** — Structured request logging (method, URL, status, latency,
|
3. **Logging** — Structured request logging (method, URL, status, latency,
|
||||||
remote IP, client IP, user agent, request ID)
|
remote IP, client IP, user agent, request ID)
|
||||||
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
||||||
@@ -3217,6 +3259,12 @@ credential check, see [The login endpoint](#the-login-endpoint).
|
|||||||
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
|
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
|
||||||
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
|
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
|
||||||
Content-Security-Policy, Referrer-Policy, and Permissions-Policy
|
Content-Security-Policy, Referrer-Policy, and Permissions-Policy
|
||||||
|
- The Content-Security-Policy is
|
||||||
|
`default-src 'self'; script-src 'self'; style-src 'self'`: a page takes
|
||||||
|
scripts and styles only from files webhooker serves, and the browser runs no
|
||||||
|
inline script or event handler and ignores any `<style>` element or `style`
|
||||||
|
attribute in a page. The pages need none: their scripts are in `static/js/`
|
||||||
|
and their styles in `static/css/`
|
||||||
- Request body size limits (1 MB) on all form POST endpoints, enforced by
|
- Request body size limits (1 MB) on all form POST endpoints, enforced by
|
||||||
middleware that runs before CSRF parses the form
|
middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
|
||||||
@@ -3284,7 +3332,8 @@ stop-hook log:
|
|||||||
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3
|
3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3
|
||||||
seconds**) and by what the hooks before it left, then a Sentry flush if
|
seconds**) and by what the hooks before it left, then a Sentry flush if
|
||||||
`SENTRY_DSN` is set
|
`SENTRY_DSN` is set
|
||||||
4. `delivery.Engine` — waits for its workers, then closes the archive databases
|
4. `delivery.Engine` — cuts off any `http` or `slack` request in flight, waits
|
||||||
|
for its workers, then closes the archive databases
|
||||||
5. `healthcheck`
|
5. `healthcheck`
|
||||||
6. `WebhookDBManager`
|
6. `WebhookDBManager`
|
||||||
7. the database close
|
7. the database close
|
||||||
@@ -3295,6 +3344,12 @@ by the stop context, so a wedge there consumes the 5 seconds before the HTTP
|
|||||||
server hook is ever entered. The hooks after the server are microsecond-scale in
|
server hook is ever entered. The hooks after the server are microsecond-scale in
|
||||||
normal operation.
|
normal operation.
|
||||||
|
|
||||||
|
The delivery engine cancels a delivery request in flight. That attempt is not
|
||||||
|
recorded or counted in the metrics, and the delivery keeps its `pending` or
|
||||||
|
`retrying` status, so startup recovery sends it again on the next start (see
|
||||||
|
Recovery paths under Bounded Worker Pool). If the receiver already had it, it
|
||||||
|
receives it twice: the at-least-once duplicate described under Rationale.
|
||||||
|
|
||||||
The HTTP drain budget is deliberately **shorter** than the sequence budget. Were
|
The HTTP drain budget is deliberately **shorter** than the sequence budget. Were
|
||||||
the two equal, a drain that used its whole budget would exhaust the sequence
|
the two equal, a drain that used its whole budget would exhaust the sequence
|
||||||
budget at the instant it finished, and every later hook — the delivery engine,
|
budget at the instant it finished, and every later hook — the delivery engine,
|
||||||
@@ -3417,14 +3472,12 @@ fixed independently of the compiler's:
|
|||||||
a step's log, and then fails. `make test` builds this stage alone.
|
a step's log, and then fails. `make test` builds this stage alone.
|
||||||
6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test
|
6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test
|
||||||
phases and the `css-check` and `markdown-check` stages passing (it copies a
|
phases and the `css-check` and `markdown-check` stages passing (it copies a
|
||||||
file from each), runs `make build` (which extracts Alpine.js from `3p/`
|
file from each), and runs `make build` (which extracts Alpine.js from `3p/`
|
||||||
first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
|
first). `make build` compiles with cgo off, so the binary is statically
|
||||||
so it runs on musl. Both builds go through `make build`, the relink adding
|
linked and runs on the Alpine runtime image. The version is the `VERSION`
|
||||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps
|
build arg if one is given, otherwise derived from the `.git` in the context,
|
||||||
the version. The version is the `VERSION` build arg if one is given,
|
and the stage fails if a context with `.git` would stamp an empty version,
|
||||||
otherwise derived from the `.git` in the context, and the stage fails if a
|
`dev` or `unknown` (see [Version stamping](#version-stamping)).
|
||||||
context with `.git` would stamp an empty version, `dev` or `unknown` (see
|
|
||||||
[Version stamping](#version-stamping)).
|
|
||||||
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
7. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
|
||||||
all SQLite databases, exposes port 8080, and includes a health check against
|
all SQLite databases, exposes port 8080, and includes a health check against
|
||||||
@@ -3436,11 +3489,6 @@ The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly
|
|||||||
rather than `make fmt-check`, `make lint` and `make test`: those targets build
|
rather than `make fmt-check`, `make lint` and `make test`: those targets build
|
||||||
docker stages, which would need a docker daemon inside this build.
|
docker stages, which would need a docker daemon inside this build.
|
||||||
|
|
||||||
The lint phase and the Go stages use Debian rather than Alpine because
|
|
||||||
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does
|
|
||||||
not compile against musl. Only the final binary is statically linked, which is
|
|
||||||
what lets it run on the Alpine runtime image.
|
|
||||||
|
|
||||||
`script/cibuild` is the CI gate: it runs `script/bootstrap`, then
|
`script/cibuild` is the CI gate: it runs `script/bootstrap`, then
|
||||||
`script/check`, then builds the image, whose build runs the lint and test phases
|
`script/check`, then builds the image, whose build runs the lint and test phases
|
||||||
and the stylesheet and Markdown checks again. A build that succeeds is a repo
|
and the stylesheet and Markdown checks again. A build that succeeds is a repo
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ type Event struct {
|
|||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
// an operator resubmit. It is nil for an event that arrived on
|
// an operator resubmit. It is nil for an event that arrived on
|
||||||
// the receiver, which is every event created before the column
|
// the receiver. It is not a foreign key: the source event can be
|
||||||
// existed. It is not a foreign key: the source event can be
|
|
||||||
// reaped by retention while its copies remain, and the id is
|
// reaped by retention while its copies remain, and the id is
|
||||||
// kept as the record of where the copy came from either way.
|
// kept as the record of where the copy came from either way.
|
||||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ type Webhook struct {
|
|||||||
// value while building the insert statement, which runs after
|
// value while building the insert statement, which runs after
|
||||||
// BeforeSave; rewriting any later than this loses that race and the
|
// BeforeSave; rewriting any later than this loses that race and the
|
||||||
// row lands at 30 days. Living on the model also means a future call
|
// row lands at 30 days. Living on the model also means a future call
|
||||||
// site — a REST API, a fixture, a migration — cannot bypass it.
|
// site, such as a REST API or a fixture, cannot bypass it.
|
||||||
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
||||||
if w.RetentionDays <= 0 {
|
if w.RetentionDays <= 0 {
|
||||||
w.RetentionDays = RetentionForeverDays
|
w.RetentionDays = RetentionForeverDays
|
||||||
@@ -98,8 +98,8 @@ func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
|||||||
// are exempt from reaping.
|
// are exempt from reaping.
|
||||||
//
|
//
|
||||||
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
||||||
// and, defensively, the non-positive values that rows written before
|
// and, defensively, any non-positive value, which BeforeSave treats
|
||||||
// the sentinel existed may still carry.
|
// as a request to retain forever.
|
||||||
func retainsForever(retentionDays int) bool {
|
func retainsForever(retentionDays int) bool {
|
||||||
return retentionDays <= 0 ||
|
return retentionDays <= 0 ||
|
||||||
retentionDays >= RetentionForeverDays
|
retentionDays >= RetentionForeverDays
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
// row in the database: the sweep then deletes every event, delivery,
|
// row in the database: the sweep then deletes every event, delivery,
|
||||||
// and delivery result, including ones created seconds ago. Rejecting
|
// and delivery result, including ones created seconds ago. Rejecting
|
||||||
// out-of-range input at the form is the primary guard; saturating here
|
// out-of-range input at the form is the primary guard; saturating here
|
||||||
// means an old row, a migration, or a future call site cannot turn a
|
// means a value that reaches the row by any other path cannot turn a
|
||||||
// too-large retention into total data loss.
|
// too-large retention into total data loss.
|
||||||
func retentionCutoff(
|
func retentionCutoff(
|
||||||
now time.Time,
|
now time.Time,
|
||||||
|
|||||||
@@ -118,9 +118,8 @@ const SQLiteFilePerm fs.FileMode = 0o600
|
|||||||
// SQLite still reports the absence rather than this function
|
// SQLite still reports the absence rather than this function
|
||||||
// materializing an empty database the caller asked not to create.
|
// materializing an empty database the caller asked not to create.
|
||||||
//
|
//
|
||||||
// Chmod of a file that already exists is what tightens a data
|
// The mode is set on every open, whoever created the file, so a
|
||||||
// directory an earlier build left at 0644 — including a developer's
|
// database or sidecar found at a wider mode is tightened too.
|
||||||
// own scratch directory — without any migration machinery.
|
|
||||||
func reserveSQLiteFile(path string, create bool) error {
|
func reserveSQLiteFile(path string, create bool) error {
|
||||||
if create {
|
if create {
|
||||||
// gosec G304: the path is the database file the caller asked
|
// gosec G304: the path is the database file the caller asked
|
||||||
|
|||||||
@@ -201,8 +201,7 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
|||||||
|
|
||||||
// sweepTarget prunes the archive of a single database target.
|
// sweepTarget prunes the archive of a single database target.
|
||||||
// A missing, empty, or "never" expiry parses as a zero duration
|
// A missing, empty, or "never" expiry parses as a zero duration
|
||||||
// and is skipped entirely, so those archives keep exactly the
|
// and is skipped entirely, so those archives are never pruned.
|
||||||
// behaviour they had before the sweep existed.
|
|
||||||
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
||||||
expiry, err := parseArchiveExpiry(target.Config)
|
expiry, err := parseArchiveExpiry(target.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ func targetTotals(
|
|||||||
|
|
||||||
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
||||||
// write records for the webhook page's statistics: the time a delivery
|
// write records for the webhook page's statistics: the time a delivery
|
||||||
// finished, set only when it becomes delivered or failed, and one more
|
// finished, set only when it becomes delivered or failed and stored in
|
||||||
// on its target's delivered or failed total.
|
// the same form as created_at, and one more on its target's delivered
|
||||||
|
// or failed total.
|
||||||
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -75,6 +76,16 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
|||||||
if tt.finished {
|
if tt.finished {
|
||||||
require.NotNil(t, stored.FinishedAt)
|
require.NotNil(t, stored.FinishedAt)
|
||||||
assert.False(t, stored.FinishedAt.Before(before))
|
assert.False(t, stored.FinishedAt.Before(before))
|
||||||
|
|
||||||
|
var createdAt, finishedAt string
|
||||||
|
|
||||||
|
require.NoError(t, db.Raw(
|
||||||
|
`SELECT CAST(created_at AS TEXT),
|
||||||
|
CAST(finished_at AS TEXT)
|
||||||
|
FROM deliveries WHERE id = ?`, d.ID,
|
||||||
|
).Row().Scan(&createdAt, &finishedAt))
|
||||||
|
assertStoredTime(t, createdAt)
|
||||||
|
assertStoredTime(t, finishedAt)
|
||||||
} else {
|
} else {
|
||||||
assert.Nil(t, stored.FinishedAt)
|
assert.Nil(t, stored.FinishedAt)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1702,7 +1702,10 @@ func writeDeliveryStatus(
|
|||||||
}).
|
}).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"finished_at": time.Now(),
|
// GORM's clock, the one that stamps created_at. A bare
|
||||||
|
// time.Now() carries a monotonic clock reading, which the
|
||||||
|
// driver would store as an " m=+…" suffix.
|
||||||
|
"finished_at": tx.NowFunc(),
|
||||||
})
|
})
|
||||||
if res.Error != nil || res.RowsAffected == 0 {
|
if res.Error != nil || res.RowsAffected == 0 {
|
||||||
return res.Error
|
return res.Error
|
||||||
@@ -1906,12 +1909,13 @@ func (e *Engine) takeForRedispatch(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GORM's clock, as writeDeliveryStatus explains.
|
||||||
res := webhookDB.
|
res := webhookDB.
|
||||||
Model(&database.Delivery{}).
|
Model(&database.Delivery{}).
|
||||||
Where(
|
Where(
|
||||||
"id = ? AND status = ?", deliveryID, eligible,
|
"id = ? AND status = ?", deliveryID, eligible,
|
||||||
).
|
).
|
||||||
UpdateColumn("updated_at", time.Now())
|
UpdateColumn("updated_at", webhookDB.NowFunc())
|
||||||
|
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
e.log.Error(
|
e.log.Error(
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package delivery_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -272,6 +277,154 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
|
|||||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedHeldDelivery gives the setup's webhook an http target with
|
||||||
|
// maxRetries, pointed at a receiver that keeps every request open until
|
||||||
|
// the client gives up on it, and seeds one delivery to that target at
|
||||||
|
// status. It returns the task that sends attempt attemptNum of the
|
||||||
|
// delivery, and a channel that is closed when a request reaches the
|
||||||
|
// receiver.
|
||||||
|
func seedHeldDelivery(
|
||||||
|
t *testing.T,
|
||||||
|
s iSetup,
|
||||||
|
maxRetries int,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
attemptNum int,
|
||||||
|
) (delivery.Task, <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
arrived := make(chan struct{})
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
// The server notices the client hanging up only once the
|
||||||
|
// body has been read; until then r.Context() never ends.
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
|
||||||
|
once.Do(func() { close(arrived) })
|
||||||
|
|
||||||
|
<-r.Context().Done()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
cfg := iHTTPConfig(ts.URL)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
|
||||||
|
iCreateTarget(
|
||||||
|
t, s.MainDB, targetID, s.WebhookID, "held",
|
||||||
|
database.TargetTypeHTTP, cfg, maxRetries,
|
||||||
|
)
|
||||||
|
|
||||||
|
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
|
||||||
|
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
|
||||||
|
|
||||||
|
bodyStr := event.Body
|
||||||
|
|
||||||
|
return iTask(
|
||||||
|
d, event, s.WebhookID, targetID, "held", cfg,
|
||||||
|
maxRetries, attemptNum, &bodyStr,
|
||||||
|
), arrived
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopDuringAttempt waits until the receiver holds an attempt open, then
|
||||||
|
// stops the engine through its real stop hook.
|
||||||
|
func stopDuringAttempt(
|
||||||
|
t *testing.T, lc *recordingLifecycle, arrived <-chan struct{},
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-arrived:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the delivery attempt never reached the receiver")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireRequeued runs restart recovery on the stopped engine and
|
||||||
|
// requires it to queue task's attempt again on ch.
|
||||||
|
func requireRequeued(
|
||||||
|
t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s.Engine.ExportRecoverWebhookDeliveries(
|
||||||
|
context.Background(), s.WebhookID,
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-ch:
|
||||||
|
assert.Equal(t, task.DeliveryID, got.DeliveryID)
|
||||||
|
assert.Equal(t, task.AttemptNum, got.AttemptNum)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("restart recovery did not queue the delivery again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEngine_StopLeavesFireAndForgetAttemptPending is the regression
|
||||||
|
// test for a stop that turned an attempt in flight into a terminal
|
||||||
|
// failure. Stopping the engine cancels the request; for a target with
|
||||||
|
// no retries that was the delivery's only attempt, recorded as failed
|
||||||
|
// with "context canceled", and nothing ever sent it again. The
|
||||||
|
// delivery must instead stay pending, with no result and no count in
|
||||||
|
// the attempt metrics, for restart recovery to send.
|
||||||
|
func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
reg := mIsolate(t, s)
|
||||||
|
lc := startEngineViaHook(t, s.Engine)
|
||||||
|
|
||||||
|
task, arrived := seedHeldDelivery(
|
||||||
|
t, s, 0, database.DeliveryStatusPending, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.Engine.Notify([]delivery.Task{task})
|
||||||
|
stopDuringAttempt(t, lc, arrived)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
assert.Empty(t, iResults(t, s.WebhookDB, task.DeliveryID))
|
||||||
|
assert.Zero(t, mCounter(t, reg, mAttempts, mTypeHTTP))
|
||||||
|
assert.Zero(t, mHTTPDurations(t, reg))
|
||||||
|
|
||||||
|
requireRequeued(t, s, s.Engine.ExportDeliveryCh(), task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression
|
||||||
|
// for a target with retries, on its last attempt: the cancelled attempt
|
||||||
|
// used to exhaust the retries and end the delivery failed. It must stay
|
||||||
|
// retrying with only its earlier attempt recorded, and restart recovery
|
||||||
|
// must send that last attempt again.
|
||||||
|
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const maxRetries = 2
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
lc := startEngineViaHook(t, s.Engine)
|
||||||
|
|
||||||
|
task, arrived := seedHeldDelivery(
|
||||||
|
t, s, maxRetries, database.DeliveryStatusRetrying, maxRetries,
|
||||||
|
)
|
||||||
|
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
|
||||||
|
|
||||||
|
s.Engine.ExportScheduleRetry(task, 0)
|
||||||
|
stopDuringAttempt(t, lc, arrived)
|
||||||
|
|
||||||
|
iAssertStatus(
|
||||||
|
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
assert.Len(t, iResults(t, s.WebhookDB, task.DeliveryID), 1)
|
||||||
|
|
||||||
|
requireRequeued(t, s, s.Engine.ExportRetryCh(), task)
|
||||||
|
}
|
||||||
|
|
||||||
// deliverToArchive gives the setup's webhook a database target,
|
// deliverToArchive gives the setup's webhook a database target,
|
||||||
// runs one delivery to it through the running engine, and returns
|
// runs one delivery to it through the running engine, and returns
|
||||||
// the target's ID and archive file path. The archive writer holds
|
// the target's ID and archive file path. The archive writer holds
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ func seedDelivery(
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
|
||||||
|
// is in the form GORM stores created_at in: the layout of
|
||||||
|
// time.Time.String, with no " m=+…" monotonic clock reading after it.
|
||||||
|
func assertStoredTime(t *testing.T, text string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := time.Parse(
|
||||||
|
"2006-01-02 15:04:05.999999999 -0700 MST", text,
|
||||||
|
)
|
||||||
|
assert.NoError(t, err, "stored as %q", text)
|
||||||
|
}
|
||||||
|
|
||||||
// httpDeliveryFixture holds the task and delivery needed
|
// httpDeliveryFixture holds the task and delivery needed
|
||||||
// for HTTP delivery tests.
|
// for HTTP delivery tests.
|
||||||
type httpDeliveryFixture struct {
|
type httpDeliveryFixture struct {
|
||||||
|
|||||||
@@ -462,7 +462,8 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
|
|||||||
|
|
||||||
// TestRedispatchStampsTheRow pins the cadence control: a stranded
|
// TestRedispatchStampsTheRow pins the cadence control: a stranded
|
||||||
// delivery that has just been handed out is not selected again by the
|
// delivery that has just been handed out is not selected again by the
|
||||||
// next tick a minute later.
|
// next tick a minute later. The stamp is stored in the same form as
|
||||||
|
// created_at.
|
||||||
func TestRedispatchStampsTheRow(t *testing.T) {
|
func TestRedispatchStampsTheRow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -493,4 +494,13 @@ func TestRedispatchStampsTheRow(t *testing.T) {
|
|||||||
"a re-dispatched delivery must be stamped so the "+
|
"a re-dispatched delivery must be stamped so the "+
|
||||||
"next tick does not select it again",
|
"next tick does not select it again",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.Raw(
|
||||||
|
`SELECT CAST(created_at AS TEXT), CAST(updated_at AS TEXT)
|
||||||
|
FROM deliveries WHERE id = ?`, d.ID,
|
||||||
|
).Row().Scan(&createdAt, &updatedAt))
|
||||||
|
assertStoredTime(t, createdAt)
|
||||||
|
assertStoredTime(t, updatedAt)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ type Scheduler interface {
|
|||||||
// Engine.observeAttempt, alongside the DeliveryResult it records for
|
// Engine.observeAttempt, alongside the DeliveryResult it records for
|
||||||
// it. Deliver is also entered for attempts that never happen — an
|
// it. Deliver is also entered for attempts that never happen — an
|
||||||
// open circuit breaker refuses one — so the count cannot be taken
|
// open circuit breaker refuses one — so the count cannot be taken
|
||||||
// from around this call.
|
// from around this call. An attempt cut off by a stop is neither
|
||||||
|
// counted nor recorded.
|
||||||
type Target interface {
|
type Target interface {
|
||||||
Deliver(
|
Deliver(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -277,7 +277,11 @@ func (w *archiveWriter) write(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row.ArchivedAt = time.Now()
|
// GORM's clock, the one that stamps created_at in the other
|
||||||
|
// databases, so this is stored in the same form. A bare time.Now()
|
||||||
|
// carries a monotonic clock reading, which the driver would store
|
||||||
|
// as an " m=+…" suffix.
|
||||||
|
row.ArchivedAt = w.db.NowFunc()
|
||||||
|
|
||||||
err := w.db.Create(&row).Error
|
err := w.db.Create(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -150,6 +150,13 @@ func TestArchiveWriter_WritesRow(t *testing.T) {
|
|||||||
assert.Equal(t, row.ContentType, got[0].ContentType)
|
assert.Equal(t, row.ContentType, got[0].ContentType)
|
||||||
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
||||||
assert.False(t, got[0].ArchivedAt.IsZero())
|
assert.False(t, got[0].ArchivedAt.IsZero())
|
||||||
|
|
||||||
|
var archivedAt string
|
||||||
|
|
||||||
|
require.NoError(t, w.DB().Raw(
|
||||||
|
"SELECT CAST(archived_at AS TEXT) FROM archived_events",
|
||||||
|
).Row().Scan(&archivedAt))
|
||||||
|
assertStoredTime(t, archivedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestArchiveWriter_RecreatesAfterRemoval(
|
func TestArchiveWriter_RecreatesAfterRemoval(
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ func isReservedTargetHeader(name string) bool {
|
|||||||
// one pair silently overwriting the other.
|
// one pair silently overwriting the other.
|
||||||
//
|
//
|
||||||
// An input with no pairs yields an empty map, which omitempty drops
|
// An input with no pairs yields an empty map, which omitempty drops
|
||||||
// from the stored config: a target configured with no headers keeps
|
// from the stored config, so a target configured with no headers
|
||||||
// the same config JSON it had before this field existed.
|
// stores no headers key at all.
|
||||||
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ type httpCore struct {
|
|||||||
// recorded and no circuit breaker is consulted. A positive
|
// recorded and no circuit breaker is consulted. A positive
|
||||||
// maxRetries gates the attempt on the circuit breaker and
|
// maxRetries gates the attempt on the circuit breaker and
|
||||||
// schedules a backed-off retry on failure.
|
// schedules a backed-off retry on failure.
|
||||||
|
//
|
||||||
|
// ctx is the context attempt sends its request with. Stopping the
|
||||||
|
// engine cancels it; see interruptedByStop.
|
||||||
func (c *httpCore) deliver(
|
func (c *httpCore) deliver(
|
||||||
|
ctx context.Context,
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
task *Task,
|
task *Task,
|
||||||
@@ -66,21 +70,55 @@ func (c *httpCore) deliver(
|
|||||||
attempt func() attemptResult,
|
attempt func() attemptResult,
|
||||||
) {
|
) {
|
||||||
if maxRetries == 0 {
|
if maxRetries == 0 {
|
||||||
c.fireAndForget(webhookDB, d, attempt())
|
c.fireAndForget(ctx, webhookDB, d, attempt())
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.withRetry(
|
c.withRetry(
|
||||||
webhookDB, d, task, sched, maxRetries, attempt,
|
ctx, webhookDB, d, task, sched, maxRetries, attempt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// interruptedByStop reports whether an attempt failed because the
|
||||||
|
// engine is stopping, which cancels the context its request is made
|
||||||
|
// with. Such an attempt is not recorded: no DeliveryResult, no circuit
|
||||||
|
// breaker outcome, no status change, no count in the attempt metrics.
|
||||||
|
// The delivery stays pending or retrying, and startup recovery sends
|
||||||
|
// it again on the next start.
|
||||||
|
//
|
||||||
|
// A target's own timeout leaves that context alone, so its attempt is
|
||||||
|
// still recorded as the failure it is.
|
||||||
|
func (c *httpCore) interruptedByStop(
|
||||||
|
ctx context.Context,
|
||||||
|
d *database.Delivery,
|
||||||
|
res attemptResult,
|
||||||
|
) bool {
|
||||||
|
if res.success || ctx.Err() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
c.eng.log.Warn(
|
||||||
|
"delivery attempt interrupted by shutdown; not recorded, "+
|
||||||
|
"it is sent again on the next start",
|
||||||
|
"delivery_id", d.ID,
|
||||||
|
"event_id", d.EventID,
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (c *httpCore) fireAndForget(
|
func (c *httpCore) fireAndForget(
|
||||||
|
ctx context.Context,
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
res attemptResult,
|
res attemptResult,
|
||||||
) {
|
) {
|
||||||
|
if c.interruptedByStop(ctx, d, res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||||
|
|
||||||
err := c.eng.recordResult(
|
err := c.eng.recordResult(
|
||||||
@@ -110,6 +148,7 @@ func (c *httpCore) fireAndForget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *httpCore) withRetry(
|
func (c *httpCore) withRetry(
|
||||||
|
ctx context.Context,
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
d *database.Delivery,
|
d *database.Delivery,
|
||||||
task *Task,
|
task *Task,
|
||||||
@@ -131,6 +170,10 @@ func (c *httpCore) withRetry(
|
|||||||
|
|
||||||
res := attempt()
|
res := attempt()
|
||||||
|
|
||||||
|
if c.interruptedByStop(ctx, d, res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||||
|
|
||||||
err := c.eng.recordResult(
|
err := c.eng.recordResult(
|
||||||
@@ -395,7 +438,7 @@ func (t *httpTarget) Deliver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.deliver(
|
t.deliver(
|
||||||
webhookDB, d, task, sched,
|
ctx, webhookDB, d, task, sched,
|
||||||
d.Target.MaxRetries, attempt,
|
d.Target.MaxRetries, attempt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
const RedactionMarker = "(redacted)"
|
const RedactionMarker = "(redacted)"
|
||||||
|
|
||||||
// Redactor removes one target's own credential material from
|
// Redactor removes one target's own credential material from
|
||||||
// text that target's remote peer chose: a delivery response
|
// a delivery response body, whose text the target's remote
|
||||||
// body, or a delivery error stored before the delivery path
|
// peer chose, and from a stored delivery error as a second
|
||||||
// learned to mask the URLs it embeds.
|
// line behind the delivery path's own URL masking.
|
||||||
//
|
//
|
||||||
// It removes byte-identical echoes of strings taken from the
|
// It removes byte-identical echoes of strings taken from the
|
||||||
// target's stored configuration, and nothing else. Anything
|
// target's stored configuration, and nothing else. Anything
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (t *slackTarget) Deliver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.deliver(
|
t.deliver(
|
||||||
webhookDB, d, task, sched,
|
ctx, webhookDB, d, task, sched,
|
||||||
d.Target.MaxRetries, attempt,
|
d.Target.MaxRetries, attempt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func openDB(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// assertBounded holds every line the adapter wrote to the stated
|
// assertBounded holds every line the adapter wrote to the stated
|
||||||
// ceiling and proves each was cut rather than merely short.
|
// ceiling and proves no line carried a whole client value.
|
||||||
func assertBounded(t *testing.T, out string) {
|
func assertBounded(t *testing.T, out string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ import (
|
|||||||
//
|
//
|
||||||
// The bound is enforced here and in SQL, because this page's
|
// The bound is enforced here and in SQL, because this page's
|
||||||
// memory profile must not depend on a constant in another
|
// memory profile must not depend on a constant in another
|
||||||
// package staying where it is, and because rows predating the
|
// package staying where it is, or on every stored row having
|
||||||
// delivery engine's own cap or restored from an archive are
|
// been written by the delivery engine under its own cap.
|
||||||
// not covered by it at all.
|
|
||||||
//
|
//
|
||||||
// It happens to equal that engine cap, so a row written by the
|
// It happens to equal that engine cap, so a row written by the
|
||||||
// current engine reaches this one exactly and is never cut
|
// engine reaches this one exactly and is never cut twice.
|
||||||
// twice. Nothing here may assume the two differ: see view.
|
// Nothing here may assume the two differ: see view.
|
||||||
const maxRenderedResponseBytes = 4096
|
const maxRenderedResponseBytes = 4096
|
||||||
|
|
||||||
// deliveryResultColumns is the delivery attempt projection.
|
// deliveryResultColumns is the delivery attempt projection.
|
||||||
@@ -40,9 +39,8 @@ const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
|
|||||||
//
|
//
|
||||||
// Both Error and ResponseBody have been through the target's
|
// Both Error and ResponseBody have been through the target's
|
||||||
// Redactor. The engine already masks the URL out of the
|
// Redactor. The engine already masks the URL out of the
|
||||||
// errors it stores, so for errors this is a second line
|
// errors it stores, so for errors this is a second line; for
|
||||||
// covering rows written before it did; for response bodies it
|
// response bodies it is the only line, and its reach is what
|
||||||
// is the only line, and its reach is what
|
|
||||||
// delivery.Redactor documents.
|
// delivery.Redactor documents.
|
||||||
type DeliveryResultView struct {
|
type DeliveryResultView struct {
|
||||||
AttemptNum int
|
AttemptNum int
|
||||||
|
|||||||
@@ -158,8 +158,7 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
|
|||||||
// TestHandleSourceLogs_EscapesResponseBody proves the
|
// TestHandleSourceLogs_EscapesResponseBody proves the
|
||||||
// response body is treated as the untrusted remote content it
|
// response body is treated as the untrusted remote content it
|
||||||
// is. The remote chooses these bytes and the page is rendered
|
// is. The remote chooses these bytes and the page is rendered
|
||||||
// inside the operator's authenticated origin, where the
|
// inside the operator's authenticated origin.
|
||||||
// application's own CSP allows inline script from 'self'.
|
|
||||||
func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
|
func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,10 @@ const eventBodyQuery = "SELECT cast(body as blob) " +
|
|||||||
// response is deliberately not renderable. Content-Disposition
|
// response is deliberately not renderable. Content-Disposition
|
||||||
// makes the browser download rather than display it, and the
|
// makes the browser download rather than display it, and the
|
||||||
// octet-stream type plus nosniff stop it being interpreted as
|
// octet-stream type plus nosniff stop it being interpreted as
|
||||||
// HTML or script. Without those a stored payload would execute
|
// HTML or script. Without those a stored payload would be shown
|
||||||
// as the logged-in operator. The application's CSP does not
|
// as a page of the operator's own origin. The application's CSP
|
||||||
// help here: script-src allows 'unsafe-inline' from 'self', so
|
// is a second defence behind them: it allows no inline script,
|
||||||
// a document served from this origin could run its own inline
|
// so such a page could not run script of its own.
|
||||||
// script.
|
|
||||||
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
webhook, ok := h.ownedWebhook(w, r)
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
|
|||||||
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
|
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
|
||||||
assert.Contains(t, page, "{\n "pad": "xxx")
|
assert.Contains(t, page, "{\n "pad": "xxx")
|
||||||
assert.Contains(t, page, ""tail": ""+sentinel+""\n}")
|
assert.Contains(t, page, ""tail": ""+sentinel+""\n}")
|
||||||
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
|
assert.Contains(t, page, `break-all max-h-[32rem] overflow-y-auto"`)
|
||||||
assert.NotContains(t, page, "Showing the first")
|
assert.NotContains(t, page, "Showing the first")
|
||||||
assert.Contains(t, page, target.Name)
|
assert.Contains(t, page, target.Name)
|
||||||
assert.Contains(t, page, ">failed</span>")
|
assert.Contains(t, page, ">failed</span>")
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
// The handler-side half of the log-field audit. Two slog calls in
|
// The handler-side half of the log-field audit. The slog calls in
|
||||||
// this package reach a value an UNAUTHENTICATED client picks outright
|
// this package that reach a value an UNAUTHENTICATED client picks
|
||||||
// and of a length it picks outright:
|
// outright, and of a length it picks outright, are the failed-login
|
||||||
|
// DEBUG lines, whose username is a form field.
|
||||||
//
|
//
|
||||||
// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
|
// They are at DEBUG, which is off in production by default. That is
|
||||||
// path segment matched no stored entrypoint and so is bounded by
|
|
||||||
// nothing;
|
|
||||||
// - the failed-login DEBUG lines, whose username is a form field.
|
|
||||||
//
|
|
||||||
// Both are at DEBUG, which is off in production by default. That is
|
|
||||||
// not a bound: an operator turning DEBUG on to diagnose a flood must
|
// not a bound: an operator turning DEBUG on to diagnose a flood must
|
||||||
// not thereby hand the flood an unbounded write. Both spend the same
|
// not thereby hand the flood an unbounded write. They spend the same
|
||||||
// internal/logfield budget as the access log, and both are held here
|
// internal/logfield budget as the access log, and are held here to
|
||||||
// to middleware.MaxAccessLogLineBytes.
|
// middleware.MaxAccessLogLineBytes.
|
||||||
//
|
//
|
||||||
// The two login lines past the username lookup — "invalid password"
|
// The two login lines past the username lookup — "invalid password"
|
||||||
// and "user logged in" — carry the same cap without needing it, since
|
// and "user logged in" — carry the same cap without needing it, since
|
||||||
@@ -195,13 +191,12 @@ func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
|||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
// postReceiver sends one POST at /h/<segment>.
|
// postReceiver sends one POST with an empty body at /h/<segment>
|
||||||
|
// through router and returns the response status.
|
||||||
//
|
//
|
||||||
// RawPath is cleared after parsing so chi routes on the decoded path
|
// RawPath is cleared after parsing so chi routes on the decoded path
|
||||||
// and the handler sees the raw bytes rather than their percent-escaped
|
// and the handler sees the raw bytes rather than their percent-escaped
|
||||||
// spelling. That is the harder case for the budget: the escaped
|
// spelling.
|
||||||
// spelling is plain ASCII, which costs one byte per byte, while the
|
|
||||||
// decoded bytes are what the log handler has to escape.
|
|
||||||
func postReceiver(
|
func postReceiver(
|
||||||
t *testing.T, router *chi.Mux, segment string,
|
t *testing.T, router *chi.Mux, segment string,
|
||||||
) int {
|
) int {
|
||||||
@@ -260,43 +255,6 @@ func postLoginWithPassword(
|
|||||||
return w.Code
|
return w.Code
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnknownEntrypoint_LogLineDoesNotTrackPathSize drives 8 KB of
|
|
||||||
// client-chosen path at the unauthenticated receiver's
|
|
||||||
// unknown-entrypoint DEBUG line and holds it to the same ceiling the
|
|
||||||
// access log states.
|
|
||||||
func TestUnknownEntrypoint_LogLineDoesNotTrackPathSize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for handlerName, newHandler := range logHandlers() {
|
|
||||||
for fillName, fill := range escapeFills() {
|
|
||||||
t.Run(handlerName+"/"+fillName, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h, buf := capturingHandlers(t, newHandler)
|
|
||||||
router := receiverRouter(h)
|
|
||||||
|
|
||||||
for i := range floodRequests {
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
http.StatusNotFound,
|
|
||||||
postReceiver(
|
|
||||||
t, router,
|
|
||||||
oversizedFill(fill)+
|
|
||||||
strings.Repeat("y", i),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := logLines(t, buf)
|
|
||||||
require.Len(t, lines, floodRequests)
|
|
||||||
assertNoClientText(t, buf)
|
|
||||||
|
|
||||||
assertBoundedFlood(t, buf.Len())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFailedLogin_LogLineDoesNotTrackUsernameSize drives 8 KB of
|
// TestFailedLogin_LogLineDoesNotTrackUsernameSize drives 8 KB of
|
||||||
// client-chosen username at the unauthenticated login endpoint's
|
// client-chosen username at the unauthenticated login endpoint's
|
||||||
// DEBUG line and holds it to the same ceiling.
|
// DEBUG line and holds it to the same ceiling.
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
|
`<div class="max-w-[108rem] mx-auto px-6 py-8">`,
|
||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
@@ -286,8 +286,8 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|||||||
// target and says what deleting it loses, that the webhook's gives its
|
// target and says what deleting it loses, that the webhook's gives its
|
||||||
// number of stored events (5 received, 2 removed by retention, so 3,
|
// number of stored events (5 received, 2 removed by retention, so 3,
|
||||||
// the statistics pane's "Within retention" figure), and that an
|
// the statistics pane's "Within retention" figure), and that an
|
||||||
// entrypoint with no description is named by its URL. The template
|
// entrypoint with no description is named by its URL. Each prompt is
|
||||||
// writes the slashes after http: as \/, which the browser reads as /.
|
// its form's data-confirm attribute, where is a line break.
|
||||||
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
|
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -325,29 +325,28 @@ func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
|
|||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`Delete webhook "delete-me"?\n\n`+
|
`data-confirm="Delete webhook "delete-me"? `+
|
||||||
`This deletes its stored events (3) and their deliveries. `+
|
`This deletes its stored events (3) and their deliveries. `+
|
||||||
`Any archive files it wrote are kept.`)
|
`Any archive files it wrote are kept."`)
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`Delete entrypoint "Stripe"?\n\n`+
|
`data-confirm="Delete entrypoint "Stripe"? `+
|
||||||
`Senders using its URL get an error from now on, `+
|
`Senders using its URL get an error from now on, `+
|
||||||
`and the URL cannot be restored.`)
|
`and the URL cannot be restored."`)
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`Delete entrypoint "http:\/\/example.com/h/`+
|
`data-confirm="Delete entrypoint "http://example.com/h/`+
|
||||||
unnamed.Path+`"?`)
|
unnamed.Path+`"? `)
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`Delete target "t-log"?\n\n`+
|
`data-confirm="Delete target "t-log"? `+
|
||||||
`Nothing more is delivered to it. `+
|
`Nothing more is delivered to it. `+
|
||||||
`Its past deliveries stay in the event log.`)
|
`Its past deliveries stay in the event log."`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
|
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
|
||||||
// webhook name with quotes, a backslash, a closing script tag and a
|
// webhook name with quotes, a backslash, a closing script tag and a
|
||||||
// newline reaches its delete prompt escaped for the script, which the
|
// newline reaches its delete prompt escaped for the attribute, which
|
||||||
// browser reads back as the name typed: each quote and angle bracket
|
// the browser reads back as the name typed: each quote and angle
|
||||||
// as a \u escape, the slash as \/, the newline as \n and the backslash
|
// bracket as a character reference, the rest as typed. An unescaped
|
||||||
// doubled. An unescaped newline would break the prompt's script, and
|
// double quote would end the attribute and cut the prompt short.
|
||||||
// the form would then submit without asking.
|
|
||||||
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -373,6 +372,6 @@ func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
|||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
"Delete webhook "Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
|
"Delete webhook "Bob's "best" \\ hook"+
|
||||||
"\\u003c\\/script\\u003e\\nline two"?")
|
"</script>\nline two"?")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,13 +49,6 @@ var (
|
|||||||
// working retry configuration over a typo. A default answers a
|
// working retry configuration over a typo. A default answers a
|
||||||
// question that was not asked; it never answers one that was asked
|
// question that was not asked; it never answers one that was asked
|
||||||
// badly.
|
// badly.
|
||||||
//
|
|
||||||
// A target stored with a count above the ceiling before this
|
|
||||||
// validation existed keeps rendering and keeps delivering — nothing
|
|
||||||
// clamps the row. Re-saving it from the edit form does have to bring
|
|
||||||
// it into range, because the form submits the pre-filled value back
|
|
||||||
// and accepting it would be the ceiling not applying to the edit
|
|
||||||
// path. The 400 names the ceiling, so the fix is one field.
|
|
||||||
func parseMaxRetries(raw string, fallback int) (int, error) {
|
func parseMaxRetries(raw string, fallback int) (int, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/logfield"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,14 +48,13 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged only once the UUID is known to name a real
|
// Logged only once the lookup has found the entrypoint, so
|
||||||
// entrypoint. The UUID comes straight out of the path on
|
// an invented path writes no INFO line; a miss is logged at
|
||||||
// the one unauthenticated endpoint, so logging it before
|
// DEBUG. The entrypoint is named by its ID: the UUID is its
|
||||||
// the lookup let a client write an INFO line per invented
|
// credential and is never logged.
|
||||||
// path; the request itself is already in the access log
|
|
||||||
// and a miss is already logged at DEBUG.
|
|
||||||
h.log.Info("webhook request received",
|
h.log.Info("webhook request received",
|
||||||
"entrypoint_uuid", entrypointUUID,
|
"entrypoint_id", entrypoint.ID,
|
||||||
|
"webhook_id", entrypoint.WebhookID,
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"remoteIP", middleware.RemoteIP(r),
|
"remoteIP", middleware.RemoteIP(r),
|
||||||
"clientIP", middleware.ClientIP(r),
|
"clientIP", middleware.ClientIP(r),
|
||||||
@@ -133,17 +131,10 @@ func (h *Handlers) lookupEntrypoint(
|
|||||||
"path = ?", entrypointUUID,
|
"path = ?", entrypointUUID,
|
||||||
).First(&entrypoint)
|
).First(&entrypoint)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
// The receiver is unauthenticated and /h/{uuid}
|
// Not even an unknown UUID is logged. The receiver's log
|
||||||
// matches any single segment, so this value is entirely
|
// lines never carry the path segment, which is a credential
|
||||||
// client-chosen on exactly the branch where the lookup
|
// whenever it names an entrypoint.
|
||||||
// failed. DEBUG is off by default; the cap is what keeps
|
h.log.Debug("entrypoint not found")
|
||||||
// turning it on from restoring an unbounded write.
|
|
||||||
h.log.Debug(
|
|
||||||
"entrypoint not found",
|
|
||||||
"path", logfield.Truncate(
|
|
||||||
entrypointUUID, logfield.MaxBytes,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return entrypoint, false
|
return entrypoint, false
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
|
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHandleWebhook_LogsClientNextToThePeer checks that the
|
// TestHandleWebhook_LogsClientNextToThePeer checks that the
|
||||||
@@ -99,6 +102,87 @@ func TestHandleWebhook_LogsClientNextToThePeer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleWebhook_LogsNoEntrypointUUID sends an accepted event, a
|
||||||
|
// request over the receiver rate limit and a request for an unknown
|
||||||
|
// UUID through the receiver as routes.go mounts it, and checks that
|
||||||
|
// nothing logged, at any level, carries either UUID.
|
||||||
|
func TestHandleWebhook_LogsNoEntrypointUUID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
db *database.Database
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg := &config.Config{DataDir: t.TempDir(), ReceiverRateLimit: 1}
|
||||||
|
|
||||||
|
app := newTestAppWithConfig(t, cfg, &h, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
log := slog.New(slog.NewJSONHandler(
|
||||||
|
buf, &slog.HandlerOptions{Level: slog.LevelDebug},
|
||||||
|
))
|
||||||
|
h.SetLogForTest(log)
|
||||||
|
mw := middlewaretest.New(t, log, cfg, nil)
|
||||||
|
|
||||||
|
webhook := seedWebhook(t, db)
|
||||||
|
entrypoint := &database.Entrypoint{
|
||||||
|
WebhookID: webhook.ID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).
|
||||||
|
Create(entrypoint).Error)
|
||||||
|
|
||||||
|
unknownUUID := uuid.New().String()
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(mw.Logging())
|
||||||
|
router.With(mw.ReceiverRateLimit()).HandleFunc(
|
||||||
|
middleware.ReceiverRoute, h.HandleWebhook(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// In order: the limit is one request per minute per path, so the
|
||||||
|
// second request for the known UUID is the rejected one.
|
||||||
|
for _, step := range []struct {
|
||||||
|
entrypointUUID string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{entrypoint.Path, http.StatusOK},
|
||||||
|
{entrypoint.Path, http.StatusTooManyRequests},
|
||||||
|
{unknownUUID, http.StatusNotFound},
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodPost,
|
||||||
|
"/h/"+step.entrypointUUID, strings.NewReader("{}"),
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, step.wantStatus, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lines this test is about were all written, so the checks
|
||||||
|
// below are not passing on an empty log.
|
||||||
|
for _, msg := range []string{
|
||||||
|
"webhook request received",
|
||||||
|
"webhook receiver rate limit exceeded",
|
||||||
|
"entrypoint not found",
|
||||||
|
} {
|
||||||
|
assert.Contains(t, buf.String(), `"msg":"`+msg+`"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotContains(t, buf.String(), entrypoint.Path)
|
||||||
|
assert.NotContains(t, buf.String(), unknownUUID)
|
||||||
|
|
||||||
|
line := receivedLine(t, buf)
|
||||||
|
assert.Equal(t, entrypoint.ID, line["entrypoint_id"])
|
||||||
|
assert.Equal(t, webhook.ID, line["webhook_id"])
|
||||||
|
}
|
||||||
|
|
||||||
// receivedLine returns the one "webhook request received" line in the
|
// receivedLine returns the one "webhook request received" line in the
|
||||||
// captured JSON log.
|
// captured JSON log.
|
||||||
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
|
func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -27,6 +28,13 @@ const floodRequests = 64
|
|||||||
// line for a redirected or rejected request may contain it.
|
// line for a redirected or rejected request may contain it.
|
||||||
const attackerMarker = "QQATTACKERTEXTQQ"
|
const attackerMarker = "QQATTACKERTEXTQQ"
|
||||||
|
|
||||||
|
// knownEntrypointUUID and failingEntrypointUUID are the UUIDs the
|
||||||
|
// stand-in receiver in accessLogRouter answers 200 and 500 to.
|
||||||
|
const (
|
||||||
|
knownEntrypointUUID = "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60"
|
||||||
|
failingEntrypointUUID = "0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35"
|
||||||
|
)
|
||||||
|
|
||||||
// maxLineBytes bounds a single access log line whose client-supplied
|
// maxLineBytes bounds a single access log line whose client-supplied
|
||||||
// fields are of ordinary size. Well above what the fixed fields need,
|
// fields are of ordinary size. Well above what the fixed fields need,
|
||||||
// well below the length of the oversized input the amplification tests
|
// well below the length of the oversized input the amplification tests
|
||||||
@@ -132,17 +140,21 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
|||||||
)
|
)
|
||||||
|
|
||||||
router.HandleFunc(
|
router.HandleFunc(
|
||||||
"/h/{uuid}",
|
middleware.ReceiverRoute,
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Stands in for the real handler: an unknown entrypoint
|
// Stands in for the real handler: a known entrypoint
|
||||||
// UUID 404s, a known one succeeds.
|
// UUID succeeds, a failing one is a server error, and
|
||||||
if chi.URLParam(r, "uuid") != "known" {
|
// any other 404s.
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
switch chi.URLParam(r, "uuid") {
|
||||||
|
case knownEntrypointUUID:
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case failingEntrypointUUID:
|
||||||
|
http.Error(
|
||||||
|
w, "boom", http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -335,11 +347,13 @@ func oversizedValue(ch string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// oversizedHeaders fills every client-supplied header the access log
|
// oversizedHeaders fills every client-supplied header the access log
|
||||||
// reads with the same value.
|
// reads with the same value. The Referer carries it as the host of an
|
||||||
|
// absolute URL, because the access log keeps only a Referer's scheme
|
||||||
|
// and host.
|
||||||
func oversizedHeaders(value string) map[string]string {
|
func oversizedHeaders(value string) map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"User-Agent": value,
|
"User-Agent": value,
|
||||||
"Referer": value,
|
"Referer": "http://" + value,
|
||||||
"X-Request-Id": value,
|
"X-Request-Id": value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,17 +663,111 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
|||||||
router := accessLogRouter(m)
|
router := accessLogRouter(m)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, get(t, router, "/h/known?src=ci"),
|
t, http.StatusOK,
|
||||||
|
get(t, router, "/.well-known/healthcheck?src=ci"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// The path resolved against a stored entrypoint, so it stays. The
|
|
||||||
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
|
||||||
entries := accessLogEntries(t, buf)
|
entries := accessLogEntries(t, buf)
|
||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
|
assert.Equal(
|
||||||
|
t, "/.well-known/healthcheck?(redacted)", entries[0]["url"],
|
||||||
|
)
|
||||||
assert.NotContains(t, buf.String(), "src=ci")
|
assert.NotContains(t, buf.String(), "src=ci")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAccessLog_ReceiverNeverLogsTheUUID covers the two statuses that
|
||||||
|
// keep the concrete path on every other route. On the receiver that
|
||||||
|
// path carries the entrypoint UUID, so the line names the route
|
||||||
|
// pattern instead.
|
||||||
|
func TestAccessLog_ReceiverNeverLogsTheUUID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for entrypointUUID, wantStatus := range map[string]int{
|
||||||
|
knownEntrypointUUID: http.StatusOK,
|
||||||
|
failingEntrypointUUID: http.StatusInternalServerError,
|
||||||
|
} {
|
||||||
|
t.Run(strconv.Itoa(wantStatus), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, buf := capturingMiddleware(t)
|
||||||
|
router := accessLogRouter(m)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, wantStatus,
|
||||||
|
get(t, router, "/h/"+entrypointUUID+"?src=ci"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entries := accessLogEntries(t, buf)
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
assert.Equal(t, middleware.ReceiverRoute, entries[0]["url"])
|
||||||
|
assert.NotContains(t, buf.String(), entrypointUUID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccessLog_ReceiverPreflightNeverLogsTheUUID sends a CORS
|
||||||
|
// preflight to the receiver in dev, in the production middleware
|
||||||
|
// order. CORS answers it with 200 before chi routes it, so the line
|
||||||
|
// names no route and carries no UUID.
|
||||||
|
func TestAccessLog_ReceiverPreflightNeverLogsTheUUID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, buf := capturingMiddleware(t)
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(m.Logging())
|
||||||
|
router.Use(m.CORS())
|
||||||
|
router.HandleFunc(
|
||||||
|
middleware.ReceiverRoute,
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodOptions,
|
||||||
|
"/h/"+knownEntrypointUUID, nil,
|
||||||
|
)
|
||||||
|
req.Header.Set("Origin", "https://sender.example")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
entries := accessLogEntries(t, buf)
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
assert.Equal(t, unmatchedRouteLiteral, entries[0]["url"])
|
||||||
|
assert.NotContains(t, buf.String(), knownEntrypointUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccessLog_RefererNeverLogsTheUUID sends receiver requests whose
|
||||||
|
// Referer is the entrypoint URL, as a sender's is after it follows a
|
||||||
|
// redirect from http:// to https://. The referer field keeps only the
|
||||||
|
// scheme and host, and a Referer without them is logged as a marker.
|
||||||
|
func TestAccessLog_RefererNeverLogsTheUUID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, buf := capturingMiddleware(t)
|
||||||
|
router := accessLogRouter(m)
|
||||||
|
|
||||||
|
for _, referer := range []string{
|
||||||
|
"http://webhooker.example/h/" + knownEntrypointUUID + "?src=ci",
|
||||||
|
"/h/" + knownEntrypointUUID,
|
||||||
|
} {
|
||||||
|
assert.Equal(t, http.StatusOK, getWithHeaders(
|
||||||
|
t, router, "/h/"+knownEntrypointUUID,
|
||||||
|
map[string]string{"Referer": referer},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := accessLogEntries(t, buf)
|
||||||
|
require.Len(t, entries, 2)
|
||||||
|
assert.Equal(t, "http://webhooker.example", entries[0]["referer"])
|
||||||
|
assert.Equal(t, "(redacted)", entries[1]["referer"])
|
||||||
|
assert.NotContains(t, buf.String(), knownEntrypointUUID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) {
|
func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ package middleware_test
|
|||||||
// the client's choosing.
|
// the client's choosing.
|
||||||
// - CSRF's 403 rejection, at WARN, also registered ahead of
|
// - CSRF's 403 rejection, at WARN, also registered ahead of
|
||||||
// RequireAuth.
|
// RequireAuth.
|
||||||
// - The rate limiters' 429 rejection, at WARN, on the
|
// - The rate limiters' 429 rejection, at WARN. On the receiver
|
||||||
// unauthenticated receiver among others.
|
// route it names the route pattern rather than the path, and
|
||||||
|
// the limiters that log a path sit behind RequireAuth, so its cap
|
||||||
|
// is defensive.
|
||||||
// - RequireAuth's own unauthenticated-request line, at DEBUG.
|
// - RequireAuth's own unauthenticated-request line, at DEBUG.
|
||||||
// - RecordLoginFailure's throttle rejection, at WARN. Its cap is
|
// - RecordLoginFailure's throttle rejection, at WARN. Its cap is
|
||||||
// defensive rather than load-bearing today: chi pins the one
|
// defensive rather than load-bearing today: chi pins the one
|
||||||
@@ -268,10 +270,14 @@ func logSites() map[string]logSite {
|
|||||||
send: postNoToken,
|
send: postNoToken,
|
||||||
wantStatus: http.StatusForbidden,
|
wantStatus: http.StatusForbidden,
|
||||||
},
|
},
|
||||||
// The per-entrypoint receiver limiter, unauthenticated. Its
|
// The 429 line every limiter but the aggregate one shares.
|
||||||
// bucket is keyed on the path, so the first request through a
|
// Mounted outside any route, as here, it logs the path, as
|
||||||
// fresh path is served and only the ones after it are
|
// it does for the limiters behind RequireAuth; on
|
||||||
// rejected; sendUntilLimited absorbs that.
|
// ReceiverRoute it names the pattern instead. The
|
||||||
|
// per-entrypoint receiver limiter's bucket is keyed on the
|
||||||
|
// path, so the first request through a fresh path is served
|
||||||
|
// and only the ones after it are rejected; sendUntilLimited
|
||||||
|
// absorbs that.
|
||||||
"receiver rate limit 429": {
|
"receiver rate limit 429": {
|
||||||
build: func(
|
build: func(
|
||||||
t *testing.T, m *middleware.Middleware,
|
t *testing.T, m *middleware.Middleware,
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ const unmatchedMethod = unmatchedRoute
|
|||||||
// after /h/ is client-chosen, so labelling by path lets any
|
// after /h/ is client-chosen, so labelling by path lets any
|
||||||
// unauthenticated client mint permanent series at will, and publishes
|
// unauthenticated client mint permanent series at will, and publishes
|
||||||
// the entrypoint UUID (the receiver's only credential) in the scrape
|
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||||
// while doing it.
|
// while doing it. The access log and the 429 line read it for the
|
||||||
|
// same reason: see ReceiverRoute.
|
||||||
//
|
//
|
||||||
// chi populates the route context during routeHTTP, so this is only
|
// chi populates the route context during routeHTTP, so this is only
|
||||||
// valid once routing has run. Every caller below is on the recording
|
// valid once routing has run. Every caller below is on the recording
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
@@ -23,6 +22,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logfield"
|
"sneak.berlin/go/webhooker/internal/logfield"
|
||||||
|
"sneak.berlin/go/webhooker/internal/referer"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,14 +96,14 @@ const (
|
|||||||
// It is also the ceiling on every OTHER line this service writes
|
// It is also the ceiling on every OTHER line this service writes
|
||||||
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
// THROUGH SLOG that carries text an UNAUTHENTICATED client
|
||||||
// supplies. Those lines — the MaxBodySize rejection, the CSRF
|
// supplies. Those lines — the MaxBodySize rejection, the CSRF
|
||||||
// rejection, the rate-limit rejection, the unauthenticated-request
|
// rejection, the unauthenticated-request DEBUG line, the
|
||||||
// and unknown-entrypoint DEBUG lines, the failed-login DEBUG
|
// failed-login DEBUG lines, and the two login-throttle WARN
|
||||||
// lines, and the two login-throttle WARN lines ("login failure
|
// lines ("login failure limit exceeded" in loginguard.go and
|
||||||
// limit exceeded" in loginguard.go and "password verification
|
// "password verification capacity exhausted" in
|
||||||
// capacity exhausted" in internal/handlers/auth.go) — spend the
|
// internal/handlers/auth.go) — spend the same per-field budgets,
|
||||||
// same per-field budgets, and each carries strictly fewer
|
// and each carries strictly fewer client-supplied fields than the
|
||||||
// client-supplied fields than the access log does,
|
// access log does, so none of them can reach a width the access
|
||||||
// so none of them can reach a width the access log cannot. That is
|
// log cannot. That is
|
||||||
// asserted directly, per line and under both handlers, rather than
|
// asserted directly, per line and under both handlers, rather than
|
||||||
// left to the reasoning: see logbound_test.go in this package and
|
// left to the reasoning: see logbound_test.go in this package and
|
||||||
// in internal/handlers.
|
// in internal/handlers.
|
||||||
@@ -115,16 +115,15 @@ const (
|
|||||||
// a caller on a parameterised route would supply, which is the
|
// a caller on a parameterised route would supply, which is the
|
||||||
// only way those caps can be pinned at all.
|
// only way those caps can be pinned at all.
|
||||||
//
|
//
|
||||||
// One writer in that set is not a handler's own slog call. The
|
// The GORM adapter in internal/gormlog writes through slog as well.
|
||||||
// GORM adapter in internal/gormlog logs the statement with the
|
// It logs each statement with its placeholders rather than the
|
||||||
// client-chosen parameter already interpolated into it, which on
|
// values bound to them (see its ParamsFilter), so the key a client
|
||||||
// the receiver and login lookups is exactly the value the budgets
|
// sends to the receiver or login lookup does not reach its lines.
|
||||||
// above exist for. Its widest line spends two logfield.MaxBytes
|
// Its widest line spends two logfield.MaxBytes budgets — the
|
||||||
// budgets — the statement and the driver error — against a fixed
|
// statement and the driver error — against a fixed portion smaller
|
||||||
// portion smaller than this one's, and
|
// than this one's, and internal/gormlog/gormlog_test.go asserts
|
||||||
// internal/gormlog/gormlog_test.go asserts every line it emits
|
// every line it emits against this constant directly rather than
|
||||||
// against this constant directly rather than leaving it as
|
// leaving it as arithmetic.
|
||||||
// arithmetic.
|
|
||||||
//
|
//
|
||||||
// What it does NOT cover, so that the figure above is not read as
|
// What it does NOT cover, so that the figure above is not read as
|
||||||
// more than it is:
|
// more than it is:
|
||||||
@@ -151,6 +150,12 @@ const (
|
|||||||
MaxAccessLogLineBytes = 2560
|
MaxAccessLogLineBytes = 2560
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ReceiverRoute is the route pattern the webhook receiver is served
|
||||||
|
// at. Its path carries the entrypoint UUID, which is the receiver's
|
||||||
|
// only credential, so the log lines this package writes name a
|
||||||
|
// request on this route by the pattern, never by its path.
|
||||||
|
const ReceiverRoute = "/h/{uuid}"
|
||||||
|
|
||||||
//nolint:revive // MiddlewareParams is a standard fx naming convention.
|
//nolint:revive // MiddlewareParams is a standard fx naming convention.
|
||||||
type MiddlewareParams struct {
|
type MiddlewareParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -268,14 +273,14 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
// marker.
|
// marker.
|
||||||
//
|
//
|
||||||
// The path on those branches is bounded by the service's routes or by
|
// The path on those branches is bounded by the service's routes or by
|
||||||
// the operator's data — a 2xx on the receiver means the UUID named a
|
// the operator's data — a 2xx under /s means the file is in the
|
||||||
// stored entrypoint, a 2xx under /s means the file is in the embedded
|
// embedded tree. The query is not bounded by anything:
|
||||||
// tree. The query is not bounded by anything: /.well-known/healthcheck
|
// /.well-known/healthcheck and /s/* take no authentication and sit
|
||||||
// and /s/* take no authentication and sit behind no rate limiter, and
|
// behind no rate limiter, and /pages/login behind only the login
|
||||||
// /pages/login behind only the login limiter, so any of them will
|
// limiter, so any of them will answer 200 to a URL carrying an
|
||||||
// answer 200 to a URL carrying an arbitrary number of arbitrary bytes
|
// arbitrary number of arbitrary bytes after the '?'. Keeping the path
|
||||||
// after the '?'. Keeping the path and dropping the query is what makes
|
// and dropping the query is what makes this branch as bounded as the
|
||||||
// this branch as bounded as the pattern branches below.
|
// pattern branches below.
|
||||||
//
|
//
|
||||||
// Nothing debuggable is lost. The only query parameters the service
|
// Nothing debuggable is lost. The only query parameters the service
|
||||||
// reads are the login page's `next`, the page to return to,
|
// reads are the login page's `next`, the page to return to,
|
||||||
@@ -297,36 +302,40 @@ func concreteLogURL(r *http.Request) string {
|
|||||||
|
|
||||||
// accessLogURL returns the value for the access log's url field.
|
// accessLogURL returns the value for the access log's url field.
|
||||||
//
|
//
|
||||||
// 2xx and 5xx responses get the concrete path (see concreteLogURL). A
|
// On ReceiverRoute it is the route pattern whatever the status, so
|
||||||
// success resolved against a static route or against the operator's
|
// the entrypoint UUID in the path is never logged. A request chi never
|
||||||
// own data — on the receiver, a 2xx means the UUID named a stored
|
// routed gets unmatchedRoute whatever the status, for the same reason:
|
||||||
// entrypoint — and a server error is our own bug, where the exact URL
|
// in dev, CORS answers a preflight to the receiver before routing.
|
||||||
// is the primary evidence and which no client can provoke at will.
|
//
|
||||||
|
// On every other route, 2xx and 5xx responses get the concrete path
|
||||||
|
// (see concreteLogURL). A success resolved against a static route or
|
||||||
|
// against the operator's own data, and a server error is our own bug,
|
||||||
|
// where the exact URL is the primary evidence and which no client can
|
||||||
|
// provoke at will.
|
||||||
//
|
//
|
||||||
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
||||||
// the outcomes an unauthenticated client drives for free: 404 or 429
|
// the outcomes an unauthenticated client drives for free, such as a
|
||||||
// on any invented /h/ path, 303 to the login page on any
|
// 303 to the login page on any invented /user/ path. Logging the
|
||||||
// invented /user/ path. Logging the concrete URL there lets a flood
|
// concrete URL there lets a flood write attacker-chosen text, of
|
||||||
// write attacker-chosen text, of attacker-chosen length, into the
|
// attacker-chosen length, into the operator's log at one line per
|
||||||
// operator's log at one line per request. The pattern comes from the
|
// request. The pattern comes from the router's own table, so it is
|
||||||
// router's own table, so it is bounded by the service's routes while
|
// bounded by the service's routes while still naming which class of
|
||||||
// still naming which class of request was rejected.
|
// request was rejected.
|
||||||
//
|
//
|
||||||
// The pattern is only populated once routing has run, so this must be
|
// The pattern is only populated once routing has run, so this must be
|
||||||
// called after the handler returns, not before.
|
// called after the handler returns, not before.
|
||||||
func accessLogURL(r *http.Request, status int) string {
|
func accessLogURL(r *http.Request, status int) string {
|
||||||
|
pattern := routePatternID(r.Context())
|
||||||
|
if pattern == ReceiverRoute || pattern == unmatchedRoute {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
if status < http.StatusMultipleChoices ||
|
if status < http.StatusMultipleChoices ||
|
||||||
status >= http.StatusInternalServerError {
|
status >= http.StatusInternalServerError {
|
||||||
return concreteLogURL(r)
|
return concreteLogURL(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rc := chi.RouteContext(r.Context()); rc != nil {
|
|
||||||
if pattern := rc.RoutePattern(); pattern != "" {
|
|
||||||
return pattern
|
return pattern
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return unmatchedRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logging returns middleware that logs each HTTP request with
|
// Logging returns middleware that logs each HTTP request with
|
||||||
@@ -379,7 +388,8 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
requestID, maxLogRequestIDBytes,
|
requestID, maxLogRequestIDBytes,
|
||||||
),
|
),
|
||||||
"referer", logfield.Truncate(
|
"referer", logfield.Truncate(
|
||||||
r.Referer(), logfield.MaxBytes,
|
referer.SchemeAndHost(r.Referer()),
|
||||||
|
logfield.MaxBytes,
|
||||||
),
|
),
|
||||||
"proto", r.Proto,
|
"proto", r.Proto,
|
||||||
"remoteIP", RemoteIP(r),
|
"remoteIP", RemoteIP(r),
|
||||||
@@ -538,7 +548,9 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
// SecurityHeaders returns middleware that sets production security
|
// SecurityHeaders returns middleware that sets production security
|
||||||
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
|
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
|
||||||
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
|
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
|
||||||
// Permissions-Policy.
|
// Permissions-Policy. The CSP takes scripts and styles only from
|
||||||
|
// files on this origin, so a template may hold no inline script,
|
||||||
|
// event handler or style attribute.
|
||||||
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||||
maxAge := s.params.Config.HSTSMaxAge
|
maxAge := s.params.Config.HSTSMaxAge
|
||||||
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
|
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
|
||||||
@@ -560,8 +572,8 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
|||||||
w.Header().Set(
|
w.Header().Set(
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'self'; "+
|
"default-src 'self'; "+
|
||||||
"script-src 'self' 'unsafe-inline'; "+
|
"script-src 'self'; "+
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self'",
|
||||||
)
|
)
|
||||||
w.Header().Set(
|
w.Header().Set(
|
||||||
"Referrer-Policy",
|
"Referrer-Policy",
|
||||||
|
|||||||
@@ -747,10 +747,10 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
|||||||
|
|
||||||
// --- SecurityHeaders Middleware Tests ---
|
// --- SecurityHeaders Middleware Tests ---
|
||||||
|
|
||||||
// hstsHeaders serves one request through SecurityHeaders built over
|
// securityHeaders serves one request through SecurityHeaders built
|
||||||
// a Config with the given HSTSMaxAge, and returns every
|
// over a Config with the given HSTSMaxAge, and returns the response's
|
||||||
// Strict-Transport-Security value on the response.
|
// headers.
|
||||||
func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
|
func securityHeaders(t *testing.T, maxAge time.Duration) http.Header {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cfg := &config.Config{HSTSMaxAge: maxAge}
|
cfg := &config.Config{HSTSMaxAge: maxAge}
|
||||||
@@ -772,7 +772,7 @@ func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
|
|||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
return w.Header().Values("Strict-Transport-Security")
|
return w.Header()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecurityHeaders_HSTS(t *testing.T) {
|
func TestSecurityHeaders_HSTS(t *testing.T) {
|
||||||
@@ -804,11 +804,28 @@ func TestSecurityHeaders_HSTS(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, hstsHeaders(t, tt.maxAge))
|
assert.Equal(
|
||||||
|
t, tt.expected,
|
||||||
|
securityHeaders(t, tt.maxAge).Values(
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSecurityHeaders_CSP pins the Content-Security-Policy: a page
|
||||||
|
// takes its scripts and styles only from files on its own origin,
|
||||||
|
// never from an inline script, event handler or style.
|
||||||
|
func TestSecurityHeaders_CSP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, "default-src 'self'; script-src 'self'; style-src 'self'",
|
||||||
|
securityHeaders(t, 0).Get("Content-Security-Policy"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- MaxBodySize Middleware Tests ---
|
// --- MaxBodySize Middleware Tests ---
|
||||||
|
|
||||||
const testBodyLimit int64 = 64
|
const testBodyLimit int64 = 64
|
||||||
|
|||||||
@@ -259,26 +259,28 @@ func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
|
|||||||
return peer, true
|
return peer, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// tooManyRequests returns the 429 handler used by the
|
// tooManyRequests returns the 429 handler used by the postRateLimit
|
||||||
// password-change and per-entrypoint receiver limiters: it logs the
|
// limiters and the per-entrypoint receiver limiter: it logs the
|
||||||
// rejection with logMessage and answers with responseMessage.
|
// rejection with logMessage and answers with responseMessage.
|
||||||
// httprate adds the Retry-After header (RFC 6585). The aggregate
|
// httprate adds the Retry-After header (RFC 6585). The aggregate
|
||||||
// receiver limiter uses floodTooManyRequests instead.
|
// receiver limiter uses floodTooManyRequests instead.
|
||||||
//
|
//
|
||||||
// The path is capped against the same budget as the access log's url
|
// On ReceiverRoute the line names the route pattern, so the
|
||||||
// field. The per-entrypoint receiver limiter is unauthenticated and
|
// entrypoint UUID in the path is never logged. Elsewhere it names the
|
||||||
// its path is a client-chosen segment of client-chosen length, so at
|
// path, capped against the same budget as the access log's url field;
|
||||||
// WARN an uncapped path would let a sender pick the size of the line
|
// the limiters there sit behind RequireAuth, so the cap is defensive.
|
||||||
// it writes — the same defect the access log capping closed.
|
|
||||||
func (m *Middleware) tooManyRequests(
|
func (m *Middleware) tooManyRequests(
|
||||||
logMessage, responseMessage string,
|
logMessage, responseMessage string,
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if routePatternID(r.Context()) == ReceiverRoute {
|
||||||
|
path = ReceiverRoute
|
||||||
|
}
|
||||||
|
|
||||||
m.log.Warn(
|
m.log.Warn(
|
||||||
logMessage,
|
logMessage,
|
||||||
"path", logfield.Truncate(
|
"path", logfield.Truncate(path, logfield.MaxBytes),
|
||||||
r.URL.Path, logfield.MaxBytes,
|
|
||||||
),
|
|
||||||
"remoteIP", RemoteIP(r),
|
"remoteIP", RemoteIP(r),
|
||||||
"clientIP", ClientIP(r),
|
"clientIP", ClientIP(r),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Package referer reduces a Referer header to its scheme and host, so
|
||||||
|
// that a URL path, which on the receiver route carries the entrypoint
|
||||||
|
// UUID, never reaches a log line or an error report.
|
||||||
|
package referer
|
||||||
|
|
||||||
|
import "net/url"
|
||||||
|
|
||||||
|
// redacted stands in for a Referer that cannot be reduced to its
|
||||||
|
// scheme and host.
|
||||||
|
const redacted = "(redacted)"
|
||||||
|
|
||||||
|
// SchemeAndHost returns a Referer's scheme and host, never its path or
|
||||||
|
// query. The path can be an entrypoint URL, which carries the UUID: a
|
||||||
|
// sender that follows a redirect from http:// to https:// sends the
|
||||||
|
// URL it was redirected from, and a browser sends the 404 page's URL
|
||||||
|
// under /h/ when it fetches that page's stylesheet. A Referer without
|
||||||
|
// both a scheme and a host comes back as "(redacted)".
|
||||||
|
//
|
||||||
|
// It is the access log's referer field, and the Referer header the
|
||||||
|
// Sentry hook sends.
|
||||||
|
func SchemeAndHost(referer string) string {
|
||||||
|
if referer == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(referer)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.Scheme + "://" + parsed.Host
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package referer_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/referer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entrypointUUID is the secret part of an entrypoint URL; no case may
|
||||||
|
// return it.
|
||||||
|
const entrypointUUID = "0b6f7d3e-6a39-4e3c-9f0a-2c4d8e1b5a77"
|
||||||
|
|
||||||
|
// redacted is what SchemeAndHost returns for a Referer it cannot
|
||||||
|
// reduce to a scheme and host.
|
||||||
|
const redacted = "(redacted)"
|
||||||
|
|
||||||
|
func TestSchemeAndHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
referer string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no Referer",
|
||||||
|
referer: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entrypoint URL with a query",
|
||||||
|
referer: "http://webhooker.example/h/" + entrypointUUID +
|
||||||
|
"?src=ci",
|
||||||
|
want: "http://webhooker.example",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host with a port",
|
||||||
|
referer: "https://webhooker.example:8443/h/" + entrypointUUID,
|
||||||
|
want: "https://webhooker.example:8443",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path without a scheme or host",
|
||||||
|
referer: "/h/" + entrypointUUID,
|
||||||
|
want: redacted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host without a scheme",
|
||||||
|
referer: "webhooker.example/h/" + entrypointUUID,
|
||||||
|
want: redacted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not a URL",
|
||||||
|
referer: "http://[::1/h/" + entrypointUUID,
|
||||||
|
want: redacted,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := referer.SchemeAndHost(tc.referer)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
assert.NotContains(t, got, entrypointUUID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,8 +60,8 @@ const (
|
|||||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
||||||
// event log in a headless browser, served by the real router and so
|
// event log in a headless browser, served by the real router and so
|
||||||
// under the real Content-Security-Policy, and checks that the pages'
|
// under the real Content-Security-Policy, and checks that the pages'
|
||||||
// Alpine.js directives and the copy control work, and that a target's
|
// Alpine.js directives, the copy control and the delete prompts work,
|
||||||
// row shows its delivery counts.
|
// and that a target's row shows its delivery counts.
|
||||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -74,6 +74,10 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
userID, _ := env.seedUser(t, "browser", "browser-password")
|
userID, _ := env.seedUser(t, "browser", "browser-password")
|
||||||
webhook, older, event, target := seedBrowserWebhook(t, env, userID)
|
webhook, older, event, target := seedBrowserWebhook(t, env, userID)
|
||||||
|
|
||||||
|
// The sign-in page is loaded before the browser signs in, since a
|
||||||
|
// signed-in visit to it is sent on to another page.
|
||||||
|
checkOtherPages(ctx, t, srv.URL+"/pages/login")
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
||||||
))
|
))
|
||||||
@@ -99,6 +103,11 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
||||||
checkMobileMenu(ctx, t, page)
|
checkMobileMenu(ctx, t, page)
|
||||||
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
|
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
|
||||||
|
checkOtherPages(ctx, t,
|
||||||
|
srv.URL+"/hooks", srv.URL+"/settings", srv.URL+"/user/browser",
|
||||||
|
// A path that does not exist, which gets the 404 error page.
|
||||||
|
srv.URL+"/no-such-page")
|
||||||
|
checkDeletePrompt(ctx, t, page, target)
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
assert.Empty(t, problems(), "the browser reported problems")
|
||||||
}
|
}
|
||||||
@@ -1388,3 +1397,75 @@ func checkPhoneWidth(
|
|||||||
assert.False(t, sideways, "the event log scrolls sideways on a phone")
|
assert.False(t, sideways, "the event log scrolls sideways on a phone")
|
||||||
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
|
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkOtherPages loads the pages at urls, which no other check opens,
|
||||||
|
// so that anything the browser reports going wrong on them fails the
|
||||||
|
// test too.
|
||||||
|
func checkOtherPages(ctx context.Context, t *testing.T, urls ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, url := range urls {
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerPrompt returns a script that stands in for the user at the
|
||||||
|
// browser's prompt: it makes window.confirm record the question in
|
||||||
|
// window.asked and give answer. It also records in window.sent whether
|
||||||
|
// a form is sent once its submit event is handled, from a listener on
|
||||||
|
// window, which the event reaches after the listener static/js/app.js
|
||||||
|
// adds to document.
|
||||||
|
func answerPrompt(answer bool) string {
|
||||||
|
return fmt.Sprintf(`
|
||||||
|
window.asked = "";
|
||||||
|
window.sent = false;
|
||||||
|
window.confirm = (question) => {
|
||||||
|
window.asked = question;
|
||||||
|
return %t;
|
||||||
|
};
|
||||||
|
window.addEventListener("submit", (event) => {
|
||||||
|
window.sent = !event.defaultPrevented;
|
||||||
|
});`, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDeletePrompt loads a webhook page and clicks Delete beside
|
||||||
|
// target. It checks that the browser asks first, naming the target,
|
||||||
|
// that answering no leaves the form unsent, and that answering yes
|
||||||
|
// sends it and the target is deleted.
|
||||||
|
func checkDeletePrompt(
|
||||||
|
ctx context.Context, t *testing.T, url string, target *database.Target,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
form := `form[action$="/targets/` + target.ID + `/delete"]`
|
||||||
|
question := `Delete target "` + target.Name + `"?` + "\n\n" +
|
||||||
|
"Nothing more is delivered to it. " +
|
||||||
|
"Its past deliveries stay in the event log."
|
||||||
|
|
||||||
|
var (
|
||||||
|
asked string
|
||||||
|
sent bool
|
||||||
|
listed bool
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, loadPage(url), chromedp.Evaluate(answerPrompt(false), nil),
|
||||||
|
))
|
||||||
|
click(ctx, t, form+" button")
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.Evaluate(`window.asked`, &asked),
|
||||||
|
chromedp.Evaluate(`window.sent`, &sent),
|
||||||
|
))
|
||||||
|
assert.Equal(t, question, asked, "Delete does not ask about the target")
|
||||||
|
assert.False(t, sent, "answering no still sends the delete form")
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Evaluate(answerPrompt(true), nil),
|
||||||
|
))
|
||||||
|
clickAndLoad(ctx, t, form+" button")
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
|
||||||
|
`document.querySelector('`+form+`') !== null`, &listed,
|
||||||
|
)))
|
||||||
|
assert.False(t, listed, "answering yes does not delete the target")
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,6 +26,11 @@ const (
|
|||||||
// its network namespace even with a published port.
|
// its network namespace even with a published port.
|
||||||
wildcardV4 = "0.0.0.0"
|
wildcardV4 = "0.0.0.0"
|
||||||
|
|
||||||
|
// loopbackV6 and wildcardV6 are the IPv6 counterparts of the two
|
||||||
|
// above.
|
||||||
|
loopbackV6 = "::1"
|
||||||
|
wildcardV6 = "::"
|
||||||
|
|
||||||
// unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a
|
// unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a
|
||||||
// well-formed literal that no host is assigned, so binding it
|
// well-formed literal that no host is assigned, so binding it
|
||||||
// fails with EADDRNOTAVAIL rather than succeeding somewhere
|
// fails with EADDRNOTAVAIL rather than succeeding somewhere
|
||||||
@@ -116,6 +122,24 @@ func otherLocalAddr(t *testing.T) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skipWithoutIPv6 skips the test when this host cannot bind
|
||||||
|
// loopbackV6. Without IPv6 there is no second family for a bind to
|
||||||
|
// claim or leave alone.
|
||||||
|
func skipWithoutIPv6(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var listenCfg net.ListenConfig
|
||||||
|
|
||||||
|
l, err := listenCfg.Listen(
|
||||||
|
t.Context(), "tcp6", net.JoinHostPort(loopbackV6, "0"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("host has no IPv6 loopback to bind")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, l.Close())
|
||||||
|
}
|
||||||
|
|
||||||
// startBoundServer starts the wired app with the given bind address
|
// startBoundServer starts the wired app with the given bind address
|
||||||
// on a free port and returns that port. The app is stopped on
|
// on a free port and returns that port. The app is stopped on
|
||||||
// cleanup.
|
// cleanup.
|
||||||
@@ -292,6 +316,59 @@ func TestBindAddress_WildcardReachesOtherAddresses(t *testing.T) {
|
|||||||
requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port)))
|
requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBindAddress_WildcardV4IsIPv4Only pins that 0.0.0.0 answers on
|
||||||
|
// IPv4 alone, as the README says, whether it is written as 0.0.0.0 or
|
||||||
|
// as the IPv4-mapped ::ffff:0.0.0.0. Go listens on 0.0.0.0 with a
|
||||||
|
// socket that answers IPv6 too unless told otherwise, which put the
|
||||||
|
// admin UI and the receiver on every IPv6 address of a host whose
|
||||||
|
// operator had chosen an IPv4 bind.
|
||||||
|
func TestBindAddress_WildcardV4IsIPv4Only(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
skipWithoutIPv6(t)
|
||||||
|
|
||||||
|
for _, bindAddress := range []string{wildcardV4, "::ffff:0.0.0.0"} {
|
||||||
|
t.Run(bindAddress, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
port := startBoundServer(t, bindAddress)
|
||||||
|
|
||||||
|
// Positive control: the service really is up, on IPv4.
|
||||||
|
requireDialable(
|
||||||
|
t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)),
|
||||||
|
)
|
||||||
|
|
||||||
|
dialer := net.Dialer{Timeout: dialTimeout}
|
||||||
|
|
||||||
|
conn, err := dialer.DialContext(
|
||||||
|
t.Context(), "tcp",
|
||||||
|
net.JoinHostPort(loopbackV6, strconv.Itoa(port)),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
require.ErrorIs(
|
||||||
|
t, err, syscall.ECONNREFUSED,
|
||||||
|
"a %s bind answered on %s", bindAddress, loopbackV6,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBindAddress_WildcardV6ReachesBothFamilies is the counterpart:
|
||||||
|
// :: is the value that binds IPv6, and it answers IPv4 as well.
|
||||||
|
func TestBindAddress_WildcardV6ReachesBothFamilies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
skipWithoutIPv6(t)
|
||||||
|
|
||||||
|
port := startBoundServer(t, wildcardV6)
|
||||||
|
|
||||||
|
requireDialable(t, net.JoinHostPort(loopbackV6, strconv.Itoa(port)))
|
||||||
|
requireDialable(t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)))
|
||||||
|
}
|
||||||
|
|
||||||
// TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound
|
// TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound
|
||||||
// listener serves the application rather than merely accepting TCP,
|
// listener serves the application rather than merely accepting TCP,
|
||||||
// so a bind address that is honoured cannot be mistaken for one that
|
// so a bind address that is honoured cannot be mistaken for one that
|
||||||
|
|||||||
+30
-1
@@ -1,9 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -39,6 +41,23 @@ func (s *Server) listenAddr() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listenNetwork returns the network the HTTP listener binds on, "tcp4"
|
||||||
|
// for an IPv4 BindAddress and "tcp" for any other.
|
||||||
|
//
|
||||||
|
// On "tcp", Go binds 0.0.0.0 with a socket that answers IPv6 as well,
|
||||||
|
// exactly as it binds ::. "tcp4" keeps 0.0.0.0 to IPv4, which is what
|
||||||
|
// the README says it means. :: stays on "tcp" and answers both. An
|
||||||
|
// IPv4-mapped address such as ::ffff:0.0.0.0 is the IPv4 address it
|
||||||
|
// carries to Go's listener, so it gets "tcp4" too.
|
||||||
|
func (s *Server) listenNetwork() string {
|
||||||
|
addr, err := netip.ParseAddr(s.params.Config.BindAddress)
|
||||||
|
if err == nil && addr.Unmap().Is4() {
|
||||||
|
return "tcp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "tcp"
|
||||||
|
}
|
||||||
|
|
||||||
// newHTTPServer builds the HTTP server for this Server's
|
// newHTTPServer builds the HTTP server for this Server's
|
||||||
// configuration.
|
// configuration.
|
||||||
//
|
//
|
||||||
@@ -67,7 +86,17 @@ func (s *Server) serveUntilShutdown() {
|
|||||||
|
|
||||||
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
||||||
|
|
||||||
err := s.httpServer.ListenAndServe()
|
// ListenAndServe always listens on "tcp", which would undo
|
||||||
|
// listenNetwork.
|
||||||
|
var listenCfg net.ListenConfig
|
||||||
|
|
||||||
|
listener, err := listenCfg.Listen(
|
||||||
|
context.Background(), s.listenNetwork(), s.httpServer.Addr,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
err = s.httpServer.Serve(listener)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
s.log.Error("listen error", "error", err)
|
s.log.Error("listen error", "error", err)
|
||||||
s.shutdownWithFailure()
|
s.shutdownWithFailure()
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
chimw "github.com/go-chi/chi/middleware"
|
||||||
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/static"
|
"sneak.berlin/go/webhooker/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupGlobalMiddleware() {
|
func (s *Server) setupGlobalMiddleware() {
|
||||||
s.router.Use(middleware.RequestID)
|
s.router.Use(chimw.RequestID)
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
s.router.Use(s.mw.SecurityHeaders())
|
||||||
s.router.Use(s.mw.Logging())
|
s.router.Use(s.mw.Logging())
|
||||||
|
|
||||||
@@ -345,7 +346,7 @@ func (s *Server) setupWebhookRoutes() {
|
|||||||
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
|
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
|
||||||
// pins it.
|
// pins it.
|
||||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||||
"/h/{uuid}",
|
middleware.ReceiverRoute,
|
||||||
s.h.HandleWebhook(),
|
s.h.HandleWebhook(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/referer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sentryRedacted stands in for a withheld field on every event shipped
|
// sentryRedacted stands in for a withheld field on every event shipped
|
||||||
@@ -253,6 +254,7 @@ func sentryTransactionName(name, pattern string) string {
|
|||||||
// gorilla/csrf accepts in place of the form field — and the shared
|
// gorilla/csrf accepts in place of the form field — and the shared
|
||||||
// secrets senders put on the receiver route (X-Gitlab-Token and the
|
// secrets senders put on the receiver route (X-Gitlab-Token and the
|
||||||
// per-provider signature headers) would otherwise ship verbatim.
|
// per-provider signature headers) would otherwise ship verbatim.
|
||||||
|
// The Referer it keeps is cut down to its scheme and host.
|
||||||
func keptSentryHeaders(headers map[string]string) map[string]string {
|
func keptSentryHeaders(headers map[string]string) map[string]string {
|
||||||
if len(headers) == 0 {
|
if len(headers) == 0 {
|
||||||
return headers
|
return headers
|
||||||
@@ -261,9 +263,15 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
|
|||||||
kept := make(map[string]string, len(headers))
|
kept := make(map[string]string, len(headers))
|
||||||
|
|
||||||
for name, value := range headers {
|
for name, value := range headers {
|
||||||
if sentryKeepsHeader(name) {
|
if !sentryKeepsHeader(name) {
|
||||||
kept[name] = value
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if http.CanonicalHeaderKey(name) == "Referer" {
|
||||||
|
value = referer.SchemeAndHost(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
kept[name] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
return kept
|
return kept
|
||||||
@@ -271,11 +279,14 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
|
|||||||
|
|
||||||
// sentryKeepsHeader reports whether a request header is routing or
|
// sentryKeepsHeader reports whether a request header is routing or
|
||||||
// content metadata rather than client-chosen payload. Referer is kept
|
// content metadata rather than client-chosen payload. Referer is kept
|
||||||
// on the reasoning that it is browser-set, that the only query
|
// only as its scheme and host, through referer.SchemeAndHost, which
|
||||||
// parameters in this service's own URLs are the login page's `next`,
|
// the access log uses too: its path can be an entrypoint URL, which
|
||||||
// `notice` and the event log's `show`, and that Referrer-Policy is set to
|
// carries the UUID, as when a sender follows a redirect from http://
|
||||||
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
// to https:// or a browser fetches the 404 page's stylesheet from
|
||||||
// local access log line, which holds the rest of the detail.
|
// under /h/. The scheme and host are all gorilla/csrf compares a
|
||||||
|
// Referer by, so a CSRF rejection is still readable from the event.
|
||||||
|
// X-Request-Id ties the event to the local access log line, which
|
||||||
|
// holds the rest of the detail.
|
||||||
func sentryKeepsHeader(name string) bool {
|
func sentryKeepsHeader(name string) bool {
|
||||||
switch http.CanonicalHeaderKey(name) {
|
switch http.CanonicalHeaderKey(name) {
|
||||||
case "Accept",
|
case "Accept",
|
||||||
|
|||||||
@@ -325,6 +325,37 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSentryScrub_KeepsOnlyTheRefererSchemeAndHost sends a receiver
|
||||||
|
// request whose Referer is the entrypoint URL, as a sender's is after
|
||||||
|
// it follows the README's redirect from http:// to https://. The event
|
||||||
|
// keeps the Referer's scheme and host, and carries the UUID nowhere.
|
||||||
|
func TestSentryScrub_KeepsOnlyTheRefererSchemeAndHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
event := onlyEvent(t, sentryCase{
|
||||||
|
scrub: true,
|
||||||
|
panics: true,
|
||||||
|
request: func(client *sentry.Client) *http.Request {
|
||||||
|
req := sentryReceiverRequest(client)
|
||||||
|
req.Header.Set(
|
||||||
|
"Referer",
|
||||||
|
"http://webhooker.example/h/"+sentryReceiverUUID,
|
||||||
|
)
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
}.capture(t))
|
||||||
|
|
||||||
|
assert.NotContains(
|
||||||
|
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"http://webhooker.example",
|
||||||
|
event.Request.Headers["Referer"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost
|
// TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost
|
||||||
// the debugging signal: the route, its scheme and host, the method and
|
// the debugging signal: the route, its scheme and host, the method and
|
||||||
// the metadata headers still identify what failed. On a static route
|
// the metadata headers still identify what failed. On a static route
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
|||||||
s.httpServer = s.newHTTPServer()
|
s.httpServer = s.newHTTPServer()
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
|
//nolint:contextcheck // The listener outlives this hook, whose
|
||||||
|
// context fx cancels once the app has started.
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
s.startupTime = time.Now()
|
s.startupTime = time.Now()
|
||||||
go s.Run()
|
go s.Run()
|
||||||
|
|||||||
@@ -441,10 +441,9 @@ func (s *Session) Regenerate(
|
|||||||
//
|
//
|
||||||
// Whichever comes first ends the session.
|
// Whichever comes first ends the session.
|
||||||
//
|
//
|
||||||
// A session that claims to be authenticated but carries no
|
// A session that claims to be authenticated but lacks the timestamp
|
||||||
// timestamps predates this check; it is treated as expired so the
|
// a deadline is measured from is treated as expired, so the user
|
||||||
// user re-authenticates rather than being granted an unbounded
|
// re-authenticates rather than being granted an unbounded session.
|
||||||
// session.
|
|
||||||
func (s *Session) expired(sess *sessions.Session) bool {
|
func (s *Session) expired(sess *sessions.Session) bool {
|
||||||
now := s.now()
|
now := s.now()
|
||||||
|
|
||||||
|
|||||||
@@ -153,17 +153,15 @@ func TestVersion_IsStableAcrossInvocations(t *testing.T) {
|
|||||||
require.Equal(t, first, second)
|
require.Equal(t, first, second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The build target is the only place composing linker flags. If a
|
// The build target is the only place that stamps the version. If a
|
||||||
// future edit drops either half, the binary silently reports "dev"
|
// future edit drops the -X flag, the binary silently reports "dev"
|
||||||
// again (the defect this package exists for) or fails to link on
|
// again, the defect this package exists for.
|
||||||
// Alpine.
|
func TestMakefile_BuildStampsTheVersion(t *testing.T) {
|
||||||
func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
makefile := read(t, makefilePath)
|
makefile := read(t, makefilePath)
|
||||||
|
|
||||||
require.Contains(t, makefile, "-X main.version=$(VERSION)")
|
require.Contains(t, makefile, "-X main.version=$(VERSION)")
|
||||||
require.Contains(t, makefile, "$(GO_LDFLAGS)")
|
|
||||||
require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
|
require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,8 +220,8 @@ func requireMake(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every compile in the image goes through the build target, so the
|
// The image compiles through the build target, so it carries the
|
||||||
// static relink cannot replace the flags that carry the stamp.
|
// target's version stamp.
|
||||||
func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -232,8 +230,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
|
|||||||
require.NotContains(t, dockerfile, "go build",
|
require.NotContains(t, dockerfile, "go build",
|
||||||
"a raw go build bypasses the Makefile's -X flag")
|
"a raw go build bypasses the Makefile's -X flag")
|
||||||
require.Contains(t, dockerfile, "ARG VERSION")
|
require.Contains(t, dockerfile, "ARG VERSION")
|
||||||
require.Contains(t, dockerfile,
|
require.Contains(t, dockerfile, `RUN make build VERSION="$VERSION"`)
|
||||||
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func read(t *testing.T, path string) string {
|
func read(t *testing.T, path string) string {
|
||||||
|
|||||||
+11
-3
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* The two shared styles for the controls a user clicks. Every page loads
|
* The two shared styles for the controls a user clicks, and the rule that
|
||||||
* this file after tailwind.css. It is plain CSS: make css does not build
|
* hides an element marked x-cloak. Every page loads this file after
|
||||||
* it.
|
* tailwind.css. It is plain CSS: make css does not build it.
|
||||||
*
|
*
|
||||||
* A button is btn-primary, btn-secondary or btn-danger, from input.css.
|
* A button is btn-primary, btn-secondary or btn-danger, from input.css.
|
||||||
* A secondary or inline action, such as Copy beside an entrypoint URL or
|
* A secondary or inline action, such as Copy beside an entrypoint URL or
|
||||||
@@ -46,3 +46,11 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Alpine.js removes x-cloak from an element once it has started, so
|
||||||
|
* something Alpine hides is not seen on the page while it loads.
|
||||||
|
*/
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -58,6 +58,18 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Asks before a form with a data-confirm attribute is sent, such as a
|
||||||
|
// delete form on the webhook page. The browser's own prompt shows the
|
||||||
|
// attribute's text, and the form is sent only if the user accepts.
|
||||||
|
document.addEventListener("submit", function (event) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const message = event.target.getAttribute("data-confirm");
|
||||||
|
if (message !== null && !window.confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Alpine.js components.
|
// Alpine.js components.
|
||||||
//
|
//
|
||||||
// The pages' Content-Security-Policy forbids eval, so the UI loads
|
// The pages' Content-Security-Policy forbids eval, so the UI loads
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{if .Binary}}
|
{{if .Binary}}
|
||||||
<p class="text-xs text-gray-500">This body is binary ({{.Size}} bytes) and is not shown. <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
|
<p class="text-xs text-gray-500">This body is binary ({{.Size}} bytes) and is not shown. <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
|
||||||
{{else if .Text}}
|
{{else if .Text}}
|
||||||
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all"{{if .Scroll}} style="max-height: 32rem; overflow-y: auto"{{end}}>{{.Text}}</pre>
|
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all{{if .Scroll}} max-h-[32rem] overflow-y-auto{{end}}">{{.Text}}</pre>
|
||||||
{{if .Cut}}
|
{{if .Cut}}
|
||||||
<p class="mt-2 text-xs text-gray-500">Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. <a href="{{.EventURL}}" class="btn-small">Show the whole body</a> <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
|
<p class="mt-2 text-xs text-gray-500">Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. <a href="{{.EventURL}}" class="btn-small">Show the whole body</a> <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -4,6 +4,5 @@
|
|||||||
<title>{{block "title" .}}Webhooker{{end}}</title>
|
<title>{{block "title" .}}Webhooker{{end}}</title>
|
||||||
<link rel="stylesheet" href="/s/css/tailwind.css">
|
<link rel="stylesheet" href="/s/css/tailwind.css">
|
||||||
<link rel="stylesheet" href="/s/css/style.css">
|
<link rel="stylesheet" href="/s/css/style.css">
|
||||||
<style>[x-cloak] { display: none !important; }</style>
|
|
||||||
{{block "head" .}}{{end}}
|
{{block "head" .}}{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -13,13 +13,11 @@
|
|||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<!-- A value too wide to sit beside its name moves to the
|
<!-- A value too wide to sit beside its name moves to the
|
||||||
next line, where a list breaks only at the spaces
|
next line, where a list breaks only at the spaces
|
||||||
between its entries. overflow-wrap: anywhere breaks
|
between its entries. wrap-anywhere breaks inside a
|
||||||
inside a value only when it alone is wider than the
|
value only when it alone is wider than the line. -->
|
||||||
line; an inline style, because the committed
|
|
||||||
tailwind.css has no class for it. -->
|
|
||||||
<div class="flex flex-wrap justify-between items-start gap-4">
|
<div class="flex flex-wrap justify-between items-start gap-4">
|
||||||
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
||||||
<code class="text-sm text-gray-900" style="overflow-wrap: anywhere">{{.Value}}</code>
|
<code class="text-sm text-gray-900 wrap-anywhere">{{.Value}}</code>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,10 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
|
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
|
||||||
event log, the navbar and the footer, so an entrypoint URL fits on
|
event log, the navbar and the footer, so an entrypoint URL fits on
|
||||||
one line. An inline style, because the committed tailwind.css has
|
one line. wrap-anywhere goes only on names and descriptions: a
|
||||||
no class this wide. wrap-anywhere goes only on names and
|
row too wide for a phone must still run past the edge, where the
|
||||||
descriptions: a row too wide for a phone must still run past the
|
browser test sees it, rather than break its controls mid-word. -->
|
||||||
edge, where the browser test sees it, rather than break its
|
<div class="max-w-[108rem] mx-auto px-6 py-8">
|
||||||
controls mid-word. -->
|
|
||||||
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/hooks" class="btn-small">← Back to webhooks</a>
|
<a href="/hooks" class="btn-small">← Back to webhooks</a>
|
||||||
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
||||||
@@ -23,11 +21,12 @@
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||||
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<!-- The delete prompts are the browser's own, so they
|
<!-- Before a delete form is sent, static/js/app.js
|
||||||
work without the page's scripts. The template
|
asks in the browser's own prompt, with the form's
|
||||||
escapes each name for the script, so a quote or a
|
data-confirm text; is a line break. The
|
||||||
backslash in it shows as typed. -->
|
template escapes each name for the attribute, so a
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook "{{.Webhook.Name}}"?\n\nThis deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.')">
|
quote in it shows as typed. -->
|
||||||
|
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" data-confirm="Delete webhook "{{.Webhook.Name}}"? This deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-danger">Delete</button>
|
<button type="submit" class="btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -90,7 +89,7 @@
|
|||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete entrypoint "{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}"?\n\nSenders using its URL get an error from now on, and the URL cannot be restored.')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" data-confirm="Delete entrypoint "{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}"? Senders using its URL get an error from now on, and the URL cannot be restored." class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -278,7 +277,7 @@
|
|||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete target "{{.Name}}"?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" data-confirm="Delete target "{{.Name}}"? Nothing more is delivered to it. Its past deliveries stay in the event log." class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user