All checks were successful
check / check (push) Successful in 2m46s
In the shipped default, any stranger denied the operator the only administrative path at 5 requests per minute: TRUSTED_PROXIES is empty, the README requires a reverse proxy, so every login POST shared one bucket keyed on the proxy. Credentials are now verified first and only a FAILED attempt spends budget, so a correct password is never throttled. Failures are counted per (client bucket, submitted username), bounded. Concurrent Argon2id verifications are capped at two, and the queue for them at 16 — because verifying first lets an attacker force a 64 MB hash per request, and bounding the wait alone bounds nothing. The issue's own recommendation was insufficient and is rejected here: keying by username stops an attacker locking out a DIFFERENT account, but this is a single-admin product with a predictable bootstrap username, so flooding the operator's own name still locks them out. This is speculative — it implements a corrected recommendation ahead of the owner's ruling so the decision can be made by merging or reverting. Three things are disclosed rather than glossed: online guessing rises from 5/min to roughly 27/s, because the 429 is a label on the response and not a gate in front of the hash; the residual exposure is a loss of login AVAILABILITY, not latency, and a determined flood still denies login while it runs, at ~400x the cost and clearing the moment it stops; and the endpoint should be provisioned for ~400 MB resident, not the 203 MB of live commitment it itemises. Independently reviewed four times. Reviewers disproved the suspected FIFO starvation by measurement, then caught two successive memory bounds the code did not have — the second by parking waiters and reading the heap rather than checking the arithmetic.
280 lines
6.9 KiB
Go
280 lines
6.9 KiB
Go
// Package handlers provides HTTP request handlers for the
|
|
// webhooker web UI and API.
|
|
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync/atomic"
|
|
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
"sneak.berlin/go/webhooker/templates"
|
|
)
|
|
|
|
const (
|
|
// maxBodyShift is the bit shift for 1 MB body limit.
|
|
maxBodyShift = 20
|
|
// recentEventLimit is the number of recent events to show.
|
|
recentEventLimit = 20
|
|
// paginationPerPage is the number of items per page.
|
|
paginationPerPage = 25
|
|
|
|
// tmplKeyError is the template data key for an error message.
|
|
tmplKeyError = "Error"
|
|
// tmplKeyWebhook is the template data key for a webhook.
|
|
tmplKeyWebhook = "Webhook"
|
|
)
|
|
|
|
// errInvalidPassword is returned when a password does not match.
|
|
var errInvalidPassword = errors.New("invalid password")
|
|
|
|
// errVerificationBusy is returned when no password-verification slot
|
|
// became free before the wait elapsed, so no password was verified.
|
|
var errVerificationBusy = errors.New(
|
|
"password verification capacity exhausted",
|
|
)
|
|
|
|
//nolint:revive // HandlersParams is a standard fx naming convention.
|
|
type HandlersParams struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Database *database.Database
|
|
WebhookDBMgr *database.WebhookDBManager
|
|
Healthcheck *healthcheck.Healthcheck
|
|
Session *session.Session
|
|
Middleware *middleware.Middleware
|
|
Notifier delivery.Notifier
|
|
Evictor delivery.WebhookEvictor
|
|
}
|
|
|
|
// Handlers provides HTTP handler methods for all application
|
|
// routes.
|
|
type Handlers struct {
|
|
params *HandlersParams
|
|
log *slog.Logger
|
|
hc *healthcheck.Healthcheck
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
session *session.Session
|
|
mw *middleware.Middleware
|
|
notifier delivery.Notifier
|
|
evictor delivery.WebhookEvictor
|
|
templates map[string]*template.Template
|
|
|
|
// dummyVerifications counts the equivalent-cost verifications
|
|
// charged for usernames that do not exist. It exists so a test
|
|
// can prove that path runs without measuring wall-clock time.
|
|
dummyVerifications atomic.Uint64
|
|
}
|
|
|
|
// parsePageTemplate parses a page-specific template set from the
|
|
// embedded FS. Each page template is combined with the shared
|
|
// base, htmlheader, and navbar templates. The page file must be
|
|
// listed first so that its root action ({{template "base" .}})
|
|
// becomes the template set's entry point.
|
|
func parsePageTemplate(pageFile string) *template.Template {
|
|
return template.Must(
|
|
template.ParseFS(
|
|
templates.Templates,
|
|
pageFile,
|
|
"base.html",
|
|
"htmlheader.html",
|
|
"navbar.html",
|
|
),
|
|
)
|
|
}
|
|
|
|
// New creates a Handlers instance, parsing all page templates at
|
|
// startup.
|
|
func New(
|
|
lc fx.Lifecycle,
|
|
params HandlersParams,
|
|
) (*Handlers, error) {
|
|
s := new(Handlers)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
s.hc = params.Healthcheck
|
|
s.db = params.Database
|
|
s.dbMgr = params.WebhookDBMgr
|
|
s.session = params.Session
|
|
s.mw = params.Middleware
|
|
s.notifier = params.Notifier
|
|
s.evictor = params.Evictor
|
|
|
|
// Parse all page templates once at startup
|
|
s.templates = map[string]*template.Template{
|
|
"login.html": parsePageTemplate("login.html"),
|
|
"profile.html": parsePageTemplate("profile.html"),
|
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
|
"source_detail.html": parsePageTemplate("source_detail.html"),
|
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
|
}
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(_ context.Context) error {
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Handlers) respondJSON(
|
|
w http.ResponseWriter,
|
|
_ *http.Request,
|
|
data any,
|
|
status int,
|
|
) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
|
|
if data != nil {
|
|
err := json.NewEncoder(w).Encode(data)
|
|
if err != nil {
|
|
s.log.Error("json encode error", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// serverError logs an error and sends a 500 response.
|
|
func (s *Handlers) serverError(
|
|
w http.ResponseWriter, msg string, err error,
|
|
) {
|
|
s.log.Error(msg, "error", err)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
}
|
|
|
|
// UserInfo represents user information for templates
|
|
type UserInfo struct {
|
|
ID string
|
|
Username string
|
|
}
|
|
|
|
// templateDataWrapper wraps non-map data with common fields.
|
|
type templateDataWrapper struct {
|
|
User *UserInfo
|
|
CSRFToken string
|
|
Data any
|
|
}
|
|
|
|
// getUserInfo extracts user info from the session.
|
|
func (s *Handlers) getUserInfo(
|
|
r *http.Request,
|
|
) *UserInfo {
|
|
sess, err := s.session.Get(r)
|
|
if err != nil || !s.session.IsAuthenticated(sess) {
|
|
return nil
|
|
}
|
|
|
|
username, ok := s.session.GetUsername(sess)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
userID, ok := s.session.GetUserID(sess)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return &UserInfo{ID: userID, Username: username}
|
|
}
|
|
|
|
// renderTemplate renders a pre-parsed template with common
|
|
// data
|
|
func (s *Handlers) renderTemplate(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
pageTemplate string,
|
|
data any,
|
|
) {
|
|
tmpl, ok := s.templates[pageTemplate]
|
|
if !ok {
|
|
s.log.Error(
|
|
"template not found",
|
|
"template", pageTemplate,
|
|
)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
userInfo := s.getUserInfo(r)
|
|
csrfToken := middleware.CSRFToken(r)
|
|
|
|
if m, ok := data.(map[string]any); ok {
|
|
m["User"] = userInfo
|
|
m["CSRFToken"] = csrfToken
|
|
s.executeTemplate(w, tmpl, m)
|
|
|
|
return
|
|
}
|
|
|
|
wrapper := templateDataWrapper{
|
|
User: userInfo,
|
|
CSRFToken: csrfToken,
|
|
Data: data,
|
|
}
|
|
|
|
s.executeTemplate(w, tmpl, wrapper)
|
|
}
|
|
|
|
// executeTemplate renders the template into a buffer and writes to
|
|
// the response only once rendering has fully succeeded. Executing
|
|
// straight into the ResponseWriter commits a partial body and a 200
|
|
// status before a mid-render error can be reported, leaving no way
|
|
// to serve a 500. Buffering makes a page's rendered size resident
|
|
// memory per concurrent viewer, so every page owes it a bound: the
|
|
// event log caps each stored body at maxRenderedBodyBytes for exactly
|
|
// this reason.
|
|
func (s *Handlers) executeTemplate(
|
|
w http.ResponseWriter,
|
|
tmpl *template.Template,
|
|
data any,
|
|
) {
|
|
var buf bytes.Buffer
|
|
|
|
err := tmpl.Execute(&buf, data)
|
|
if err != nil {
|
|
s.log.Error(
|
|
"failed to execute template", "error", err,
|
|
)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
_, err = buf.WriteTo(w)
|
|
if err != nil {
|
|
s.log.Error(
|
|
"failed to write rendered page", "error", err,
|
|
)
|
|
}
|
|
}
|