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.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 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.Group(func(r chi.Router) { r.Use(s.mw.LoginRateLimit()) 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(), ) 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(), ) }