Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// HandleTargetCreate handles adding a new target to a webhook.
|
||||
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.getUserID(r)
|
||||
if !ok {
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sourceID := chi.URLParam(r, "sourceID")
|
||||
|
||||
h.archives.RenameLock().Lock()
|
||||
defer h.archives.RenameLock().Unlock()
|
||||
|
||||
var webhook database.Webhook
|
||||
|
||||
err := h.db.DB().Where(
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The body size cap is enforced by the MaxBodySize
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.processTargetCreate(w, r, webhook)
|
||||
}
|
||||
}
|
||||
|
||||
// processTargetCreate validates and creates a new target. A refused
|
||||
// submission shows the webhook page again, with the add target form
|
||||
// open on the chosen type, the values entered, and the reason.
|
||||
func (h *Handlers) processTargetCreate(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
) {
|
||||
in := targetFormInputFrom(r)
|
||||
|
||||
target, errMsg := h.newTarget(r.Context(), webhook.ID, in)
|
||||
if errMsg != "" {
|
||||
h.renderSourceDetail(w, r, webhook, in, errMsg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := h.db.DB().Create(target).Error
|
||||
if err != nil {
|
||||
h.serverError(w, r, "failed to create target", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
// newTarget validates a new target for a webhook and returns the row
|
||||
// to create, or, when it refuses the target, the message the form
|
||||
// shows. Every form that creates a target goes through here, so they
|
||||
// all accept and refuse the same things.
|
||||
func (h *Handlers) newTarget(
|
||||
ctx context.Context,
|
||||
webhookID string,
|
||||
in targetFormInput,
|
||||
) (*database.Target, string) {
|
||||
target := &database.Target{
|
||||
WebhookID: webhookID,
|
||||
Type: in.Type,
|
||||
Active: true,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
|
||||
errMsg := h.setTargetFromForm(ctx, target, in)
|
||||
if errMsg != "" {
|
||||
return nil, errMsg
|
||||
}
|
||||
|
||||
return target, ""
|
||||
}
|
||||
|
||||
// setTargetFromForm validates a target form against the target's type
|
||||
// and sets the target's name, settings and number of delivery attempts
|
||||
// from it. It returns the message the form shows for anything it
|
||||
// refuses, an unknown type among them; the caller then discards the
|
||||
// target, which may be partly set.
|
||||
// The add target form and the target edit form both go through here,
|
||||
// so the two cannot come to disagree about what a target may be.
|
||||
func (h *Handlers) setTargetFromForm(
|
||||
ctx context.Context,
|
||||
target *database.Target,
|
||||
in targetFormInput,
|
||||
) string {
|
||||
if in.Name == "" {
|
||||
return "Name is required"
|
||||
}
|
||||
|
||||
errMsg := h.setTargetSettings(ctx, target, in)
|
||||
if errMsg != "" {
|
||||
return errMsg
|
||||
}
|
||||
|
||||
// An empty max_attempts keeps the target's count: the default of
|
||||
// 1 for a new target, and the stored count for an edited one,
|
||||
// since the forms for target types that do not retry have no such
|
||||
// field. A value that is filled in but invalid is refused rather
|
||||
// than becoming that count, so a typo cannot destroy the count a
|
||||
// target is delivering with.
|
||||
maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts)
|
||||
if err != nil {
|
||||
return "Invalid delivery attempts: " + attemptsErrorMessage(err)
|
||||
}
|
||||
|
||||
target.Name = in.Name
|
||||
target.MaxAttempts = maxAttempts
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// targetFormInput carries the raw values of a target form. Both the
|
||||
// create and the edit path fill one and hand it to setTargetFromForm,
|
||||
// so neither can come to validate a target differently from the
|
||||
// other. Both forms are filled from one: the edit form with the
|
||||
// stored values, and a refused form with the values submitted.
|
||||
type targetFormInput struct {
|
||||
// Name is the target's name.
|
||||
Name string
|
||||
// Type is the type chosen on the add target form. The edit form
|
||||
// has none: a target's stored type decides.
|
||||
Type database.TargetType
|
||||
// URL is the destination for an HTTP target and the webhook URL
|
||||
// for a Slack target.
|
||||
URL string
|
||||
// Headers is an HTTP target's headers, one "Name: value" per
|
||||
// line.
|
||||
Headers string
|
||||
// Timeout is an HTTP target's per-request timeout in seconds.
|
||||
Timeout string
|
||||
// ForwardQuery is an HTTP target's checkbox that passes each
|
||||
// event's query string on to it.
|
||||
ForwardQuery bool
|
||||
// MaxAttempts is an HTTP or Slack target's max_attempts.
|
||||
MaxAttempts string
|
||||
// Expiry is a database (archive) target's row expiry.
|
||||
Expiry string
|
||||
// Rotation is a database (archive) target's rotation.
|
||||
Rotation string
|
||||
}
|
||||
|
||||
// targetFormInputFrom reads a target form from a request body. The
|
||||
// body size cap is enforced by the MaxBodySize middleware, which runs
|
||||
// before CSRF parses the form.
|
||||
//
|
||||
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||
// falls back to the query string, which would let
|
||||
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// configure a target from a value the request line carries — and the
|
||||
// request line, unlike the body, is what logs, proxies, Referer
|
||||
// headers and error trackers record. The headers field is under the
|
||||
// same rule and for the same reason: its values are authorization
|
||||
// tokens.
|
||||
func targetFormInputFrom(r *http.Request) targetFormInput {
|
||||
return targetFormInput{
|
||||
Name: r.PostFormValue("name"),
|
||||
Type: database.TargetType(r.PostFormValue("type")),
|
||||
URL: r.PostFormValue("url"),
|
||||
Headers: r.PostFormValue("headers"),
|
||||
Timeout: r.PostFormValue("timeout"),
|
||||
ForwardQuery: r.PostFormValue("forward_query") != "",
|
||||
MaxAttempts: r.PostFormValue("max_attempts"),
|
||||
Expiry: r.PostFormValue("expiry"),
|
||||
Rotation: r.PostFormValue("rotation"),
|
||||
}
|
||||
}
|
||||
|
||||
// setTargetSettings sets a target's settings from the submitted form
|
||||
// values, or returns the message the form shows for a value it
|
||||
// refuses. Which fields of in apply depends on the target type; a
|
||||
// type without a URL ignores any URL submitted, and the settings a
|
||||
// type does not use are left empty.
|
||||
func (h *Handlers) setTargetSettings(
|
||||
ctx context.Context,
|
||||
target *database.Target,
|
||||
in targetFormInput,
|
||||
) string {
|
||||
switch target.Type {
|
||||
case database.TargetTypeHTTP:
|
||||
return h.setHTTPTargetSettings(ctx, target, in)
|
||||
case database.TargetTypeSlack:
|
||||
return h.setSlackTargetSettings(ctx, target, in.URL)
|
||||
case database.TargetTypeDatabase:
|
||||
return setDatabaseTargetSettings(target, in.Expiry, in.Rotation)
|
||||
case database.TargetTypeLog:
|
||||
return ""
|
||||
default:
|
||||
return "Invalid target type"
|
||||
}
|
||||
}
|
||||
|
||||
// setHTTPTargetSettings sets an HTTP target's SSRF-validated
|
||||
// destination plus the optional headers, timeout and query string
|
||||
// setting the delivery path honours.
|
||||
func (h *Handlers) setHTTPTargetSettings(
|
||||
ctx context.Context,
|
||||
target *database.Target,
|
||||
in targetFormInput,
|
||||
) string {
|
||||
errMsg := h.validateTargetURL(
|
||||
ctx, in.URL, "URL is required for HTTP targets",
|
||||
)
|
||||
if errMsg != "" {
|
||||
return errMsg
|
||||
}
|
||||
|
||||
headers, err := delivery.ParseTargetHeaders(in.Headers)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Invalid headers: %v", err)
|
||||
}
|
||||
|
||||
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Invalid timeout: %v", err)
|
||||
}
|
||||
|
||||
target.URL = in.URL
|
||||
target.Headers = headers
|
||||
target.TimeoutSeconds = timeout
|
||||
target.ForwardQuery = in.ForwardQuery
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// setSlackTargetSettings sets a Slack target's one setting, its
|
||||
// SSRF-validated webhook URL.
|
||||
func (h *Handlers) setSlackTargetSettings(
|
||||
ctx context.Context,
|
||||
target *database.Target,
|
||||
targetURL string,
|
||||
) string {
|
||||
errMsg := h.validateTargetURL(
|
||||
ctx, targetURL,
|
||||
"Webhook URL is required for Slack targets",
|
||||
)
|
||||
if errMsg != "" {
|
||||
return errMsg
|
||||
}
|
||||
|
||||
target.URL = targetURL
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateTargetURL refuses an empty or SSRF-blocked destination,
|
||||
// returning the message the form shows, or "" when the destination
|
||||
// is accepted. missingMsg is the message for no URL at all.
|
||||
//
|
||||
// It is the single point at which a user-supplied destination enters
|
||||
// the SSRF guard, on create and on edit alike. An edit path that
|
||||
// reached storage without passing through here would reopen the hole
|
||||
// the guard closes.
|
||||
func (h *Handlers) validateTargetURL(
|
||||
ctx context.Context,
|
||||
targetURL, missingMsg string,
|
||||
) string {
|
||||
if targetURL == "" {
|
||||
return missingMsg
|
||||
}
|
||||
|
||||
err := h.ssrf.ValidateTargetURL(ctx, targetURL)
|
||||
if err != nil {
|
||||
// The submitted URL can be a credential (a Slack
|
||||
// incoming webhook URL is a bearer token), so the log
|
||||
// records only its scheme and host.
|
||||
h.log.Warn(
|
||||
"target URL blocked by SSRF protection",
|
||||
"url", delivery.MaskURL(targetURL),
|
||||
"error", err,
|
||||
)
|
||||
|
||||
msg := "Invalid target URL: " + err.Error()
|
||||
|
||||
// Only a private or reserved address's refusal says how
|
||||
// to allow it. Other refusals never do: link-local, the
|
||||
// unspecified addresses and the unconditional metadata
|
||||
// addresses cannot be opened, and the default
|
||||
// blocklist's public addresses, which listing does open,
|
||||
// hand out credentials.
|
||||
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
||||
msg += ". Private and reserved addresses are refused " +
|
||||
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
||||
"setting allows named networks (see \"Allowing " +
|
||||
"egress to your own network\" in the README)."
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// setDatabaseTargetSettings sets a database (archive) target's
|
||||
// expiry and rotation. Both are optional: an empty or never expiry
|
||||
// keeps the archive forever, and an empty rotation is stored as none,
|
||||
// one file. A bad value is refused here, at the form, instead of
|
||||
// being stored.
|
||||
func setDatabaseTargetSettings(
|
||||
target *database.Target,
|
||||
expiry, rotation string,
|
||||
) string {
|
||||
archiveExpiry, err := archive.ParseExpiry(strings.TrimSpace(expiry))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Invalid archive expiry: %v", err)
|
||||
}
|
||||
|
||||
err = archive.ValidateRotation(rotation)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Invalid archive rotation: %v", err)
|
||||
}
|
||||
|
||||
if rotation == "" {
|
||||
rotation = archiveRotationNone
|
||||
}
|
||||
|
||||
target.ArchiveExpiry = archiveExpiry
|
||||
target.ArchiveRotation = rotation
|
||||
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user