All checks were successful
check / check (push) Successful in 2m54s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503 and no hash runs for it. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
182 lines
4.9 KiB
Go
182 lines
4.9 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.
|
|
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.Recoverer)
|
|
s.router.Use(middleware.RequestID)
|
|
s.router.Use(s.mw.SecurityHeaders())
|
|
s.router.Use(s.mw.Logging())
|
|
|
|
// Metrics middleware (only if credentials are configured)
|
|
if s.params.Config.MetricsUsername != "" {
|
|
s.router.Use(s.mw.Metrics())
|
|
}
|
|
|
|
s.router.Use(s.mw.CORS())
|
|
s.router.Use(middleware.Timeout(requestTimeout))
|
|
|
|
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
|
// true so panics still bubble up to the Recoverer middleware.
|
|
if s.sentryEnabled {
|
|
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(),
|
|
)
|
|
|
|
// set up authenticated /metrics route:
|
|
if s.params.Config.MetricsUsername != "" {
|
|
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 must precede CSRF: gorilla/csrf parses the
|
|
// form, so the cap has to be installed before it runs.
|
|
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 must precede CSRF: gorilla/csrf parses the
|
|
// form, so the cap has to be installed before it runs.
|
|
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 must precede CSRF: gorilla/csrf parses the
|
|
// form, so the cap has to be installed before it runs.
|
|
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 must precede CSRF: gorilla/csrf parses the
|
|
// form, so the cap has to be installed before it runs.
|
|
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())
|
|
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())
|
|
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(),
|
|
)
|
|
}
|