Author SHA1 Message Date
sneak 3b1029f65c Drop 'unsafe-inline' from the Content-Security-Policy (closes #530)
check / check (push) Waiting to run
The delete prompts on the webhook page were inline onsubmit handlers.
Each delete form now carries its question in a data-confirm attribute,
which one submit listener in static/js/app.js asks. The three style
attributes became Tailwind classes, with tailwind.css regenerated, and
the x-cloak rule moved to style.css. The policy now sends
script-src 'self' and style-src 'self'.

The browser test now answers a target's delete prompt no and then yes,
and loads the webhook list, settings and profile pages under the
policy. The README states the policy as sent.

Model: opus-5-5
2026-10-15 09:46:04 +00:00
clawbot f14fe02011 Store delivery and archive times without the monotonic reading (closes #529)
check / check (push) Waiting to run
finished_at, archived_at and the updated_at stamp a re-dispatch
writes came from a bare time.Now(). The SQLite driver stores a time
as time.Time.String(), which appends the monotonic clock reading
(" m=+…") to a time read from time.Now(). All three now come from
GORM's own clock, NowFunc(), which stamps created_at, so they are
stored in the same form. The tests for each write read the stored
text back.

Model: opus-5-5
2026-10-15 11:15:35 +02:00
21 changed files with 267 additions and 101 deletions
+44 -33
View File
@@ -1235,34 +1235,36 @@ 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 webhook page, its edit pages, the
the event log under the real policy and checks that: the add entrypoint form event log, the webhook list, the settings page and the profile page under the
stays hidden until Add is clicked; for every target type, the targets section's real policy and checks that: the add entrypoint form stays hidden until Add is
Add shows only a choice of type with Next and Cancel, Next shows only that clicked; for every target type, the targets section's Add shows only a choice of
type's fields (no url field for `database` or `log`), Cancel at either step type with Next and Cancel, Next shows only that type's fields (no url field for
closes the form, and saving adds the target; a refused target comes back with `database` or `log`), Cancel at either step closes the form, and saving adds the
its form open, the values entered and the reason, and after Cancel the next Add target; a refused target comes back with its form open, the values entered and
starts with an empty form and no reason; a refused save on the target edit page the reason, and after Cancel the next Add starts with an empty form and no
and on the webhook edit page comes back with the reason and every value entered; reason; a refused save on the target edit page and on the webhook edit page
the Copy button beside an entrypoint URL reads "Copied" once clicked; an comes back with the reason and every value entered; the Copy button beside an
entrypoint's Edit button shows its edit form in place of its description and entrypoint URL reads "Copied" once clicked; an entrypoint's Edit button shows
hides until the form closes, Cancel hides the form and drops what was typed, as its edit form in place of its description and hides until the form closes,
does leaving the page and going back to it, and Save changes the description; of Cancel hides the form and drops what was typed, as does leaving the page and
the recent events on the webhook page only the newest starts expanded, each going back to it, and Save changes the description; of the recent events on the
expands and collapses, and Open leads to the event's own page; of the events in webhook page only the newest starts expanded, each expands and collapses, and
the event log only the newest starts expanded, and an event there expands and Open leads to the event's own page; of the events in the event log only the
collapses when its row's caret or its ID is clicked, and from the keyboard, but newest starts expanded, and an event there expands and collapses when its row's
not when its ID is selected with the mouse, and a delivery's attempts inside it caret or its ID is clicked, and from the keyboard, but not when its ID is
expand and collapse; and at phone width the menu button opens and closes the selected with the mouse, and a delivery's attempts inside it expand and
mobile menu, and neither the webhook page nor the event log, with a delivery's collapse; at phone width the menu button opens and closes the mobile menu, and
attempts open, scrolls sideways or cuts anything off at the page's or a card's neither the webhook page nor the event log, with a delivery's attempts open,
edge. It also fails if the browser reports a console warning or error, an scrolls sideways or cuts anything off at the page's or a card's edge; and a
uncaught exception, or anything the policy refused. `make check` and the image target's Delete asks first, naming the target, sends nothing when the answer is
build lint it but do not run it, and `make test` leaves it out (its file is no and deletes the target when it is yes. It also fails if the browser reports a
built only with the `browser` build tag). Run it with `make test-browser` after console warning or error, an uncaught exception, or anything the policy refused.
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which `make check` and the image build lint it but do not run it, and `make test`
runs the test in a digest-pinned headless browser image, so the host needs no leaves it out (its file is built only with the `browser` build tag). Run it with
browser. `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 +1297,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.
@@ -3076,8 +3080,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 +3135,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 +3222,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
+13 -2
View File
@@ -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)
} }
+7 -3
View File
@@ -1701,8 +1701,11 @@ func writeDeliveryStatus(
database.DeliveryStatusFailed, database.DeliveryStatusFailed,
}). }).
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(
+12
View File
@@ -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 {
+11 -1
View File
@@ -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)
} }
+5 -1
View File
@@ -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(
@@ -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()
+4 -5
View File
@@ -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)
+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, strconv.Itoa(len(body))+" bytes")
assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx") 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, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\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>")
+17 -18
View File
@@ -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 &#10; 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 &quot;delete-me&quot;?\n\n`+ `data-confirm="Delete webhook &quot;delete-me&quot;?&#10;&#10;`+
`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 &quot;Stripe&quot;?\n\n`+ `data-confirm="Delete entrypoint &quot;Stripe&quot;?&#10;&#10;`+
`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 &quot;http:\/\/example.com/h/`+ `data-confirm="Delete entrypoint &quot;http://example.com/h/`+
unnamed.Path+`&quot;?`) unnamed.Path+`&quot;?&#10;&#10;`)
assert.Contains(t, body, assert.Contains(t, body,
`Delete target &quot;t-log&quot;?\n\n`+ `data-confirm="Delete target &quot;t-log&quot;?&#10;&#10;`+
`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 &quot;Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+ "Delete webhook &quot;Bob&#39;s &#34;best&#34; \\ hook"+
"\\u003c\\/script\\u003e\\nline two&quot;?") "&lt;/script&gt;\nline two&quot;?")
} }
+5 -3
View File
@@ -538,7 +538,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 +562,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",
+23 -6
View File
@@ -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
+77 -2
View File
@@ -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()
@@ -99,6 +99,9 @@ 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")
checkDeletePrompt(ctx, t, page, target)
assert.Empty(t, problems(), "the browser reported problems") assert.Empty(t, problems(), "the browser reported problems")
} }
@@ -1388,3 +1391,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")
}
+11 -3
View File
@@ -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
+12
View File
@@ -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
+1 -1
View File
@@ -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}}
-1
View File
@@ -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}}
+3 -5
View File
@@ -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>
+12 -13
View File
@@ -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">&larr; Back to webhooks</a> <a href="/hooks" class="btn-small">&larr; 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; &#10; 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 &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.')"> 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.">
<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 &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"> <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">
<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 &quot;{{.Name}}&quot;?\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 &quot;{{.Name}}&quot;?&#10;&#10;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>