check / check (push) Waiting to run
A target's settings move from the per-type JSON string in targets.config to columns: url, headers, timeout_seconds, forward_query, archive_expiry and archive_rotation. A column a type does not use stays empty. headers holds the header map as JSON text through GORM's JSON serializer, and archive_expiry holds a time.Duration, 0 meaning never. The forms check each setting once, with the same refusals and messages, and the edit form is filled from the columns, so the per-type config structs, their parsers and the "could not be read" branches are gone. A delivery task carries the six settings in place of the JSON string; tests check that each copy into a task, and back out of one, keeps all six. Model: opus-5-5
358 lines
10 KiB
Go
358 lines
10 KiB
Go
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 ""
|
|
}
|