All checks were successful
check / check (push) Successful in 2m54s
The SSRF blocklist had no escape hatch, so the thing webhooker is mostly for — taking a public webhook and forwarding it to something on your own network — could not be configured at all. Every private address, Docker sibling and loopback service was permanently unreachable as a delivery destination. ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery targets may reach despite the default blocklist. It is an allowlist and only ever adds destinations: there is no boolean, and no value disables SSRF protection wholesale. Empty, the guard behaves exactly as before. A fixed set of addresses is refused before the allowlist is consulted, so no supplied CIDR opens one — not the exact address, not a supernet, not 0.0.0.0/0 or ::/0. It is the two link-local blocks (169.254.0.0/16, fe80::/10) plus host routes for the cloud metadata endpoints that sit outside them: AWS's IPv6 IMDS at fd00:ec2::254, which lives in ordinary ULA space, and Alibaba's 100.100.100.200, which lives in CGNAT. Allowlisting fd00::/8 or 100.64.0.0/10 (Tailscale's range) is an ordinary thing for an operator to do and must not reopen instance-credential theft. The IPv4-compatible (::a9fe:a9fe) and NAT64 (64:ff9b::a9fe:a9fe) spellings of 169.254.169.254 are listed too, because To4() does not normalise them into the link-local block the way it does the IPv4-mapped form. Reaching any of these is credential theft rather than delivery to an internal service. The policy now lives in one function, Guard.checkIP, which both target-creation validation and the delivery dialer call. The two paths previously decided separately, which is how they came to disagree about a destination. The guard is built once from config and injected via fx into both the handlers and the delivery engine, so there is a single instance and a single answer. A set-but-unparseable value aborts startup naming the variable, reusing the existing envPrefixList parser. A non-empty list is logged at startup with the blocks spelled out, not counted, so the hole is visible in the log of any deployment that has one. Tests: an allowlisted loopback CIDR both validates and delivers to a live server (and the same URL still fails without the allowlist); a private address outside the listed block stays refused on both paths; every unconditionally blocked address stays refused on both paths under an allowlist that covers it, and the set itself is pinned entry by entry; public addresses are unaffected either way; and config coverage for parsing, startup abort, and the warning's contents.
291 lines
7.3 KiB
Go
291 lines
7.3 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/metrics"
|
|
"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
|
|
SSRFGuard *delivery.Guard
|
|
}
|
|
|
|
// 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
|
|
mtr *metrics.Set
|
|
templates map[string]*template.Template
|
|
|
|
// ssrf validates submitted target URLs. It is the same guard
|
|
// the delivery engine dials through, so a URL accepted here
|
|
// is one delivery will actually attempt.
|
|
ssrf *delivery.Guard
|
|
|
|
// 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
|
|
s.mtr = metrics.Default()
|
|
s.ssrf = params.SSRFGuard
|
|
|
|
// 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"),
|
|
"target_edit.html": parsePageTemplate("target_edit.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,
|
|
)
|
|
}
|
|
}
|