Author SHA1 Message Date
clawbot 0ef53a4e15 Escape the database path in the SQLite connection string (closes #518)
check / check (push) Canceled after 0s
SQLite reads a file: URI's path only up to the first ? or #, and
decodes %XX escapes in it. With a data directory such as
/srv/hooks#1, every database opened the one file /srv/hooks. The
connection string is now built with url.URL, which escapes the path,
and OmitHost keeps a relative path relative.

Tests open databases in a directory whose name holds #, ? and %, and
at a relative path, and check through PRAGMA database_list that each
opened the file asked for. A handlers test comment that blamed # in
the temporary directory's path is dropped, as that is no longer true.

Model: opus-5-5
2026-10-13 06:35:38 +00:00
34 changed files with 157 additions and 324 deletions
+9 -15
View File
@@ -214,22 +214,16 @@ RUN version="$(make version VERSION="$VERSION")"; \
# (Alpine.js, extracted from its tarball in 3p/) first.
RUN make build VERSION="$VERSION"
# Rebuild with static linking for the Alpine runtime. The cgo binary the
# step above builds is dynamically linked against glibc, which Alpine
# (musl) does not have.
# 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.
#
# A statically linked glibc still loads shared libraries at run time for
# getaddrinfo and dlopen, and Alpine has none to load, so the build tags
# keep both out of the binary: netgo and osusergo select Go's own DNS
# resolver and user lookup instead of glibc's, and
# sqlite_omit_load_extension leaves out SQLite extension loading, which
# webhooker never uses.
#
# The static flags and tags go in through GO_LDFLAGS and GO_TAGS 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_TAGS=netgo,osusergo,sqlite_omit_load_extension GO_LDFLAGS='-extldflags "-static"'
# 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
# alpine:3.21, 2026-03-17
+4 -6
View File
@@ -16,16 +16,14 @@ VERSION ?= $(shell script/version)
# exists to correct.
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
# Extra linker flags and build tags for the build target. The static
# relink in the Dockerfile passes -extldflags and its build tags through
# these rather than a -ldflags of its own, so composing flags cannot drop
# the version stamp.
# 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 ?=
GO_TAGS ?=
bootstrap:
@script/bootstrap
@@ -55,7 +53,7 @@ check:
@script/check
build: assets
go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build
./bin/webhooker
+39 -52
View File
@@ -1235,36 +1235,34 @@ markup. The CSP build runs no expressions, so every Alpine directive in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the webhook page, its edit pages, the
event log, the webhook list, the settings page and the profile page under the
real policy and checks that: the add entrypoint form stays hidden until Add is
clicked; for every target type, the targets section's Add shows only a choice of
type with Next and Cancel, Next shows only that type's fields (no url field for
`database` or `log`), Cancel at either step closes the form, and saving adds the
target; a refused target comes back with its form open, the values entered and
the reason, and after Cancel the next Add starts with an empty form and no
reason; a refused save on the target edit page and on the webhook edit page
comes back with the reason and every value entered; the Copy button beside an
entrypoint URL reads "Copied" once clicked; an entrypoint's Edit button shows
its edit form in place of its description and hides until the form closes,
Cancel hides the form and drops what was typed, as does leaving the page and
going back to it, and Save changes the description; of the recent events on the
webhook page only the newest starts expanded, each expands and collapses, and
Open leads to the event's own page; of the events in the event log only the
newest starts expanded, and an event there expands and collapses when its row's
caret or its ID is clicked, and from the keyboard, but not when its ID is
selected with the mouse, and a delivery's attempts inside it expand and
collapse; at phone width the menu button opens and closes the mobile menu, and
neither the webhook page nor the event log, with a delivery's attempts open,
scrolls sideways or cuts anything off at the page's or a card's edge; and a
target's Delete asks first, naming the target, sends nothing when the answer is
no and deletes the target when it is yes. It also fails if the browser reports a
console warning or error, an uncaught exception, or anything the policy refused.
`make check` and the image build lint it but do not run it, and `make test`
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.
A browser test in `internal/server` loads the webhook page, its edit pages and
the event log under the real policy and checks that: the add entrypoint form
stays hidden until Add is clicked; for every target type, the targets section's
Add shows only a choice of type with Next and Cancel, Next shows only that
type's fields (no url field for `database` or `log`), Cancel at either step
closes the form, and saving adds the target; a refused target comes back with
its form open, the values entered and the reason, and after Cancel the next Add
starts with an empty form and no reason; a refused save on the target edit page
and on the webhook edit page comes back with the reason and every value entered;
the Copy button beside an entrypoint URL reads "Copied" once clicked; an
entrypoint's Edit button shows its edit form in place of its description and
hides until the form closes, Cancel hides the form and drops what was typed, as
does leaving the page and going back to it, and Save changes the description; of
the recent events on the webhook page only the newest starts expanded, each
expands and collapses, and Open leads to the event's own page; of the events in
the event log only the newest starts expanded, and an event there expands and
collapses when its row's caret or its ID is clicked, and from the keyboard, but
not when its ID is selected with the mouse, and a delivery's attempts inside it
expand and collapse; and at phone width the menu button opens and closes the
mobile menu, and neither the webhook page nor the event log, with a delivery's
attempts open, scrolls sideways or cuts anything off at the page's or a card's
edge. It also fails if the browser reports a console warning or error, an
uncaught exception, or anything the policy refused. `make check` and the image
build lint it but do not run it, and `make test` 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
byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -1297,9 +1295,7 @@ 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
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`
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.
generates. `static/css/style.css` is hand-written and is not generated.
`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.
@@ -3080,8 +3076,8 @@ webhooker/
│ ├── static.go # //go:embed directive
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── 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, the rule hiding x-cloak elements
│ ├── js/app.js # Copy-to-clipboard, the delete prompts, and the Alpine.js components
│ ├── 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
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints
@@ -3135,9 +3131,8 @@ Applied to all routes in this order:
1. **RequestID** — Generate unique request IDs (chi built-in)
2. **SecurityHeaders** — Production security headers on every response (HSTS
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP
`default-src 'self'; script-src 'self'; style-src 'self'`, Referrer-Policy,
Permissions-Policy)
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP,
Referrer-Policy, Permissions-Policy)
3. **Logging** — Structured request logging (method, URL, status, latency,
remote IP, client IP, user agent, request ID)
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
@@ -3222,12 +3217,6 @@ credential check, see [The login endpoint](#the-login-endpoint).
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
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
middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
@@ -3430,14 +3419,12 @@ fixed independently of the compiler's:
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/`
first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking
so it runs on musl, with the build tags `netgo`, `osusergo` and
`sqlite_omit_load_extension` so it needs none of glibc's shared libraries at
run time. Both builds go through `make build`, the relink adding its
`-extldflags` via `GO_LDFLAGS` and its tags via `GO_TAGS`, so neither can
drop the `-X` that stamps the version. The version is the `VERSION` build arg
if one is given, otherwise derived from the `.git` in the context, and the
stage fails if a context with `.git` would stamp an empty version, `dev` or
`unknown` (see [Version stamping](#version-stamping)).
so it runs on musl. Both builds go through `make build`, the relink adding
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps
the version. The version is the `VERSION` build arg if one is given,
otherwise derived from the `.git` in the context, and the stage fails if a
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
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for
all SQLite databases, exposes port 8080, and includes a health check against
+2 -1
View File
@@ -45,7 +45,8 @@ type Event struct {
// ResubmittedFromID names the event this one was copied from by
// an operator resubmit. It is nil for an event that arrived on
// the receiver. It is not a foreign key: the source event can be
// the receiver, which is every event created before the column
// existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is
// 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"`
+3 -3
View File
@@ -82,7 +82,7 @@ type Webhook struct {
// value while building the insert statement, which runs after
// 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
// site, such as a REST API or a fixture, cannot bypass it.
// site — a REST API, a fixture, a migration — cannot bypass it.
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
if w.RetentionDays <= 0 {
w.RetentionDays = RetentionForeverDays
@@ -98,8 +98,8 @@ func (w *Webhook) BeforeSave(_ *gorm.DB) error {
// are exempt from reaping.
//
// It accepts the RetentionForeverDays sentinel written by BeforeSave
// and, defensively, any non-positive value, which BeforeSave treats
// as a request to retain forever.
// and, defensively, the non-positive values that rows written before
// the sentinel existed may still carry.
func retainsForever(retentionDays int) bool {
return retentionDays <= 0 ||
retentionDays >= RetentionForeverDays
+1 -1
View File
@@ -253,7 +253,7 @@ func (r *RetentionReaper) reapWebhook(
// row in the database: the sweep then deletes every event, delivery,
// and delivery result, including ones created seconds ago. Rejecting
// out-of-range input at the form is the primary guard; saturating here
// means a value that reaches the row by any other path cannot turn a
// means an old row, a migration, or a future call site cannot turn a
// too-large retention into total data loss.
func retentionCutoff(
now time.Time,
+3 -2
View File
@@ -118,8 +118,9 @@ const SQLiteFilePerm fs.FileMode = 0o600
// SQLite still reports the absence rather than this function
// materializing an empty database the caller asked not to create.
//
// The mode is set on every open, whoever created the file, so a
// database or sidecar found at a wider mode is tightened too.
// Chmod of a file that already exists is what tightens a data
// directory an earlier build left at 0644 — including a developer's
// own scratch directory — without any migration machinery.
func reserveSQLiteFile(path string, create bool) error {
if create {
// gosec G304: the path is the database file the caller asked
+2 -1
View File
@@ -201,7 +201,8 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
// sweepTarget prunes the archive of a single database target.
// A missing, empty, or "never" expiry parses as a zero duration
// and is skipped entirely, so those archives are never pruned.
// and is skipped entirely, so those archives keep exactly the
// behaviour they had before the sweep existed.
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
expiry, err := parseArchiveExpiry(target.Config)
if err != nil {
+2 -13
View File
@@ -32,9 +32,8 @@ func targetTotals(
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
// write records for the webhook page's statistics: the time a delivery
// finished, set only when it becomes delivered or failed and stored in
// the same form as created_at, and one more on its target's delivered
// or failed total.
// finished, set only when it becomes delivered or failed, and one more
// on its target's delivered or failed total.
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
t.Parallel()
@@ -76,16 +75,6 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
if tt.finished {
require.NotNil(t, stored.FinishedAt)
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 {
assert.Nil(t, stored.FinishedAt)
}
+3 -7
View File
@@ -1701,11 +1701,8 @@ func writeDeliveryStatus(
database.DeliveryStatusFailed,
}).
Updates(map[string]any{
"status": status,
// 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(),
"status": status,
"finished_at": time.Now(),
})
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
@@ -1909,13 +1906,12 @@ func (e *Engine) takeForRedispatch(
return false
}
// GORM's clock, as writeDeliveryStatus explains.
res := webhookDB.
Model(&database.Delivery{}).
Where(
"id = ? AND status = ?", deliveryID, eligible,
).
UpdateColumn("updated_at", webhookDB.NowFunc())
UpdateColumn("updated_at", time.Now())
if res.Error != nil {
e.log.Error(
-12
View File
@@ -117,18 +117,6 @@ func seedDelivery(
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
// for HTTP delivery tests.
type httpDeliveryFixture struct {
+1 -11
View File
@@ -462,8 +462,7 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
// TestRedispatchStampsTheRow pins the cadence control: a stranded
// delivery that has just been handed out is not selected again by the
// next tick a minute later. The stamp is stored in the same form as
// created_at.
// next tick a minute later.
func TestRedispatchStampsTheRow(t *testing.T) {
t.Parallel()
@@ -494,13 +493,4 @@ func TestRedispatchStampsTheRow(t *testing.T) {
"a re-dispatched delivery must be stamped so the "+
"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)
}
+1 -5
View File
@@ -277,11 +277,7 @@ func (w *archiveWriter) write(
}
}
// 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()
row.ArchivedAt = time.Now()
err := w.db.Create(&row).Error
if err != nil {
@@ -150,13 +150,6 @@ func TestArchiveWriter_WritesRow(t *testing.T) {
assert.Equal(t, row.ContentType, got[0].ContentType)
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
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(
+2 -2
View File
@@ -82,8 +82,8 @@ func isReservedTargetHeader(name string) bool {
// one pair silently overwriting the other.
//
// An input with no pairs yields an empty map, which omitempty drops
// from the stored config, so a target configured with no headers
// stores no headers key at all.
// from the stored config: a target configured with no headers keeps
// the same config JSON it had before this field existed.
func ParseTargetHeaders(raw string) (map[string]string, error) {
headers := make(map[string]string)
+3 -3
View File
@@ -13,9 +13,9 @@ import (
const RedactionMarker = "(redacted)"
// Redactor removes one target's own credential material from
// a delivery response body, whose text the target's remote
// peer chose, and from a stored delivery error as a second
// line behind the delivery path's own URL masking.
// text that target's remote peer chose: a delivery response
// body, or a delivery error stored before the delivery path
// learned to mask the URLs it embeds.
//
// It removes byte-identical echoes of strings taken from the
// target's stored configuration, and nothing else. Anything
+8 -6
View File
@@ -12,12 +12,13 @@ import (
//
// The bound is enforced here and in SQL, because this page's
// memory profile must not depend on a constant in another
// package staying where it is, or on every stored row having
// been written by the delivery engine under its own cap.
// package staying where it is, and because rows predating the
// delivery engine's own cap or restored from an archive are
// not covered by it at all.
//
// It happens to equal that engine cap, so a row written by the
// engine reaches this one exactly and is never cut twice.
// Nothing here may assume the two differ: see view.
// current engine reaches this one exactly and is never cut
// twice. Nothing here may assume the two differ: see view.
const maxRenderedResponseBytes = 4096
// deliveryResultColumns is the delivery attempt projection.
@@ -39,8 +40,9 @@ const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
//
// Both Error and ResponseBody have been through the target's
// Redactor. The engine already masks the URL out of the
// errors it stores, so for errors this is a second line; for
// response bodies it is the only line, and its reach is what
// errors it stores, so for errors this is a second line
// covering rows written before it did; for response bodies it
// is the only line, and its reach is what
// delivery.Redactor documents.
type DeliveryResultView struct {
AttemptNum int
@@ -158,7 +158,8 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
// TestHandleSourceLogs_EscapesResponseBody proves the
// response body is treated as the untrusted remote content it
// is. The remote chooses these bytes and the page is rendered
// inside the operator's authenticated origin.
// inside the operator's authenticated origin, where the
// application's own CSP allows inline script from 'self'.
func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
t.Parallel()
+5 -4
View File
@@ -35,10 +35,11 @@ const eventBodyQuery = "SELECT cast(body as blob) " +
// response is deliberately not renderable. Content-Disposition
// makes the browser download rather than display it, and the
// octet-stream type plus nosniff stop it being interpreted as
// HTML or script. Without those a stored payload would be shown
// as a page of the operator's own origin. The application's CSP
// is a second defence behind them: it allows no inline script,
// so such a page could not run script of its own.
// HTML or script. Without those a stored payload would execute
// as the logged-in operator. The application's CSP does not
// help here: script-src allows 'unsafe-inline' from 'self', so
// a document served from this origin could run its own inline
// script.
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
+1 -1
View File
@@ -84,7 +84,7 @@ func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx")
assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}")
assert.Contains(t, page, `break-all max-h-[32rem] overflow-y-auto"`)
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
assert.NotContains(t, page, "Showing the first")
assert.Contains(t, page, target.Name)
assert.Contains(t, page, ">failed</span>")
+18 -17
View File
@@ -273,7 +273,7 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
assert.Contains(
t, body,
`<div class="max-w-[108rem] mx-auto px-6 py-8">`,
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
)
assert.Contains(
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
// number of stored events (5 received, 2 removed by retention, so 3,
// the statistics pane's "Within retention" figure), and that an
// entrypoint with no description is named by its URL. Each prompt is
// its form's data-confirm attribute, where &#10; is a line break.
// entrypoint with no description is named by its URL. The template
// writes the slashes after http: as \/, which the browser reads as /.
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
t.Parallel()
@@ -325,28 +325,29 @@ func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body,
`data-confirm="Delete webhook &quot;delete-me&quot;?&#10;&#10;`+
`Delete webhook &quot;delete-me&quot;?\n\n`+
`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,
`data-confirm="Delete entrypoint &quot;Stripe&quot;?&#10;&#10;`+
`Delete entrypoint &quot;Stripe&quot;?\n\n`+
`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,
`data-confirm="Delete entrypoint &quot;http://example.com/h/`+
unnamed.Path+`&quot;?&#10;&#10;`)
`Delete entrypoint &quot;http:\/\/example.com/h/`+
unnamed.Path+`&quot;?`)
assert.Contains(t, body,
`data-confirm="Delete target &quot;t-log&quot;?&#10;&#10;`+
`Delete target &quot;t-log&quot;?\n\n`+
`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
// webhook name with quotes, a backslash, a closing script tag and a
// newline reaches its delete prompt escaped for the attribute, which
// the browser reads back as the name typed: each quote and angle
// bracket as a character reference, the rest as typed. An unescaped
// double quote would end the attribute and cut the prompt short.
// newline reaches its delete prompt escaped for the script, which the
// browser reads back as the name typed: each quote and angle bracket
// as a \u escape, the slash as \/, the newline as \n and the backslash
// doubled. An unescaped newline would break the prompt's script, and
// the form would then submit without asking.
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
t.Parallel()
@@ -372,6 +373,6 @@ func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body,
"Delete webhook &quot;Bob&#39;s &#34;best&#34; \\ hook"+
"&lt;/script&gt;\nline two&quot;?")
"Delete webhook &quot;Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
"\\u003c\\/script\\u003e\\nline two&quot;?")
}
+7
View File
@@ -49,6 +49,13 @@ var (
// working retry configuration over a typo. A default answers a
// question that was not asked; it never answers one that was asked
// 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) {
raw = strings.TrimSpace(raw)
if raw == "" {
+3 -5
View File
@@ -538,9 +538,7 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
// SecurityHeaders returns middleware that sets production security
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
// 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.
// Permissions-Policy.
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
maxAge := s.params.Config.HSTSMaxAge
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
@@ -562,8 +560,8 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
w.Header().Set(
"Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self'; "+
"style-src 'self'",
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'",
)
w.Header().Set(
"Referrer-Policy",
+6 -23
View File
@@ -747,10 +747,10 @@ func TestNoCache_SetsHeaders(t *testing.T) {
// --- SecurityHeaders Middleware Tests ---
// securityHeaders serves one request through SecurityHeaders built
// over a Config with the given HSTSMaxAge, and returns the response's
// headers.
func securityHeaders(t *testing.T, maxAge time.Duration) http.Header {
// hstsHeaders serves one request through SecurityHeaders built over
// a Config with the given HSTSMaxAge, and returns every
// Strict-Transport-Security value on the response.
func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
t.Helper()
cfg := &config.Config{HSTSMaxAge: maxAge}
@@ -772,7 +772,7 @@ func securityHeaders(t *testing.T, maxAge time.Duration) http.Header {
handler.ServeHTTP(w, req)
return w.Header()
return w.Header().Values("Strict-Transport-Security")
}
func TestSecurityHeaders_HSTS(t *testing.T) {
@@ -804,28 +804,11 @@ func TestSecurityHeaders_HSTS(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tt.expected,
securityHeaders(t, tt.maxAge).Values(
"Strict-Transport-Security",
),
)
assert.Equal(t, tt.expected, hstsHeaders(t, tt.maxAge))
})
}
}
// 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 ---
const testBodyLimit int64 = 64
+2 -77
View File
@@ -60,8 +60,8 @@ const (
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives, the copy control and the delete prompts work,
// and that a target's row shows its delivery counts.
// Alpine.js directives and the copy control work, and that a target's
// row shows its delivery counts.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel()
@@ -99,9 +99,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page)
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
checkOtherPages(ctx, t,
srv.URL+"/hooks", srv.URL+"/settings", srv.URL+"/user/browser")
checkDeletePrompt(ctx, t, page, target)
assert.Empty(t, problems(), "the browser reported problems")
}
@@ -1391,75 +1388,3 @@ func checkPhoneWidth(
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")
}
// 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")
}
+4 -3
View File
@@ -441,9 +441,10 @@ func (s *Session) Regenerate(
//
// Whichever comes first ends the session.
//
// A session that claims to be authenticated but lacks the timestamp
// a deadline is measured from is treated as expired, so the user
// re-authenticates rather than being granted an unbounded session.
// A session that claims to be authenticated but carries no
// timestamps predates this check; it is treated as expired so the
// user re-authenticates rather than being granted an unbounded
// session.
func (s *Session) expired(sess *sessions.Session) bool {
now := s.now()
@@ -156,8 +156,7 @@ func TestVersion_IsStableAcrossInvocations(t *testing.T) {
// The build target is the only place composing linker flags. If a
// future edit drops either half, the binary silently reports "dev"
// again (the defect this package exists for) or fails to link on
// Alpine. If it stops passing GO_TAGS, the image's static binary links
// glibc's getaddrinfo and dlopen again.
// Alpine.
func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
t.Parallel()
@@ -165,7 +164,6 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
require.Contains(t, makefile, "-X main.version=$(VERSION)")
require.Contains(t, makefile, "$(GO_LDFLAGS)")
require.Contains(t, makefile, "-tags '$(GO_TAGS)'")
require.Contains(t, makefile, "VERSION ?= $(shell script/version)")
}
@@ -235,9 +233,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
"a raw go build bypasses the Makefile's -X flag")
require.Contains(t, dockerfile, "ARG VERSION")
require.Contains(t, dockerfile,
`make build VERSION="$VERSION"`+
` GO_TAGS=netgo,osusergo,sqlite_omit_load_extension`+
` GO_LDFLAGS='-extldflags "-static"'`)
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
}
func read(t *testing.T, path string) string {
+3 -11
View File
@@ -1,7 +1,7 @@
/*
* The two shared styles for the controls a user clicks, and the rule that
* hides an element marked x-cloak. Every page loads this file after
* tailwind.css. It is plain CSS: make css does not build it.
* The two shared styles for the controls a user clicks. Every page loads
* this file after 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 secondary or inline action, such as Copy beside an entrypoint URL or
@@ -46,11 +46,3 @@
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
-12
View File
@@ -58,18 +58,6 @@
}
})();
// 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.
//
// The pages' Content-Security-Policy forbids eval, so the UI loads
+1 -1
View File
@@ -5,7 +5,7 @@
{{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>
{{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}} max-h-[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}} style="max-height: 32rem; overflow-y: auto"{{end}}>{{.Text}}</pre>
{{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>
{{end}}
+1
View File
@@ -4,5 +4,6 @@
<title>{{block "title" .}}Webhooker{{end}}</title>
<link rel="stylesheet" href="/s/css/tailwind.css">
<link rel="stylesheet" href="/s/css/style.css">
<style>[x-cloak] { display: none !important; }</style>
{{block "head" .}}{{end}}
{{end}}
+5 -3
View File
@@ -13,11 +13,13 @@
<div class="p-4">
<!-- A value too wide to sit beside its name moves to the
next line, where a list breaks only at the spaces
between its entries. wrap-anywhere breaks inside a
value only when it alone is wider than the line. -->
between its entries. overflow-wrap: anywhere breaks
inside a value only when it alone is wider than the
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">
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
<code class="text-sm text-gray-900 wrap-anywhere">{{.Value}}</code>
<code class="text-sm text-gray-900" style="overflow-wrap: anywhere">{{.Value}}</code>
</div>
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
</div>
+13 -12
View File
@@ -5,10 +5,12 @@
{{define "content"}}
<!-- 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
one line. wrap-anywhere goes only on names and descriptions: a
row too wide for a phone must still run past the edge, where the
browser test sees it, rather than break its controls mid-word. -->
<div class="max-w-[108rem] mx-auto px-6 py-8">
one line. An inline style, because the committed tailwind.css has
no class this wide. wrap-anywhere goes only on names and
descriptions: a row too wide for a phone must still run past the
edge, where the browser test sees it, rather than break its
controls mid-word. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
<div class="mb-6">
<a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
@@ -21,12 +23,11 @@
<div class="flex gap-2">
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<!-- Before a delete form is sent, static/js/app.js
asks in the browser's own prompt, with the form's
data-confirm text; &#10; is a line break. The
template escapes each name for the attribute, so a
quote in it shows as typed. -->
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" data-confirm="Delete webhook &quot;{{.Webhook.Name}}&quot;?&#10;&#10;This deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.">
<!-- The delete prompts are the browser's own, so they
work without the page's scripts. The template
escapes each name for the script, so a quote or a
backslash in it shows as typed. -->
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook &quot;{{.Webhook.Name}}&quot;?\n\nThis 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}}">
<button type="submit" class="btn-danger">Delete</button>
</form>
@@ -89,7 +90,7 @@
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" data-confirm="Delete entrypoint &quot;{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}&quot;?&#10;&#10;Senders 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" onsubmit="return confirm('Delete entrypoint &quot;{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}&quot;?\n\nSenders 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}}">
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form>
@@ -277,7 +278,7 @@
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" data-confirm="Delete target &quot;{{.Name}}&quot;?&#10;&#10;Nothing 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" onsubmit="return confirm('Delete target &quot;{{.Name}}&quot;?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form>