Author SHA1 Message Date
clawbot 1dc7636ace Say how to allow a refused private target address (closes #398)
check / check (push) Waiting to run
Adding or editing an http or slack target whose address is private or
reserved was refused with no hint that the refusal is deliberate or
that it can be lifted. The refusal now adds that such addresses are
refused by default and that the server's ALLOWED_EGRESS_CIDRS setting
allows named networks, naming the README section "Allowing egress to
your own network". Metadata refusals do not get it.

The default blocklist's public addresses move to a list of their own,
still checked after the allowlist, and are refused as cloud metadata
addresses. The private-and-reserved error is exported as
ErrBlockedPrivateOrReservedIP so the handler can tell them apart.

Model: opus-5-5
2026-10-01 23:25:37 +00:00
29 changed files with 467 additions and 750 deletions
-6
View File
@@ -2932,12 +2932,6 @@ local record instead of nothing. What that placement gives up is
recovery of a panic in the six entries above it, none of which does recovery of a panic in the six entries above it, none of which does
more than set a header or start a timer. more than set a header or start a timer.
Each admin page route group (`/pages`, `/user/*`, `/sources`,
`/source/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
set, its own **Sentry** error reporting. That Recoverer answers a panic
with the `500` error page in the normal layout; the global one keeps
the plain-text `500` for every other route.
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`, Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
`/source/*`) apply a **MaxBodySize** middleware that limits `/source/*`) apply a **MaxBodySize** middleware that limits
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
+5 -5
View File
@@ -40,11 +40,6 @@ const (
ExportPendingSweepMinAge = pendingSweepMinAge ExportPendingSweepMinAge = pendingSweepMinAge
) )
// ExportIsBlockedIP exposes isBlockedIP for testing.
func ExportIsBlockedIP(ip net.IP) bool {
return isBlockedIP(ip)
}
// NewTestGuard builds an SSRF Guard from an explicit egress // NewTestGuard builds an SSRF Guard from an explicit egress
// allowlist, without going through config. Passing no prefixes // allowlist, without going through config. Passing no prefixes
// yields the default guard, which blocks every private/reserved // yields the default guard, which blocks every private/reserved
@@ -70,6 +65,11 @@ func ExportBlockedNetworks() []*net.IPNet {
return blockedNetworks return blockedNetworks
} }
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
func ExportBlockedPublicNetworks() []*net.IPNet {
return blockedPublicNetworks
}
// ExportIsForwardableHeader exposes isForwardableHeader. // ExportIsForwardableHeader exposes isForwardableHeader.
func ExportIsForwardableHeader(name string) bool { func ExportIsForwardableHeader(name string) bool {
return isForwardableHeader(name) return isForwardableHeader(name)
+46 -25
View File
@@ -25,8 +25,16 @@ var (
errNoIPs = errors.New( errNoIPs = errors.New(
"hostname resolved to no IP addresses", "hostname resolved to no IP addresses",
) )
errBlockedIP = errors.New( // ErrBlockedPrivateOrReservedIP reports an address in the
"blocked private, reserved or cloud metadata address", // default blocklist's private and reserved ranges,
// blockedNetworks.
ErrBlockedPrivateOrReservedIP = errors.New(
"blocked private or reserved address",
)
// errBlockedPublicMetadata reports a public address on the
// default blocklist, one in blockedPublicNetworks.
errBlockedPublicMetadata = errors.New(
"blocked cloud metadata address",
) )
errBlockedMetadata = errors.New( errBlockedMetadata = errors.New(
"blocked link-local or cloud instance metadata " + "blocked link-local or cloud instance metadata " +
@@ -37,22 +45,32 @@ var (
) )
) )
// blockedNetworks is the default blocklist: the private and // blockedNetworks and blockedPublicNetworks together are the
// reserved IP ranges, plus the public cloud metadata addresses, // default blocklist: the private and reserved IP ranges, plus
// that are blocked to prevent SSRF attacks. An operator can // the public cloud metadata addresses, that are blocked to
// permit specific blocks out of this set with // prevent SSRF attacks. An operator can permit specific blocks
// ALLOWED_EGRESS_CIDRS; see Guard. // out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
// //
// A public address belongs on the default blocklist only if it // blockedNetworks holds the private and reserved IP ranges.
// hands credentials, user data or bootstrap material to whatever
// can reach it, without the caller presenting anything. A
// provider's other public addresses are not refused, since
// reaching them can be legitimate and no list of them could be
// complete.
// //
//nolint:gochecknoglobals // package-level network list is appropriate here //nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet var blockedNetworks []*net.IPNet
// blockedPublicNetworks holds the default blocklist's public
// addresses, kept apart from blockedNetworks so that they are
// refused as cloud metadata addresses, never as private or
// reserved ones.
//
// A public address belongs on the default blocklist only if it
// hands credentials, user data or bootstrap material to whatever
// can reach it, without the caller presenting anything; it goes
// in this list. A provider's other public addresses are not
// refused, since reaching them can be legitimate and no list of
// them could be complete.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedPublicNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can // alwaysBlockedNetworks are the ranges no configuration can
// open: the link-local blocks and the cloud instance metadata // open: the link-local blocks and the cloud instance metadata
// endpoints that live outside them. Reaching one is credential // endpoints that live outside them. Reaching one is credential
@@ -88,8 +106,8 @@ var blockedNetworks []*net.IPNet
// when it clears both halves. Nothing in this list can be // when it clears both halves. Nothing in this list can be
// reopened, so putting a public address here leaves the operator // reopened, so putting a public address here leaves the operator
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS // no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
// exists to remove. Default-block it in blockedNetworks instead, // exists to remove. Default-block it in blockedPublicNetworks
// which an allowlist can override. // instead, which an allowlist can override.
// //
// This is a criterion, not an enumeration of every metadata // This is a criterion, not an enumeration of every metadata
// address in existence. // address in existence.
@@ -130,6 +148,9 @@ func init() {
"::1/128", "::1/128",
"fc00::/7", "fc00::/7",
"fe80::/10", "fe80::/10",
})
blockedPublicNetworks = mustParseCIDRs([]string{
// Azure WireServer, a public address that serves VM credentials. // Azure WireServer, a public address that serves VM credentials.
"168.63.129.16/32", "168.63.129.16/32",
}) })
@@ -225,13 +246,6 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
return false return false
} }
// isBlockedIP checks whether an IP address falls within
// the default blocklist, before any operator allowlist is
// considered.
func isBlockedIP(ip net.IP) bool {
return matchesAny(blockedNetworks, ip)
}
// Guard makes every SSRF decision in the process. // Guard makes every SSRF decision in the process.
// //
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and // It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
@@ -332,7 +346,8 @@ func (g *Guard) allows(ip net.IP) bool {
// consulted, so no configured CIDR reaches link-local or a // consulted, so no configured CIDR reaches link-local or a
// cloud metadata endpoint at a non-public address. // cloud metadata endpoint at a non-public address.
// 2. The allowlist is consulted next, so a listed private // 2. The allowlist is consulted next, so a listed private
// network becomes reachable. // network, or a listed public address on the default
// blocklist, becomes reachable.
// 3. Everything else keeps the default blocklist's answer. // 3. Everything else keeps the default blocklist's answer.
func (g *Guard) checkIP(ip net.IP) error { func (g *Guard) checkIP(ip net.IP) error {
if matchesAny(alwaysBlockedNetworks, ip) { if matchesAny(alwaysBlockedNetworks, ip) {
@@ -345,9 +360,15 @@ func (g *Guard) checkIP(ip net.IP) error {
return nil return nil
} }
if isBlockedIP(ip) { if matchesAny(blockedNetworks, ip) {
return fmt.Errorf( return fmt.Errorf(
"target IP %s: %w", ip, errBlockedIP, "target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
)
}
if matchesAny(blockedPublicNetworks, ip) {
return fmt.Errorf(
"target IP %s: %w", ip, errBlockedPublicMetadata,
) )
} }
+80 -2
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"net/url" "net/url"
"slices"
"testing" "testing"
"time" "time"
@@ -23,6 +24,10 @@ const (
metadataIP = "169.254.169.254" metadataIP = "169.254.169.254"
metadataURL = "http://" + metadataIP + "/latest/meta-data/" metadataURL = "http://" + metadataIP + "/latest/meta-data/"
// linkLocalIPv4 is the IPv4 link-local block, which holds
// metadataIP.
linkLocalIPv4 = "169.254.0.0/16"
// loopbackHookURL is a target on this host: blocked by // loopbackHookURL is a target on this host: blocked by
// default, reachable only once an operator allowlists // default, reachable only once an operator allowlists
// loopback. // loopback.
@@ -237,7 +242,7 @@ func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
}, },
{ {
name: "whole link-local block", name: "whole link-local block",
allow: "169.254.0.0/16", allow: linkLocalIPv4,
target: metadataURL, target: metadataURL,
}, },
{ {
@@ -412,6 +417,9 @@ func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
"WireServer must be refused by the default blocklist, "+ "WireServer must be refused by the default blocklist, "+
"which an allowlist can override", "which an allowlist can override",
) )
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
"WireServer is public, not private or reserved",
)
assertDialRefused(t, defaultGuard, target) assertDialRefused(t, defaultGuard, target)
@@ -496,7 +504,7 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
want := []string{ want := []string{
// IPv4 link-local: the 169.254.169.254 metadata // IPv4 link-local: the 169.254.169.254 metadata
// service on AWS, Azure and others. // service on AWS, Azure and others.
"169.254.0.0/16", linkLocalIPv4,
// IPv6 link-local. // IPv6 link-local.
"fe80::/10", "fe80::/10",
// AWS IPv6 IMDS, inside the ULA space an operator may // AWS IPv6 IMDS, inside the ULA space an operator may
@@ -526,6 +534,76 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
assert.Equal(t, want, got) assert.Equal(t, want, got)
} }
// TestDefaultBlocklist_PinnedSet pins the default blocklist, its
// private and reserved ranges and its public addresses together,
// and how ALLOWED_EGRESS_CIDRS opens each entry: listing an entry
// opens it unless the unconditional set also holds it.
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
t.Parallel()
tests := []struct {
cidr string
reopenable bool
}{
{"127.0.0.0/8", true},
{"10.0.0.0/8", true},
{"172.16.0.0/12", true},
{"192.168.0.0/16", true},
{linkLocalIPv4, false},
{"0.0.0.0/8", true},
{"100.64.0.0/10", true},
{"192.0.0.0/24", true},
{"192.0.2.0/24", true},
{"198.18.0.0/15", true},
{"198.51.100.0/24", true},
{"203.0.113.0/24", true},
{"224.0.0.0/4", true},
{"240.0.0.0/4", true},
{"::1/128", true},
{"fc00::/7", true},
{"fe80::/10", false},
{"168.63.129.16/32", true},
}
want := make([]string, 0, len(tests))
for _, tt := range tests {
want = append(want, tt.cidr)
}
nets := slices.Concat(
delivery.ExportBlockedNetworks(),
delivery.ExportBlockedPublicNetworks(),
)
got := make([]string, 0, len(nets))
for _, n := range nets {
got = append(got, n.String())
}
assert.ElementsMatch(t, want, got)
for _, tt := range tests {
t.Run(tt.cidr, func(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(tt.cidr)
ip := net.IP(prefix.Addr().AsSlice())
require.Error(t,
delivery.NewTestGuard().ExportCheckIP(ip),
"the default guard must refuse %s", ip,
)
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
if tt.reopenable {
assert.NoError(t, err, "listing %s must open it", tt.cidr)
} else {
assert.Error(t, err, "listing %s must not open it", tt.cidr)
}
})
}
}
// requireLoopback fails the test unless rawURL's host is a // requireLoopback fails the test unless rawURL's host is a
// loopback address, so the allowlist test cannot silently stop // loopback address, so the allowlist test cannot silently stop
// exercising a blocked range. // exercising a blocked range.
+6 -4
View File
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
func TestIsBlockedIP_PrivateRanges(t *testing.T) { func TestGuardCheckIP_PrivateRanges(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
@@ -56,12 +56,14 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) {
"failed to parse IP %s", tt.ip, "failed to parse IP %s", tt.ip,
) )
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
assert.Equal(t, assert.Equal(t,
tt.blocked, tt.blocked,
delivery.ExportIsBlockedIP(ip), refused,
"isBlockedIP(%s) = %v, want %v", "default guard refuses %s = %v, want %v",
tt.ip, tt.ip,
delivery.ExportIsBlockedIP(ip), refused,
tt.blocked, tt.blocked,
) )
}) })
+23 -5
View File
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
h.renderError(w, r, http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
@@ -166,7 +166,11 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password) valid, err := database.VerifyPassword(password, user.Password)
if err != nil { if err != nil {
h.serverError(w, r, "failed to verify password", err) h.log.Error("failed to verify password", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return user, err return user, err
} }
@@ -238,14 +242,24 @@ func (h *Handlers) createAuthenticatedSession(
) error { ) error {
oldSess, err := h.session.Get(r) oldSess, err := h.session.Get(r)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get session", err) h.log.Error("failed to get session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
sess, err := h.session.Regenerate(r, w, oldSess) sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil { if err != nil {
h.serverError(w, r, "failed to regenerate session", err) h.log.Error(
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
@@ -254,7 +268,11 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess) err = h.session.Save(r, w, sess)
if err != nil { if err != nil {
h.serverError(w, r, "failed to save session", err) h.log.Error("failed to save session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
+9 -7
View File
@@ -105,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -122,14 +124,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook, webhook database.Webhook,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get webhook database", err) h.serverError(w, "failed to get webhook database", err)
return return
} }
@@ -171,7 +173,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"), &original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error ).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return nil, false return nil, false
} }
@@ -193,7 +195,7 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to count in-flight deliveries", err, w, "failed to count in-flight deliveries", err,
) )
return return
@@ -210,7 +212,7 @@ func (h *Handlers) queueReplay(
err = webhookDB. err = webhookDB.
First(&event, "id = ?", original.EventID).Error First(&event, "id = ?", original.EventID).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to load event for replay", err) h.serverError(w, "failed to load event for replay", err)
return return
} }
@@ -220,7 +222,7 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to create replay delivery", err, w, "failed to create replay delivery", err,
) )
return return
-53
View File
@@ -1,53 +0,0 @@
package handlers_test
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
// which cannot render answers with the status it was reporting, as
// plain text, and is not attempted again: a page whose own render
// fails reaches the error page, and the error page failing as well
// ends there with the 500.
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// .Status is an int, so asking it for a field fails the render.
failing := `{{.Status.Missing}}`
h.AddTemplateForTest("error.html", template.Must(
template.New("error").Parse(failing),
))
h.AddTemplateForTest("failing.html", template.Must(
template.New("failing").Parse(`{{.Data.Missing}}`),
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
w := httptest.NewRecorder()
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "Not Found\n", w.Body.String())
w = httptest.NewRecorder()
h.RenderTemplateForTest(w, req, "failing.html", 0)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
}
+5 -5
View File
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
// steered by a client. // steered by a client.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
eventID string, eventID string,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get webhook database", err) h.serverError(w, "failed to get webhook database", err)
return return
} }
body, found, err := eventBody(webhookDB, webhook.ID, eventID) body, found, err := eventBody(webhookDB, webhook.ID, eventID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to read event body", err) h.serverError(w, "failed to read event body", err)
return return
} }
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
// row and the whole body is served, or it does not and the // row and the whole body is served, or it does not and the
// response is a clean 404. // response is a clean 404.
if !found { if !found {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
+8 -8
View File
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
// alphabet rather than from the request. // alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get webhook database", err) h.serverError(w, "failed to get webhook database", err)
return return
} }
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
webhookDB, webhook.ID, eventID.String(), webhookDB, webhook.ID, eventID.String(),
) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to load event to resubmit", err) h.serverError(w, "failed to load event to resubmit", err)
return return
} }
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
// A miss is a 404 whether the event was reaped, belongs to // A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed. // another webhook, or never existed.
if !found { if !found {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
// inactive one is skipped rather than refused. // inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID) targets, err := h.loadActiveTargets(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to query targets", err) h.serverError(w, "failed to query targets", err)
return return
} }
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
targets, targets,
) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to store resubmitted event", err) h.serverError(w, "failed to store resubmitted event", err)
return return
} }
+2 -12
View File
@@ -1,11 +1,9 @@
package handlers package handlers
import ( import (
"context"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -65,20 +63,12 @@ func (s *Handlers) LoadEventLogViewsForTest(
page int, page int,
) []EventLogView { ) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries( views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil, page, w, webhook, nil, page,
) )
return views return views
} }
// newRequestForTest is the request the helpers here pass on for
// callers that have none: it is used only to render the error page.
func newRequestForTest() *http.Request {
return httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
}
// AddTemplateForTest registers a template under a page name so that // AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a // the handlers_test package can drive the render path with a
// template of its own. // template of its own.
@@ -132,5 +122,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter, w http.ResponseWriter,
expiry string, expiry string,
) (string, error) { ) (string, error) {
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry) return s.buildDatabaseTargetConfig(w, expiry)
} }
+19 -89
View File
@@ -135,7 +135,6 @@ func New(
"source_edit.html": parsePageTemplate("source_edit.html"), "source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"), "source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"), "target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
@@ -147,16 +146,6 @@ func New(
return s, nil return s, nil
} }
// HandleErrorPage returns a handler that answers every request with
// the error page for status. The router uses it for unknown paths, the
// CSRF middleware for a refused form, and each admin page route
// group's recoverer for a panic.
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, status)
}
}
func (s *Handlers) respondJSON( func (s *Handlers) respondJSON(
w http.ResponseWriter, w http.ResponseWriter,
_ *http.Request, _ *http.Request,
@@ -174,76 +163,15 @@ func (s *Handlers) respondJSON(
} }
} }
// serverError logs an error and answers with the 500 error page. // serverError logs an error and sends a 500 response.
func (s *Handlers) serverError( func (s *Handlers) serverError(
w http.ResponseWriter, r *http.Request, msg string, err error, w http.ResponseWriter, msg string, err error,
) { ) {
s.log.Error(msg, "error", err) s.log.Error(msg, "error", err)
s.renderError(w, r, http.StatusInternalServerError) http.Error(
} w, "Internal server error",
http.StatusInternalServerError,
// renderError answers with status and the error page: the normal )
// layout, one fixed line explaining the status, and a link back to the
// webhook list, or to sign-in when nobody is signed in.
//
// It renders the page itself rather than through renderTemplate,
// whose own failure comes here. If the error page cannot render
// either, the answer is the same status in plain text: never a second
// attempt, and never a different status.
func (s *Handlers) renderError(
w http.ResponseWriter,
r *http.Request,
status int,
) {
// The page names the signed-in user, and some error pages are
// served outside the routes where NoCache runs.
w.Header().Set("Cache-Control", "no-store")
data := s.pageData(r, map[string]any{
"Status": status,
"StatusText": http.StatusText(status),
"Message": errorPageText(status),
})
var buf bytes.Buffer
err := s.templates["error.html"].Execute(&buf, data)
if err != nil {
s.log.Error("failed to render error page", "error", err)
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
s.log.Error("failed to write error page", "error", err)
}
}
// errorPageText is the line the error page shows for status. It is
// fixed per status, so the page tells the reader no more than the
// plain-text answers it replaced did.
func errorPageText(status int) string {
switch status {
case http.StatusBadRequest:
return "The request could not be read."
case http.StatusForbidden:
return "The request was refused. If it came from a form " +
"left open for a long time, reload the page and try " +
"again."
case http.StatusNotFound:
return "There is nothing here. It may have been deleted, " +
"or the address may be wrong."
case http.StatusServiceUnavailable:
return "The server is busy. Please try again in a moment."
default: // http.StatusInternalServerError
return "Something went wrong on the server. Please try " +
"again."
}
} }
// UserInfo represents user information for templates // UserInfo represents user information for templates
@@ -296,17 +224,14 @@ func (s *Handlers) renderTemplate(
"template not found", "template not found",
"template", pageTemplate, "template", pageTemplate,
) )
s.renderError(w, r, http.StatusInternalServerError) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
}
// pageData adds the fields the shared layout renders to a page's own
// data.
func (s *Handlers) pageData(r *http.Request, data any) any {
userInfo := s.getUserInfo(r) userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r) csrfToken := middleware.CSRFToken(r)
@@ -320,16 +245,19 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
m["User"] = userInfo m["User"] = userInfo
m["CSRFToken"] = csrfToken m["CSRFToken"] = csrfToken
m["Version"] = version m["Version"] = version
s.executeTemplate(w, tmpl, m)
return m return
} }
return templateDataWrapper{ wrapper := templateDataWrapper{
User: userInfo, User: userInfo,
CSRFToken: csrfToken, CSRFToken: csrfToken,
Version: version, Version: version,
Data: data, Data: data,
} }
s.executeTemplate(w, tmpl, wrapper)
} }
// executeTemplate renders the template into a buffer and writes to // executeTemplate renders the template into a buffer and writes to
@@ -342,7 +270,6 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
// this reason. // this reason.
func (s *Handlers) executeTemplate( func (s *Handlers) executeTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
tmpl *template.Template, tmpl *template.Template,
data any, data any,
) { ) {
@@ -353,7 +280,10 @@ func (s *Handlers) executeTemplate(
s.log.Error( s.log.Error(
"failed to execute template", "error", err, "failed to execute template", "error", err,
) )
s.renderError(w, r, http.StatusInternalServerError) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
+2 -6
View File
@@ -307,14 +307,10 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t, http.StatusInternalServerError, w.Code, t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500", "a failed render must report a 500",
) )
assert.NotContains( assert.Equal(
t, w.Body.String(), partialPageMarker, t, "Internal server error\n", w.Body.String(),
"the response must carry no part of the aborted page", "the response must carry no part of the aborted page",
) )
assert.Contains(
t, w.Body.String(), "500 Internal Server Error",
"a failed render must answer with the error page",
)
} }
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) { func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
+28 -16
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
@@ -36,14 +37,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
h.renderError(w, r, http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
successMessage, errorMessage, handled := h.applyPasswordChange( successMessage, errorMessage, handled := h.applyPasswordChange(
r.Context(),
w, w,
r,
sessionUsername, sessionUsername,
// PostFormValue, not FormValue: the credential must // PostFormValue, not FormValue: the credential must
// come from the body, never from the query string. // come from the body, never from the query string.
@@ -65,12 +66,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
// applyPasswordChange verifies the current password and, on success, // applyPasswordChange verifies the current password and, on success,
// persists a fresh hash for the user, reusing the same helpers that // persists a fresh hash for the user, reusing the same helpers that
// bootstrap the admin user. It returns the success and error messages // bootstrap the admin user. It returns the success and error messages
// to display on the profile page. On an internal failure it writes the // to display on the profile page. On an internal failure it writes a
// error page itself and returns handled=false, signalling the caller // 500 response itself and returns handled=false, signalling the caller
// to stop without re-rendering the page. // to stop without re-rendering the page.
func (h *Handlers) applyPasswordChange( func (h *Handlers) applyPasswordChange(
ctx context.Context,
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
username, currentPassword, newPassword, confirmPassword string, username, currentPassword, newPassword, confirmPassword string,
) (string, string, bool) { ) (string, string, bool) {
// This endpoint verifies one password and hashes another, at // This endpoint verifies one password and hashes another, at
@@ -78,10 +79,15 @@ func (h *Handlers) applyPasswordChange(
// endpoint uses. The bound is per hash, not per endpoint: leaving // endpoint uses. The bound is per hash, not per endpoint: leaving
// this path outside it would leave a hole in it. The slot is held // this path outside it would leave a hole in it. The slot is held
// across both hashes. // across both hashes.
release, ok := h.mw.BeginPasswordVerification(r.Context()) release, ok := h.mw.BeginPasswordVerification(ctx)
if !ok { if !ok {
h.log.Warn("password verification capacity exhausted") h.log.Warn("password verification capacity exhausted")
h.renderError(w, r, http.StatusServiceUnavailable) http.Error(
w,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
return "", "", false return "", "", false
} }
@@ -97,7 +103,7 @@ func (h *Handlers) applyPasswordChange(
).First(&user).Error ).First(&user).Error
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to load user for password change", err, w, "failed to load user for password change", err,
) )
return "", "", false return "", "", false
@@ -107,7 +113,7 @@ func (h *Handlers) applyPasswordChange(
currentPassword, user.Password, currentPassword, user.Password,
) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to verify password", err) h.serverError(w, "failed to verify password", err)
return "", "", false return "", "", false
} }
@@ -126,7 +132,7 @@ func (h *Handlers) applyPasswordChange(
hashedPassword, err := database.HashPassword(newPassword) hashedPassword, err := database.HashPassword(newPassword)
if err != nil { if err != nil {
h.serverError(w, r, "failed to hash new password", err) h.serverError(w, "failed to hash new password", err)
return "", "", false return "", "", false
} }
@@ -135,7 +141,7 @@ func (h *Handlers) applyPasswordChange(
"password", hashedPassword, "password", hashedPassword,
).Error ).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to update password", err) h.serverError(w, "failed to update password", err)
return "", "", false return "", "", false
} }
@@ -156,7 +162,7 @@ func (h *Handlers) profileOwnerOrDeny(
) (string, string, bool) { ) (string, string, bool) {
requestedUsername := chi.URLParam(r, "username") requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" { if requestedUsername == "" {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return "", "", false return "", "", false
} }
@@ -166,7 +172,7 @@ func (h *Handlers) profileOwnerOrDeny(
// unexpected retrieval error. // unexpected retrieval error.
sess, err := h.session.Get(r) sess, err := h.session.Get(r)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get session", err) h.serverError(w, "failed to get session", err)
return "", "", false return "", "", false
} }
@@ -174,7 +180,10 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUsername, ok := h.session.GetUsername(sess) sessionUsername, ok := h.session.GetUsername(sess)
if !ok { if !ok {
h.log.Error("authenticated session missing username") h.log.Error("authenticated session missing username")
h.renderError(w, r, http.StatusInternalServerError) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false return "", "", false
} }
@@ -182,14 +191,17 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUserID, ok := h.session.GetUserID(sess) sessionUserID, ok := h.session.GetUserID(sess)
if !ok { if !ok {
h.log.Error("authenticated session missing user ID") h.log.Error("authenticated session missing user ID")
h.renderError(w, r, http.StatusInternalServerError) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false return "", "", false
} }
// Only allow users to act on their own profile. // Only allow users to act on their own profile.
if requestedUsername != sessionUsername { if requestedUsername != sessionUsername {
h.renderError(w, r, http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return "", "", false return "", "", false
} }
+2 -4
View File
@@ -128,9 +128,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
var sess *session.Session var sess *session.Session
var h *handlers.Handlers app := newTestApp(t, &log, &cfg, &sess)
app := newTestApp(t, &log, &cfg, &sess, &h)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -141,7 +139,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
router := chi.NewRouter() router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) { router.Route("/user/{username}", func(r chi.Router) {
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden))) r.Use(mw.CSRF())
r.Use(mw.RequireAuth()) r.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) { r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true handlerReached = true
+77 -44
View File
@@ -149,7 +149,13 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
"user_id = ?", userID, "user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error ).Order("created_at DESC").Find(&webhooks).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to list webhooks", err) h.log.Error(
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
@@ -243,7 +249,9 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -303,7 +311,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
err := h.commitWebhook(webhook) err := h.commitWebhook(webhook)
if err != nil { if err != nil {
h.serverError(w, r, "failed to create webhook", err) h.serverError(w, "failed to create webhook", err)
return return
} }
@@ -380,7 +388,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -412,7 +420,7 @@ func (h *Handlers) renderSourceDetail(
if h.dbMgr.DBExists(webhook.ID) { if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get webhook database", err) h.serverError(w, "failed to get webhook database", err)
return return
} }
@@ -421,7 +429,7 @@ func (h *Handlers) renderSourceDetail(
webhookDB, webhook.ID, singleHTTPTargetID(targets), webhookDB, webhook.ID, singleHTTPTargetID(targets),
) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to load recent events", err) h.serverError(w, "failed to load recent events", err)
return return
} }
@@ -474,7 +482,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -509,7 +517,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -518,7 +526,9 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -572,7 +582,7 @@ func (h *Handlers) applyWebhookEdit(
err := h.db.DB().Save(webhook).Error err := h.db.DB().Save(webhook).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to update webhook", err) h.serverError(w, "failed to update webhook", err)
return return
} }
@@ -602,7 +612,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -629,7 +639,7 @@ func (h *Handlers) deleteWebhookResources(
// be removed by hand; deleted history cannot be recovered. // be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook) err := h.commitWebhookDeletion(&webhook)
if err != nil { if err != nil {
h.serverError(w, r, "failed to delete webhook", err) h.serverError(w, "failed to delete webhook", err)
return return
} }
@@ -655,7 +665,7 @@ func (h *Handlers) deleteWebhookResources(
// redirecting as though everything succeeded: the file // redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it. // needs removing by hand, and the logged error names it.
h.serverError( h.serverError(
w, r, "failed to delete webhook event database", err, w, "failed to delete webhook event database", err,
) )
return return
@@ -799,7 +809,7 @@ func (h *Handlers) ownedWebhook(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return database.Webhook{}, false return database.Webhook{}, false
} }
@@ -821,7 +831,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// Without the map every delivery renders through a // Without the map every delivery renders through a
// zero redactor, so failing the page is the only // zero redactor, so failing the page is the only
// safe answer. // safe answer.
h.serverError(w, r, "failed to load targets", err) h.serverError(w, "failed to load targets", err)
return return
} }
@@ -829,7 +839,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
page := h.parsePage(r) page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries( evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets, page, w, webhook, targets, page,
) )
if !ok { if !ok {
return return
@@ -939,7 +949,6 @@ func (h *Handlers) parsePage(r *http.Request) int {
// caller must then render nothing further. // caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries( func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
webhook database.Webhook, webhook database.Webhook,
targetMap map[string]eventLogTarget, targetMap map[string]eventLogTarget,
page int, page int,
@@ -953,7 +962,7 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to get webhook database", err, w, "failed to get webhook database", err,
) )
return nil, 0, false return nil, 0, false
@@ -990,7 +999,7 @@ func (h *Handlers) loadEventsWithDeliveries(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to load delivery attempts", err, w, "failed to load delivery attempts", err,
) )
return nil, 0, false return nil, 0, false
@@ -999,7 +1008,7 @@ func (h *Handlers) loadEventsWithDeliveries(
resubmits, err := resubmitCounts(webhookDB, eventIDs) resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to count event resubmissions", err, w, "failed to count event resubmissions", err,
) )
return nil, 0, false return nil, 0, false
@@ -1222,7 +1231,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -1231,7 +1240,9 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -1247,7 +1258,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
err = h.db.DB().Create(entrypoint).Error err = h.db.DB().Create(entrypoint).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to create entrypoint", err) h.serverError(w, "failed to create entrypoint", err)
return return
} }
@@ -1278,7 +1289,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -1287,7 +1298,9 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -1358,7 +1371,7 @@ func (h *Handlers) processTargetCreate(
err = h.db.DB().Create(target).Error err = h.db.DB().Create(target).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to create target", err) h.serverError(w, "failed to create target", err)
return return
} }
@@ -1452,7 +1465,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack: case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL) return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, r, in.Expiry) return h.buildDatabaseTargetConfig(w, in.Expiry)
case database.TargetTypeLog: case database.TargetTypeLog:
return "", nil return "", nil
default: default:
@@ -1502,7 +1515,7 @@ func (h *Handlers) buildHTTPTargetConfig(
return "", err return "", err
} }
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{ return marshalTargetConfig(w, delivery.HTTPTargetConfig{
URL: in.URL, URL: in.URL,
Headers: headers, Headers: headers,
Timeout: timeout, Timeout: timeout,
@@ -1524,7 +1537,7 @@ func (h *Handlers) buildSlackTargetConfig(
return "", err return "", err
} }
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{ return marshalTargetConfig(w, delivery.SlackTargetConfig{
WebhookURL: targetURL, WebhookURL: targetURL,
}) })
} }
@@ -1564,11 +1577,22 @@ func (h *Handlers) validateTargetURL(
"url", delivery.MaskURL(targetURL), "url", delivery.MaskURL(targetURL),
"error", err, "error", err,
) )
http.Error(
w, msg := "Invalid target URL: " + err.Error()
"Invalid target URL: "+err.Error(),
http.StatusBadRequest, // Only a private or reserved address's refusal says how
) // to allow it. Metadata refusals never do: link-local and
// the other unconditional metadata addresses cannot be
// opened, and the default blocklist's public addresses,
// which listing does open, hand out credentials.
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
msg += ". Private and reserved addresses are refused " +
"by default; the server's ALLOWED_EGRESS_CIDRS " +
"setting allows named networks (see \"Allowing " +
"egress to your own network\" in the README)."
}
http.Error(w, msg, http.StatusBadRequest)
return err return err
} }
@@ -1578,14 +1602,16 @@ func (h *Handlers) validateTargetURL(
// marshalTargetConfig serialises a target configuration for storage, // marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot. // writing a 500 itself if it cannot.
func (h *Handlers) marshalTargetConfig( func marshalTargetConfig(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
cfg any, cfg any,
) (string, error) { ) (string, error) {
configBytes, err := json.Marshal(cfg) configBytes, err := json.Marshal(cfg)
if err != nil { if err != nil {
h.serverError(w, r, "failed to encode target config", err) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err return "", err
} }
@@ -1601,7 +1627,6 @@ func (h *Handlers) marshalTargetConfig(
// expiry yields an empty config (the keep-forever default). // expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig( func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
expiry string, expiry string,
) (string, error) { ) (string, error) {
expiry = strings.TrimSpace(expiry) expiry = strings.TrimSpace(expiry)
@@ -1620,8 +1645,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
return "", err return "", err
} }
return h.marshalTargetConfig( return marshalTargetConfig(
w, r, map[string]any{"expiry": expiry}, w, map[string]any{"expiry": expiry},
) )
} }
@@ -1675,7 +1700,7 @@ func (h *Handlers) deleteChildResource(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -1685,7 +1710,11 @@ func (h *Handlers) deleteChildResource(
childID, webhook.ID, childID, webhook.ID,
).Delete(model) ).Delete(model)
if result.Error != nil { if result.Error != nil {
h.serverError(w, r, errMsg, result.Error) h.log.Error(errMsg, "error", result.Error)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
@@ -1775,14 +1804,18 @@ func (h *Handlers) toggleChildResource(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
err = toggleFn(webhook.ID, childID) err = toggleFn(webhook.ID, childID)
if err != nil { if err != nil {
h.serverError(w, r, errMsg, err) h.log.Error(errMsg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
+5 -3
View File
@@ -88,7 +88,9 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -155,7 +157,7 @@ func (h *Handlers) applyTargetEdit(
err = h.db.DB().Save(target).Error err = h.db.DB().Save(target).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to update target", err) h.serverError(w, "failed to update target", err)
return return
} }
@@ -218,7 +220,7 @@ func (h *Handlers) ownedTarget(
chi.URLParam(r, "targetID"), webhook.ID, chi.URLParam(r, "targetID"), webhook.ID,
).First(&target).Error ).First(&target).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return database.Webhook{}, nil, false return database.Webhook{}, nil, false
} }
@@ -0,0 +1,116 @@
package handlers_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// privateRefusalHint is the sentence that tells an operator a private
// destination is refused on purpose, and how to allow one.
const privateRefusalHint = "Private and reserved addresses are " +
"refused by default; the server's ALLOWED_EGRESS_CIDRS setting " +
"allows named networks (see \"Allowing egress to your own " +
"network\" in the README)."
// TestTargetRefusal_PrivateDestinationSaysHowToAllowIt covers both
// target types that take a URL, on add and on edit.
func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
targetTypes := []database.TargetType{
database.TargetTypeHTTP,
database.TargetTypeSlack,
}
for _, targetType := range targetTypes {
t.Run(string(targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
targetsPath := "/source/" + webhook.ID + "/targets"
form := url.Values{}
form.Set("name", "private")
form.Set("type", string(targetType))
form.Set("url", editBlockedURL)
added := serveTarget(
env, http.MethodPost, targetsPath, form,
)
assert.Equal(t, http.StatusBadRequest, added.Code)
assert.Contains(
t, added.Body.String(), privateRefusalHint,
)
form.Set("url", editOriginalURL)
created := serveTarget(
env, http.MethodPost, targetsPath, form,
)
require.Equal(
t, http.StatusSeeOther, created.Code,
created.Body.String(),
)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
form.Set("url", editBlockedURL)
edited := submitTargetEdit(
env, webhook.ID, targets[0].ID, form,
)
assert.Equal(t, http.StatusBadRequest, edited.Code)
assert.Contains(
t, edited.Body.String(), privateRefusalHint,
)
})
}
}
// TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt: no
// setting opens a link-local address, and Azure's WireServer hands out
// VM credentials, so neither refusal points at the setting.
func TestTargetRefusal_MetadataDestinationDoesNotSayHowToAllowIt(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
metadataURLs := map[string]string{
"link-local": "http://169.254.169.254/latest/meta-data/",
"wireserver": "http://168.63.129.16/?comp=versions",
}
for name, metadataURL := range metadataURLs {
t.Run(name, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "metadata")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", metadataURL)
w := serveTarget(
env, http.MethodPost,
"/source/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.NotContains(
t, w.Body.String(), privateRefusalHint,
)
})
}
}
+3 -16
View File
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
headersJSON, err := json.Marshal(r.Header) headersJSON, err := json.Marshal(r.Header)
if err != nil { if err != nil {
h.receiverError(w, "failed to serialize headers", err) h.serverError(w, "failed to serialize headers", err)
return return
} }
targets, err := h.loadActiveTargets(entrypoint.WebhookID) targets, err := h.loadActiveTargets(entrypoint.WebhookID)
if err != nil { if err != nil {
h.receiverError(w, "failed to query targets", err) h.serverError(w, "failed to query targets", err)
return return
} }
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
targets, targets,
) )
if err != nil { if err != nil {
h.receiverError(w, "failed to store webhook event", err) h.serverError(w, "failed to store webhook event", err)
return return
} }
@@ -204,19 +204,6 @@ func (h *Handlers) createAndDeliverEvent(
h.finishWebhookResponse(w, event, entrypoint, tasks) h.finishWebhookResponse(w, event, entrypoint, tasks)
} }
// receiverError logs an error and answers the sender with a plain-text
// 500. The receiver's answers are for programs, so it never sends the
// error page the web UI uses.
func (h *Handlers) receiverError(
w http.ResponseWriter, msg string, err error,
) {
h.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// eventSource carries the fields a new event is built from. The // eventSource carries the fields a new event is built from. The
// receiver fills it from the live request; the resubmit handler fills // receiver fills it from the live request; the resubmit handler fills
// it from a stored event. Both then go through createAndFanOut, so an // it from a stored event. Both then go through createAndFanOut, so an
+3 -5
View File
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
// key to sign a CSRF cookie and validates a masked token submitted via // key to sign a CSRF cookie and validates a masked token submitted via
// the "csrf_token" form field (or the "X-CSRF-Token" header) on // the "csrf_token" form field (or the "X-CSRF-Token" header) on
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing // POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
// token are logged and answered by forbidden, which must write the 403. // token receive a 403 Forbidden response.
// //
// The middleware detects the client-facing transport protocol // The middleware detects the client-facing transport protocol
// per-request via reqtls.IsTLS, the single TLS predicate the session // per-request via reqtls.IsTLS, the single TLS predicate the session
@@ -36,9 +36,7 @@ func CSRFToken(r *http.Request) string {
// Two gorilla/csrf instances are maintained — one with Secure cookies // Two gorilla/csrf instances are maintained — one with Secure cookies
// (for TLS) and one without (for plaintext HTTP) — because the // (for TLS) and one without (for plaintext HTTP) — because the
// csrf.Secure option is set at creation time, not per-request. // csrf.Secure option is set at creation time, not per-request.
func (m *Middleware) CSRF( func (m *Middleware) CSRF() func(http.Handler) http.Handler {
forbidden http.Handler,
) func(http.Handler) http.Handler {
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSRF is registered ahead of RequireAuth on every route // CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an // group that uses it, so this WARN is reachable by an
@@ -59,7 +57,7 @@ func (m *Middleware) CSRF(
"remote_addr", r.RemoteAddr, "remote_addr", r.RemoteAddr,
"reason", csrf.FailureReason(r), "reason", csrf.FailureReason(r),
) )
forbidden.ServeHTTP(w, r) http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
}) })
key := m.session.GetKey() key := m.session.GetKey()
+9 -15
View File
@@ -18,12 +18,6 @@ import (
// csrfCookieName is the gorilla/csrf cookie name. // csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf" const csrfCookieName = "_gorilla_csrf"
// forbidden stands in for the error page the server hands CSRF to
// answer a refused request with.
func forbidden(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}
// csrfGetToken performs a GET request through the CSRF middleware // csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies. // and returns the token and cookies.
func csrfGetToken( func csrfGetToken(
@@ -104,7 +98,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
var gotToken string var gotToken string
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc( handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) { func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r) gotToken = middleware.CSRFToken(r)
}, },
@@ -126,7 +120,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev) m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -158,7 +152,7 @@ func csrfPOSTWithoutTokenTest(
t.Helper() t.Helper()
m, _ := testMiddleware(t, env) m, _ := testMiddleware(t, env)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
// GET to establish the CSRF cookie // GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc( getHandler := csrfMW(http.HandlerFunc(
@@ -215,7 +209,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev) m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
// GET to establish the CSRF cookie // GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc( getHandler := csrfMW(http.HandlerFunc(
@@ -271,7 +265,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
var called bool var called bool
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc( handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) { func(_ http.ResponseWriter, _ *http.Request) {
called = true called = true
}, },
@@ -334,7 +328,7 @@ func csrfTookStrictPath(
t.Helper() t.Helper()
m, _ := testMiddleware(t, env) m, _ := testMiddleware(t, env)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
newReq := func(method string) *http.Request { newReq := func(method string) *http.Request {
r := httptest.NewRequestWithContext( r := httptest.NewRequestWithContext(
@@ -483,7 +477,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -523,7 +517,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -568,7 +562,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
+1 -3
View File
@@ -260,9 +260,7 @@ func logSites() map[string]logSite {
) http.Handler { ) http.Handler {
t.Helper() t.Helper()
return m.CSRF(http.HandlerFunc(forbidden))( return m.CSRF()(unreachable(t))
unreachable(t),
)
}, },
send: postNoToken, send: postNoToken,
wantStatus: http.StatusForbidden, wantStatus: http.StatusForbidden,
+3 -37
View File
@@ -109,8 +109,7 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
// Recoverer returns middleware that turns a handler panic into one // Recoverer returns middleware that turns a handler panic into one
// structured ERROR record and a 500, rather than a dropped // structured ERROR record and a 500, rather than a dropped
// connection. The 500 is page when page is not nil, and plain text // connection.
// when it is nil or when page panics before writing anything.
// //
// It replaces chi's middleware.Recoverer, which does neither on a // It replaces chi's middleware.Recoverer, which does neither on a
// current Go release. chi v1.5.5's pretty-printer scans the stack for // current Go release. chi v1.5.5's pretty-printer scans the stack for
@@ -137,13 +136,9 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
// //
// Unlike http.Error on its own, it deletes any Set-Cookie the handler // Unlike http.Error on its own, it deletes any Set-Cookie the handler
// set before panicking, because a request that failed must not hand // set before panicking, because a request that failed must not hand
// the client a credential. It touches no other header: when page // the client a credential; every other header is left to http.Error.
// answers, every other header the handler set goes out with it, apart
// from any page sets itself; otherwise they are left to http.Error.
// See https://git.eeqj.de/sneak/webhooker/issues/193. // See https://git.eeqj.de/sneak/webhooker/issues/193.
func (s *Middleware) Recoverer( func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
page http.Handler,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
w http.ResponseWriter, w http.ResponseWriter,
@@ -176,14 +171,6 @@ func (s *Middleware) Recoverer(
rw.Header().Del("Set-Cookie") rw.Header().Del("Set-Cookie")
if page != nil {
s.servePage(rw, r, page)
}
if rw.committed {
return
}
http.Error( http.Error(
rw, rw,
http.StatusText( http.StatusText(
@@ -198,27 +185,6 @@ func (s *Middleware) Recoverer(
} }
} }
// servePage answers with page. A panic in page itself is logged and
// recovered here, so the Recoverer can still send its plain 500.
func (s *Middleware) servePage(
w http.ResponseWriter,
r *http.Request,
page http.Handler,
) {
defer func() {
rvr := recover()
if rvr != nil {
s.log.Error("error page panic",
"panic", logfield.Truncate(
fmt.Sprint(rvr), maxPanicValueBytes,
),
)
}
}()
page.ServeHTTP(w, r)
}
// logPanic writes the record. Every field it can grow is truncated to // logPanic writes the record. Every field it can grow is truncated to
// a fixed budget, so MaxPanicLogLineBytes holds. // a fixed budget, so MaxPanicLogLineBytes holds.
// //
+2 -58
View File
@@ -76,7 +76,7 @@ func newRecovererProbe(
// Logging outside so the recovered 500 is the status it records. // Logging outside so the recovered 500 is the status it records.
router.Use(chimw.RequestID) router.Use(chimw.RequestID)
router.Use(m.Logging()) router.Use(m.Logging())
router.Use(m.Recoverer(nil)) router.Use(m.Recoverer())
router.Get("/probe", handler) router.Get("/probe", handler)
serverErrors := new(bytes.Buffer) serverErrors := new(bytes.Buffer)
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
m, _ := capturingMiddleware(t) m, _ := capturingMiddleware(t)
handler := m.Recoverer(nil)(http.HandlerFunc( handler := m.Recoverer()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("chunk")) _, _ = w.Write([]byte("chunk"))
@@ -672,59 +672,3 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "chunk", string(body)) assert.Equal(t, "chunk", string(body))
} }
// TestRecovererAnswersWithThePage covers a recoverer given a page:
// the panic is logged as before, and the 500 is that page.
func TestRecovererAnswersWithThePage(t *testing.T) {
t.Parallel()
m, logs := capturingMiddleware(t)
page := http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("the error page"))
},
)
w := httptest.NewRecorder()
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
),
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "the error page", w.Body.String())
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
assert.Contains(t, logs.String(), panicMarker)
}
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
// before writing anything: both panics are logged, and the client
// still gets the plain 500.
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
t.Parallel()
m, logs := capturingMiddleware(t)
const pagePanic = "QQERRORPAGEPANICQQ"
page := http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {
panic(pagePanic)
},
)
w := httptest.NewRecorder()
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
),
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
assert.Contains(t, logs.String(), panicMarker)
assert.Contains(t, logs.String(), pagePanic)
}
-220
View File
@@ -1,220 +0,0 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/getsentry/sentry-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/server"
)
// The link back the error page offers: to the webhook list for a
// signed-in user, to sign-in for anyone else.
const (
backToWebhooks = `<a href="/sources" class="btn-secondary">` +
`Back to webhooks</a>`
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
`Sign in</a>`
)
// assertErrorPage checks that w is the error page for status, in the
// normal layout, offering link.
func assertErrorPage(
t *testing.T,
w *httptest.ResponseRecorder,
status int,
link string,
) {
t.Helper()
body := w.Body.String()
assert.Equal(t, status, w.Code)
assert.Equal(
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
)
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
assert.Contains(t, body, `<nav class="app-bar"`)
assert.Contains(
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
)
assert.Contains(t, body, link)
}
func TestErrorPage_DeletedWebhook(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Delete(wh).Error)
w := env.get("/source/"+wh.ID, cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_DeletedTarget(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID)
require.NoError(t, env.db.DB().Delete(tgt).Error)
w := env.get(
"/source/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_UnknownPath(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
assertErrorPage(
t, env.get("/no-such-page", nil),
http.StatusNotFound, backToSignIn,
)
// Outside every route group there is no form token, so the
// page leaves out the logout form rather than offer one that
// would be refused.
w := env.get("/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
// Inside a route group the page has a token, and logout works.
wh := env.seedWebhook(t, userID)
w = env.get("/source/"+wh.ID+"/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
}
func TestErrorPage_BadCSRFToken(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
form := url.Values{}
form.Set("username", "someone")
form.Set("password", "irrelevant")
form.Set("csrf_token", "not-a-token")
assertErrorPage(
t, env.post("/pages/login", form, nil),
http.StatusForbidden, backToSignIn,
)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
edit := url.Values{}
edit.Set("name", "renamed")
assertErrorPage(
t, env.post("/source/"+wh.ID+"/edit", edit, cookies),
http.StatusForbidden, backToWebhooks,
)
}
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
// admin page route group through the real router, with error
// tracking on: the client gets the 500 error page, and the tracker
// still gets the panic, once. The same panic outside the admin page
// route groups keeps the plain 500.
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
transport := &captureTransport{}
opts := server.SentryClientOptionsForTest(
"https://public@sentry.invalid/1", "webhooker-test",
)
opts.Transport = transport
client, err := sentry.NewClient(opts)
require.NoError(t, err)
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
sentry.SetHubOnContext(
context.Background(),
sentry.NewHub(client, sentry.NewScope()),
),
http.MethodGet, path, nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := serve(
server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.PageProbePattern,
)
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
w = serve(
server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.ProbePattern,
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
require.Len(t, transport.events, 2)
for _, event := range transport.events {
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
}
}
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
// the web UI only: a sender posting to an entrypoint that does not
// exist still gets the plain-text answer.
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
t.Parallel()
// newTestEnv leaves the receiver rate limit at zero, which
// refuses every request before it reaches the receiver.
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: 10,
})
w := env.post("/webhook/no-such-entrypoint", url.Values{}, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "404 page not found\n", w.Body.String())
}
-37
View File
@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
@@ -102,39 +101,3 @@ func NewRouterWithProbeForTest(
return s.router return s.router
} }
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
// probe: inside the /pages route group, the admin page group a
// request reaches without signing in.
const PageProbePattern = "/pages/probe"
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
// probe added to the /pages route group once SetupRoutes has built
// it, so the probe runs behind that group's own middleware exactly as
// the group's real routes do.
func NewRouterWithPageProbeForTest(
log *slog.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
sentryEnabled bool,
probe http.HandlerFunc,
) http.Handler {
s := &Server{
log: log,
mw: mw,
h: h,
params: ServerParams{Config: cfg},
}
s.sentryEnabled.Store(sentryEnabled)
s.SetupRoutes()
for _, route := range s.router.Routes() {
pages, ok := route.SubRoutes.(chi.Router)
if ok && route.Pattern == "/pages/*" {
pages.Get("/probe", probe)
}
}
return s.router
}
+13 -44
View File
@@ -15,10 +15,9 @@ import (
// bytes) for form POST endpoints. 1 MB is generous for any form // bytes) for form POST endpoints. 1 MB is generous for any form
// submission while preventing abuse from oversized payloads. // submission while preventing abuse from oversized payloads.
// //
// The four admin page route groups below (/pages, /user/{username}, // Every route group below installs MaxBodySize(maxFormBodySize) as
// /sources and /source/{sourceID}) install MaxBodySize(maxFormBodySize) // its FIRST middleware, ahead of both CSRF and RequireAuth. Both
// right after their recoverer and error reporting, ahead of both CSRF // orderings are deliberate.
// and RequireAuth. Both orderings are deliberate.
// //
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to // Ahead of CSRF because gorilla/csrf parses the form. The cap has to
// be installed before anything reads the body, or the parse runs // be installed before anything reads the body, or the parse runs
@@ -47,14 +46,6 @@ const requestTimeout = 60 * time.Second
// server's router. // server's router.
func (s *Server) SetupRoutes() { func (s *Server) SetupRoutes() {
s.router = chi.NewRouter() s.router = chi.NewRouter()
// An unknown path gets the error page. Registered before the
// global middleware, because chi wraps a not-found handler in the
// middleware already on its router, which would then run twice.
// The route groups below wrap it in their own middleware the same
// way; running theirs twice is harmless.
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
s.setupGlobalMiddleware() s.setupGlobalMiddleware()
s.setupRoutes() s.setupRoutes()
} }
@@ -78,33 +69,23 @@ func (s *Server) setupGlobalMiddleware() {
// Panic recovery, deliberately here rather than first. It has to // Panic recovery, deliberately here rather than first. It has to
// run inside every middleware that observes the response, so the // run inside every middleware that observes the response, so the
// 500 it writes is the status the access log records and the // 500 it writes is the status the access log records and the
// metrics count, and outside the sentryhttp handler, whose // metrics count, and outside the sentryhttp handler below, whose
// Repanic option needs something further out to catch what it // Repanic option needs something further out to catch what it
// re-raises. chi's own middleware.Recoverer held the first slot // re-raises. chi's own middleware.Recoverer held the first slot
// until it was measured: on a current Go release it crashes // until it was measured: on a current Go release it crashes
// inside its stack pretty-printer instead of recovering, so the // inside its stack pretty-printer instead of recovering, so the
// connection dropped and the original panic was never reported. // connection dropped and the original panic was never reported.
// See https://git.eeqj.de/sneak/webhooker/issues/187. // See https://git.eeqj.de/sneak/webhooker/issues/187.
s.recoverPanics(s.router, nil) s.router.Use(s.mw.Recoverer())
}
// recoverPanics installs on r the recoverer, answering a panic with
// page (a plain 500 when page is nil), and inside it the Sentry error
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
// bubble up to the recoverer.
//
// Each admin page route group installs its own, with the error page,
// as its first middleware. A panic there is logged, reported and
// answered inside the group and never reaches the global recoverer,
// which keeps the plain 500 for every other route.
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
r.Use(s.mw.Recoverer(page))
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
// true so panics still bubble up to the Recoverer middleware
// registered immediately above.
if s.sentryEnabled.Load() { if s.sentryEnabled.Load() {
sentryHandler := sentryhttp.New(sentryhttp.Options{ sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true, Repanic: true,
}) })
r.Use(sentryHandler.Handle) s.router.Use(sentryHandler.Handle)
} }
} }
@@ -166,13 +147,10 @@ func (s *Server) setupRoutes() {
func (s *Server) setupPageRoutes() { func (s *Server) setupPageRoutes() {
s.router.Route("/pages", func(r chi.Router) { s.router.Route("/pages", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden))) r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache()) r.Use(s.mw.NoCache())
// The login POST carries no pre-emptive rate limiter. Behind // The login POST carries no pre-emptive rate limiter. Behind
@@ -191,13 +169,10 @@ func (s *Server) setupPageRoutes() {
func (s *Server) setupUserRoutes() { func (s *Server) setupUserRoutes() {
s.router.Route("/user/{username}", func(r chi.Router) { s.router.Route("/user/{username}", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden))) r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache()) r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleProfile()) r.Get("/", s.h.HandleProfile())
@@ -209,13 +184,10 @@ func (s *Server) setupUserRoutes() {
func (s *Server) setupSourceRoutes() { func (s *Server) setupSourceRoutes() {
s.router.Route("/sources", func(r chi.Router) { s.router.Route("/sources", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden))) r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache()) r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceList()) r.Get("/", s.h.HandleSourceList())
@@ -224,13 +196,10 @@ func (s *Server) setupSourceRoutes() {
}) })
s.router.Route("/source/{sourceID}", func(r chi.Router) { s.router.Route("/source/{sourceID}", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden))) r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache()) r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceDetail()) r.Get("/", s.h.HandleSourceDetail())
-15
View File
@@ -1,15 +0,0 @@
{{template "base" .}}
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-4xl mx-auto px-6 py-12">
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
<p class="text-gray-600 mb-6">{{.Message}}</p>
{{if .User}}
<a href="/sources" class="btn-secondary">Back to webhooks</a>
{{else}}
<a href="/pages/login" class="btn-primary">Sign in</a>
{{end}}
</div>
{{end}}
-6
View File
@@ -24,14 +24,10 @@
</svg> </svg>
{{.User.Username}} {{.User.Username}}
</a> </a>
{{/* An error page can be served before a form token is issued,
and a logout without one is refused. */}}
{{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline"> <form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button> <button type="submit" class="btn-text">Logout</button>
</form> </form>
{{end}}
{{else}} {{else}}
<a href="/pages/login" class="btn-primary">Login</a> <a href="/pages/login" class="btn-primary">Login</a>
{{end}} {{end}}
@@ -44,12 +40,10 @@
{{if .User}} {{if .User}}
<a href="/sources" class="btn-text w-full text-left">Webhooks</a> <a href="/sources" class="btn-text w-full text-left">Webhooks</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a> <a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
{{if .CSRFToken}}
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button> <button type="submit" class="btn-text w-full text-left">Logout</button>
</form> </form>
{{end}}
{{else}} {{else}}
<a href="/pages/login" class="btn-primary w-full">Login</a> <a href="/pages/login" class="btn-primary w-full">Login</a>
{{end}} {{end}}