All checks were successful
check / check (push) Successful in 3m41s
The plaintext listener bound `:PORT`, so it answered on every interface with no way to say otherwise. That published the admin UI and the unauthenticated receiver in cleartext beside whatever TLS proxy was in front of them, reachable from any host that could route to the machine. BIND_ADDRESS now selects the address. The binary defaults to 127.0.0.1, which is the safe answer for a bare host: reaching webhooker from elsewhere becomes a deliberate act. The image sets 0.0.0.0, which is the correct answer inside a container, where the network namespace is already the boundary and exposure is decided by the publish flag instead — so `-p 127.0.0.1:8080:8080` is what the README shows. Existing container deployments are unaffected. Only IP address literals are accepted: hostnames, host:port and CIDR blocks abort startup naming the variable and the value, and a literal that is not an address of this host fails at listen and exits non-zero. The http.Server is now built in New rather than in the serving goroutine, and sentryEnabled is atomic. Both fields were written by the serving goroutine and read by the fx stop hook with nothing ordering them, and the OnStart hook returns before that goroutine has necessarily run: cleanShutdown could dereference a nil httpServer on an early SIGTERM, and both reads raced. No test started and stopped the server, so nothing observed it. Closes #226. README gains a "Deployment behind a reverse proxy" section: a working nginx server block, and the five things that are silent when wrong — bind or firewall the app port, WEBHOOKER_ENVIRONMENT=prod, TRUSTED_PROXIES, Host as $http_host rather than $host, and keeping the proxy's access log because webhooker's own records only the proxy.
272 lines
9.0 KiB
Go
272 lines
9.0 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
|
"github.com/go-chi/chi"
|
|
"github.com/go-chi/chi/middleware"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"sneak.berlin/go/webhooker/static"
|
|
)
|
|
|
|
// maxFormBodySize is the maximum allowed request body size (in
|
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
|
// submission while preventing abuse from oversized payloads.
|
|
//
|
|
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
|
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
|
|
// orderings are deliberate.
|
|
//
|
|
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
|
// be installed before anything reads the body, or the parse runs
|
|
// under net/http's 10 MB default instead of this one.
|
|
//
|
|
// Ahead of RequireAuth because an oversize body should be refused
|
|
// before the request buys a cookie decrypt, a session load and the
|
|
// database read behind it. Rejecting first is the cheaper failure,
|
|
// and it is the ordering that keeps an unauthenticated flood from
|
|
// choosing how much session work the process does.
|
|
//
|
|
// What that ordering costs: the 413 branch is reachable
|
|
// unauthenticated, at a URL of the client's choosing and of the
|
|
// client's chosen length. So is the CSRF rejection, which sits in
|
|
// front of RequireAuth for the same reason. Both log that path, so
|
|
// both cap it — see the log calls in Middleware.MaxBodySize and
|
|
// Middleware.CSRF, which spend the same per-field budget as the
|
|
// access log.
|
|
const maxFormBodySize int64 = 1 * 1024 * 1024 // 1 MB
|
|
|
|
// requestTimeout is the maximum time allowed for a single HTTP
|
|
// request.
|
|
const requestTimeout = 60 * time.Second
|
|
|
|
// SetupRoutes configures all HTTP routes and middleware on the
|
|
// server's router.
|
|
func (s *Server) SetupRoutes() {
|
|
s.router = chi.NewRouter()
|
|
s.setupGlobalMiddleware()
|
|
s.setupRoutes()
|
|
}
|
|
|
|
func (s *Server) setupGlobalMiddleware() {
|
|
s.router.Use(middleware.RequestID)
|
|
s.router.Use(s.mw.SecurityHeaders())
|
|
s.router.Use(s.mw.Logging())
|
|
|
|
// Metrics recording middleware, registered only when the
|
|
// endpoint that exposes what it records is served. The
|
|
// condition is the same MetricsAuthEnabled the /metrics mount
|
|
// in setupRoutes reads.
|
|
if s.params.Config.MetricsAuthEnabled() {
|
|
s.router.Use(s.mw.Metrics())
|
|
}
|
|
|
|
s.router.Use(s.mw.CORS())
|
|
s.router.Use(middleware.Timeout(requestTimeout))
|
|
|
|
// Panic recovery, deliberately here rather than first. It has to
|
|
// run inside every middleware that observes the response, so the
|
|
// 500 it writes is the status the access log records and the
|
|
// metrics count, and outside the sentryhttp handler below, whose
|
|
// Repanic option needs something further out to catch what it
|
|
// re-raises. chi's own middleware.Recoverer held the first slot
|
|
// until it was measured: on a current Go release it crashes
|
|
// inside its stack pretty-printer instead of recovering, so the
|
|
// connection dropped and the original panic was never reported.
|
|
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
|
s.router.Use(s.mw.Recoverer())
|
|
|
|
// 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() {
|
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
|
Repanic: true,
|
|
})
|
|
s.router.Use(sentryHandler.Handle)
|
|
}
|
|
}
|
|
|
|
func (s *Server) setupRoutes() {
|
|
s.router.Get("/", s.h.HandleIndex())
|
|
|
|
s.router.Mount(
|
|
"/s",
|
|
http.StripPrefix("/s", http.FileServer(http.FS(static.Static))),
|
|
)
|
|
|
|
s.router.Route("/api/v1", func(_ chi.Router) {
|
|
// API routes will be added here.
|
|
})
|
|
|
|
s.router.Get(
|
|
"/.well-known/healthcheck",
|
|
s.h.HandleHealthCheck(),
|
|
)
|
|
|
|
// Authenticated /metrics route. The condition is
|
|
// Config.MetricsAuthEnabled and never the username alone: a
|
|
// username with an empty password would otherwise mount the
|
|
// endpoint behind a credential map that accepts an empty
|
|
// password. Config rejects that combination at startup, and
|
|
// this reads the same value the startup log reports, so the
|
|
// two cannot disagree about whether the route exists.
|
|
if s.params.Config.MetricsAuthEnabled() {
|
|
s.router.Group(func(r chi.Router) {
|
|
r.Use(s.mw.MetricsAuth())
|
|
r.Get(
|
|
"/metrics",
|
|
http.HandlerFunc(
|
|
promhttp.Handler().ServeHTTP,
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
s.setupPageRoutes()
|
|
s.setupUserRoutes()
|
|
s.setupSourceRoutes()
|
|
s.setupWebhookRoutes()
|
|
}
|
|
|
|
func (s *Server) setupPageRoutes() {
|
|
s.router.Route("/pages", func(r chi.Router) {
|
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
// see maxFormBodySize for why, and for what it costs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF())
|
|
r.Use(s.mw.NoCache())
|
|
|
|
// The login POST carries no pre-emptive rate limiter. Behind
|
|
// the reverse proxy production requires, with TRUSTED_PROXIES
|
|
// unset, every client shares one bucket, so a limiter spent
|
|
// on arrival lets any stranger deny the operator the only
|
|
// administrative path. The handler verifies credentials first
|
|
// and charges only failures; see Handlers.authenticateUser.
|
|
r.Get("/login", s.h.HandleLoginPage())
|
|
r.Post("/login", s.h.HandleLoginSubmit())
|
|
|
|
r.Post("/logout", s.h.HandleLogout())
|
|
})
|
|
}
|
|
|
|
func (s *Server) setupUserRoutes() {
|
|
s.router.Route("/user/{username}", func(r chi.Router) {
|
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
// see maxFormBodySize for why, and for what it costs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF())
|
|
r.Use(s.mw.NoCache())
|
|
r.Use(s.mw.RequireAuth())
|
|
r.Get("/", s.h.HandleProfile())
|
|
r.With(s.mw.PasswordChangeRateLimit()).Post(
|
|
"/password", s.h.HandlePasswordChange(),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (s *Server) setupSourceRoutes() {
|
|
s.router.Route("/sources", func(r chi.Router) {
|
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
// see maxFormBodySize for why, and for what it costs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF())
|
|
r.Use(s.mw.NoCache())
|
|
r.Use(s.mw.RequireAuth())
|
|
r.Get("/", s.h.HandleSourceList())
|
|
r.Get("/new", s.h.HandleSourceCreate())
|
|
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
|
})
|
|
|
|
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
// see maxFormBodySize for why, and for what it costs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF())
|
|
r.Use(s.mw.NoCache())
|
|
r.Use(s.mw.RequireAuth())
|
|
r.Get("/", s.h.HandleSourceDetail())
|
|
r.Get("/edit", s.h.HandleSourceEdit())
|
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
|
r.Post("/delete", s.h.HandleSourceDelete())
|
|
r.Get("/logs", s.h.HandleSourceLogs())
|
|
// The log page renders each body only up to its cap, so
|
|
// this is the only route that serves a whole one. It
|
|
// belongs to this group for its RequireAuth and
|
|
// NoCache; see HandleEventBodyDownload for the headers
|
|
// that keep the bytes it returns inert.
|
|
r.Get(
|
|
"/logs/{eventID}/body",
|
|
s.h.HandleEventBodyDownload(),
|
|
)
|
|
// Replay is the one page action that queues outbound work:
|
|
// it creates a delivery from a stored event and hands it to
|
|
// the delivery engine. The rate limit is what bounds a
|
|
// held-down button or a scripted loop; the handler
|
|
// separately refuses a replay while an earlier one for the
|
|
// same event and target is still in flight. POST only, so
|
|
// the action cannot be taken by a link, a prefetch or an
|
|
// image tag.
|
|
r.With(s.mw.ReplayRateLimit()).Post(
|
|
"/deliveries/{deliveryID}/replay",
|
|
s.h.HandleDeliveryReplay(),
|
|
)
|
|
// Resubmit is the other page action that queues outbound
|
|
// work: it copies a stored event into a new one and fans
|
|
// that out to every currently active target. It is
|
|
// deliberately repeatable, so the rate limit is the only
|
|
// bound on a held-down button; it gets its own bucket so
|
|
// that spending it does not also disable replay. POST
|
|
// only, so the action cannot be taken by a link, a
|
|
// prefetch or an image tag.
|
|
r.With(s.mw.ResubmitRateLimit()).Post(
|
|
"/events/{eventID}/resubmit",
|
|
s.h.HandleEventResubmit(),
|
|
)
|
|
r.Post(
|
|
"/entrypoints",
|
|
s.h.HandleEntrypointCreate(),
|
|
)
|
|
r.Post(
|
|
"/entrypoints/{entrypointID}/delete",
|
|
s.h.HandleEntrypointDelete(),
|
|
)
|
|
r.Post(
|
|
"/entrypoints/{entrypointID}/toggle",
|
|
s.h.HandleEntrypointToggle(),
|
|
)
|
|
r.Post("/targets", s.h.HandleTargetCreate())
|
|
// The edit form is the one page that renders a target's
|
|
// destination URL and header values in full; see
|
|
// delivery.TargetConfigForm. It belongs to this group for
|
|
// its RequireAuth and NoCache, which are what keep that
|
|
// exception from reaching an unauthenticated request or a
|
|
// shared cache.
|
|
r.Get(
|
|
"/targets/{targetID}/edit",
|
|
s.h.HandleTargetEdit(),
|
|
)
|
|
r.Post(
|
|
"/targets/{targetID}/edit",
|
|
s.h.HandleTargetEditSubmit(),
|
|
)
|
|
r.Post(
|
|
"/targets/{targetID}/delete",
|
|
s.h.HandleTargetDelete(),
|
|
)
|
|
r.Post(
|
|
"/targets/{targetID}/toggle",
|
|
s.h.HandleTargetToggle(),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (s *Server) setupWebhookRoutes() {
|
|
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
|
"/webhook/{uuid}",
|
|
s.h.HandleWebhook(),
|
|
)
|
|
}
|